diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index b9aa260635cf..d669a3c66533 100644 --- a/.github/playwright/impact-map.generated.json +++ b/.github/playwright/impact-map.generated.json @@ -3328,6 +3328,7 @@ "playwright/e2e/Features/AdvancedSearch.spec.ts", "playwright/e2e/Features/ContextCenterDashboard.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", + "playwright/e2e/Features/DataQuality/ProfilerIngestionForm.spec.ts", "playwright/e2e/Features/DataQuality/TableTestCaseExportFilters.spec.ts", "playwright/e2e/Features/DomainFilterQueryFilter.spec.ts", "playwright/e2e/Features/LandingPageWidgets/LandingPageContextCenter.spec.ts", @@ -8669,6 +8670,7 @@ "openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/AddIngestion.component.tsx" ], "specs": [ + "playwright/e2e/Features/DataQuality/ProfilerIngestionForm.spec.ts", "playwright/e2e/Features/StorageMetadataAgentForm.spec.ts" ] }, diff --git a/docs/decisions/2026-10-13-ui-page-sizes-lists-15-25-50-grids-12-24-48.md b/docs/decisions/2026-10-13-ui-page-sizes-lists-15-25-50-grids-12-24-48.md new file mode 100644 index 000000000000..f5a13d2a6acb --- /dev/null +++ b/docs/decisions/2026-10-13-ui-page-sizes-lists-15-25-50-grids-12-24-48.md @@ -0,0 +1,55 @@ +# Paged UI lists step 15/25/50 and paged card grids step 12/24/48 + +- **Status:** Accepted +- **Revisions:** v1 2026-10-13 (initial) +- **Deciders:** Sweta Agarwalla +- **Guard:** `PaginationUtils.test.ts` (`view page sizes` describe block) +- **Related:** — + +## Context +Page sizes had drifted page by page: 10, 9, 20 and 5 sat beside the app-wide 15/25/50, a card grid +offered 15/25/50 (rows that never fill), and one list offered 12/24/48. The core-components +`PaginationCardWithControls` defaulted to 10 with 10/25/50, so any page that did not pass its own +options showed a scale no other page used. The `pageSize` URL param and the `globalPageSize` +preference are shared across pages, so a size from one scale could land in a picker with no +matching entry. + +## Decision +- A paged **list or table** defaults to 15 and offers 15/25/50 (`LIST_PAGE_SIZE_OPTIONS`, + `PAGE_SIZE_BASE` in `src/constants/constants.ts`). +- A paged **card grid** defaults to 12 and offers 12/24/48 (`GRID_PAGE_SIZE_OPTIONS`, + `GRID_PAGE_SIZE_BASE`), so rows of 2, 3 or 4 cards fill evenly. +- A page with a table/card toggle takes its scale from the view it is in + (`getViewPageSizeOptions` / `getViewPageSize` in `utils/PaginationUtils.ts`) + and returns to page 1 when the view changes. +- A grid page passes `GRID_PAGE_SIZE_OPTIONS` to `usePaging` so a list-scale size from the shared + URL is reconciled to its own scale. +- The size the user picks is remembered per scale (`useSavedPageSize`): list sizes in + `globalPageSize`, grid sizes in `globalGridPageSize`. A page that switches views comes back to + the size last picked in each, and a grid size never lands in a list picker. +- Every paged view starts from that saved size; no page sets a fixed starting size of its own. + This includes views with no size picker (Metrics, KPIs, Domains, Data Products, reindex + failures, Context Center memories). A size carried in the URL (`pageSize` param or settings + hash) still wins, so a shared link opens at its own size, but only for the view it was opened + in: a table/card switch drops it from the URL, and the new view (and any later switch back) + starts at the saved size for its scale. A size the current view does not offer is ignored, and + so are the page and cursor counted at it: the view opens on page 1 at its saved size and drops + the link's paging from the URL or hash. Only the visible route rewrites the URL, since app mode + keeps hidden routes mounted on it. When the saved size changes while a view stays mounted, its + page returns to 1 before the next request, unless the URL carries its own size: that page, such + as one restored by browser Back, is kept. Fixed + fetch batches that are not a paged view (e.g. loading all glossary terms, approval tasks) are + out of scope. +- `usePaging` treats a caller that declares no options as on the list scale, so a grid size left + in the shared URL param, or an off-scale size an older build stored in the preference, never + reaches a 15/25/50 picker. +- Shared defaults (core `PaginationCardWithControls`, `TableV2` client paging, the atoms listing + hooks) are 15. +- Out of scope: fetch limits that are not a paged view — typeahead/suggestion searches, tree-select + batches, count-only queries, bulk fetches, and "show more" previews. + +## Consequences +A new paged view picks one of the two scales instead of inventing a size. The two scales share no +value, so switching a page between list and grid changes its size to the one last picked on the +new scale (its first step until the user picks one). Playwright specs that wait on a list request's `limit`/`size` must use the scale of the page +they open. Adding a third scale, or a page size outside these, means amending this record. diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/pagination/pagination.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/pagination/pagination.tsx index 202c48404f81..59c1d684afcc 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/pagination/pagination.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/pagination/pagination.tsx @@ -417,8 +417,8 @@ interface PaginationCardWithControlsProps extends PaginationProps { export const PaginationCardWithControls = ({ page = 1, total = 10, - pageSize = 10, - pageSizeOptions = [10, 25, 50], + pageSize = 15, + pageSizeOptions = [15, 25, 50], className, minimal = false, onPageChange, diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/stories/Pagination.stories.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/stories/Pagination.stories.tsx index d40b7ad3c229..ea9ebaa7ab3f 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/stories/Pagination.stories.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/stories/Pagination.stories.tsx @@ -113,7 +113,7 @@ export const ButtonGroup: StoryObj = { export const CardWithControls: StoryObj = { render: () => { const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + const [pageSize, setPageSize] = useState(15); const totalPages = Math.max(Math.ceil(TOTAL_ITEMS / pageSize), 1); return ( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/AppStopRunModal.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/AppStopRunModal.spec.ts index c6bdafca60bf..699d95da6074 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/AppStopRunModal.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/AppStopRunModal.spec.ts @@ -81,7 +81,7 @@ test.describe('App Stop Run Modal', () => { }); const appsResponse = page.waitForResponse( - `**/api/v1/apps?limit=15&include=non-deleted` + `**/api/v1/apps?limit=12&include=non-deleted` ); await settingClick(page, GlobalSettingOptions.APPLICATIONS); await appsResponse; @@ -141,7 +141,7 @@ test.describe('App Stop Run Modal', () => { }); const appsResponse = page.waitForResponse( - `**/api/v1/apps?limit=15&include=non-deleted` + `**/api/v1/apps?limit=12&include=non-deleted` ); await settingClick(page, GlobalSettingOptions.APPLICATIONS); await appsResponse; @@ -195,7 +195,7 @@ test.describe('App Stop Run Modal', () => { }); const appsResponse = page.waitForResponse( - `**/api/v1/apps?limit=15&include=non-deleted` + `**/api/v1/apps?limit=12&include=non-deleted` ); await settingClick(page, GlobalSettingOptions.APPLICATIONS); await appsResponse; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataInsightReportApplication.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataInsightReportApplication.spec.ts index c4f88454e08e..1c1676a4a68e 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataInsightReportApplication.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataInsightReportApplication.spec.ts @@ -51,7 +51,7 @@ test.describe.serial('Data Insight Report Application', () => { await redirectToHomePage(page); const appsResponse = page.waitForResponse( - `/api/v1/apps?limit=15&include=non-deleted` + `/api/v1/apps?limit=12&include=non-deleted` ); await settingClick(page, GlobalSettingOptions.APPLICATIONS); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataInsightSettings.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataInsightSettings.spec.ts index da487bd0decd..1383fd4c00cb 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataInsightSettings.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataInsightSettings.spec.ts @@ -41,7 +41,7 @@ test.describe.serial( await redirectToHomePage(page); const appsResponse = page.waitForResponse( - `/api/v1/apps?limit=15&include=non-deleted` + `/api/v1/apps?limit=12&include=non-deleted` ); await settingClick(page, GlobalSettingOptions.APPLICATIONS); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts index ae5bfd38e257..a50cb1308530 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts @@ -583,7 +583,7 @@ test.describe('Domains', () => { await Promise.all([ createSubDomain(page, subDomain.data), page.waitForResponse( - '/api/v1/search/query?q=&index=domain&from=0&size=9&deleted=false*' + '/api/v1/search/query?q=&index=domain&from=0&size=15&deleted=false*' ), ]); @@ -631,7 +631,7 @@ test.describe('Domains', () => { await selectDomain(page, domain.data); const subDomainApiRes1 = page.waitForResponse( - '/api/v1/search/query?q=&index=domain&from=0&size=9&deleted=false*' + '/api/v1/search/query?q=&index=domain&from=0&size=15&deleted=false*' ); // Create new sub domain under the existing sub domain diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts index 0ed302002e60..40a7003164be 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts @@ -47,7 +47,7 @@ interface AppRunRecordResponse { */ const installSearchIndexApplication = async (page: Page) => { const getMarketPlaceResponse = page.waitForResponse( - '/api/v1/apps/marketplace?limit=15' + '/api/v1/apps/marketplace?limit=12' ); await page.click('[data-testid="add-application"]'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SubDomainPagination.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SubDomainPagination.spec.ts index 72a6e8e8622f..063097443a83 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SubDomainPagination.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SubDomainPagination.spec.ts @@ -30,7 +30,7 @@ test.use({ storageState: 'playwright/.auth/admin.json' }); const domain = new Domain(); const subDomains: SubDomain[] = []; const SUBDOMAIN_COUNT = 60; -const PAGE_SIZE = 9; +const PAGE_SIZE = 15; test.describe('SubDomain Pagination', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { test.beforeAll('Setup domain and subdomains', async ({ browser }) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.desync.real-hook.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.desync.real-hook.test.tsx index c356f9d9b2e9..ef080c73bd3b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.desync.real-hook.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.desync.real-hook.test.tsx @@ -24,11 +24,12 @@ import AlertRecentEventsTab from './AlertRecentEventsTab'; // Only useCurrentUserPreferences is stubbed: the real usePaging hook, its // useTableFilters URL-write path, and the real NextPreviousWithOffset // component are all exercised under MemoryRouter. +// The preference must be a size on the list scale (15/25/50): usePaging replaces any other value. const mockSetPreference = jest.fn(); jest.mock('../../../../hooks/currentUserStore/useCurrentUserStore', () => ({ useCurrentUserPreferences: () => ({ - preferences: { globalPageSize: 5 }, + preferences: { globalPageSize: 25 }, setPreference: mockSetPreference, }), })); @@ -105,34 +106,34 @@ describe('AlertRecentEventsTab pagination/filter reset (real usePaging + real Ne it('resets currentPage to 1 on filter change and keeps Next navigation contiguous', async () => { await renderWithRouter(); - expect(indicator()).toHaveTextContent('1/20'); + expect(indicator()).toHaveTextContent('1/4'); await clickNext(); - expect(indicator()).toHaveTextContent('2/20'); + expect(indicator()).toHaveTextContent('2/4'); await clickNext(); - expect(indicator()).toHaveTextContent('3/20'); - expect(lastCallParams().paginationOffset).toBe(10); + expect(indicator()).toHaveTextContent('3/4'); + expect(lastCallParams().paginationOffset).toBe(50); await selectFilter('label.successful'); expect(await screen.findByTestId('applied-filter-text')).toHaveTextContent( ': label.successful' ); - expect(indicator()).toHaveTextContent('1/20'); + expect(indicator()).toHaveTextContent('1/4'); expect(lastCallParams().paginationOffset).toBe(0); expect(lastCallParams().status).toBe(AlertRecentEventFilters.SUCCESSFUL); await clickNext(); - expect(indicator()).toHaveTextContent('2/20'); - expect(lastCallParams().paginationOffset).toBe(5); + expect(indicator()).toHaveTextContent('2/4'); + expect(lastCallParams().paginationOffset).toBe(25); await clickPrevious(); - expect(indicator()).toHaveTextContent('1/20'); + expect(indicator()).toHaveTextContent('1/4'); expect(lastCallParams().paginationOffset).toBe(0); }); @@ -161,8 +162,8 @@ describe('AlertRecentEventsTab pagination/filter reset (real usePaging + real Ne it('loads a URL-restored currentPage and resets it on the next filter interaction', async () => { await renderWithRouter('/alert?currentPage=3'); - expect(indicator()).toHaveTextContent('3/20'); - expect(lastCallParams().paginationOffset).toBe(10); + expect(indicator()).toHaveTextContent('3/4'); + expect(lastCallParams().paginationOffset).toBe(50); // Changing the filter resets the page to 1 and rewrites the URL. await selectFilter('label.successful'); @@ -170,7 +171,7 @@ describe('AlertRecentEventsTab pagination/filter reset (real usePaging + real Ne expect(await screen.findByTestId('applied-filter-text')).toHaveTextContent( ': label.successful' ); - expect(indicator()).toHaveTextContent('1/20'); + expect(indicator()).toHaveTextContent('1/4'); expect(lastCallParams().paginationOffset).toBe(0); expect(lastCallParams().status).toBe(AlertRecentEventFilters.SUCCESSFUL); expect(screen.getByTestId('location-search').textContent).toContain( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/ModelTab/ModelTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/ModelTab/ModelTab.component.tsx index ef5ddaf71900..b189269b63b7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/ModelTab/ModelTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/ModelTab/ModelTab.component.tsx @@ -15,7 +15,6 @@ import { groupBy, isEmpty, omit, uniqBy } from 'lodash'; import { EntityTags, TagFilterOptions } from 'Models'; import { lazy, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { PAGE_SIZE_LARGE } from '../../../../../constants/constants'; import { COMMON_STATIC_TABLE_VISIBLE_COLUMNS, DEFAULT_DASHBOARD_DATA_MODEL_VISIBLE_COLUMNS, @@ -91,7 +90,7 @@ const ModelTab = () => { showPagination, paging, handlePagingChange, - } = usePaging(PAGE_SIZE_LARGE); + } = usePaging(); const { data: dataModel, permissions } = useGenericContext(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSchemaFormTab/ContractScehmaFormTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSchemaFormTab/ContractScehmaFormTab.tsx index c9dd736e0b61..9ac1510d32bc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSchemaFormTab/ContractScehmaFormTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSchemaFormTab/ContractScehmaFormTab.tsx @@ -15,10 +15,7 @@ import { isEmpty, pick, uniqBy } from 'lodash'; import { Key, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants'; -import { - NO_DATA_PLACEHOLDER, - PAGE_SIZE_MEDIUM, -} from '../../../constants/constants'; +import { NO_DATA_PLACEHOLDER } from '../../../constants/constants'; import { TABLE_COLUMNS_KEYS } from '../../../constants/TableKeys.constants'; import { EntityType, @@ -97,7 +94,7 @@ export const ContractSchemaFormTab: React.FC<{ handlePageSizeChange, handlePagingChange, showPagination, - } = usePaging(PAGE_SIZE_MEDIUM); + } = usePaging(); const handleChangeTable = useCallback( (selectedRowKeys: Key[]) => { setSelectedKeys(selectedRowKeys as string[]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/DataProductListPage.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/DataProductListPage.tsx index 7b95c4495ccd..1eafe1cfa554 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/DataProductListPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/DataProductListPage.tsx @@ -43,12 +43,14 @@ import { VIEW_MODE_PAGE } from '../../constants/platform/personaAppLayout.consta import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { DataProduct } from '../../generated/entity/domains/dataProduct'; import { PageViewMode } from '../../generated/type/personaPreferences'; +import { useSavedPageSize } from '../../hooks/paging/useSavedPageSize'; import { usePersonaViewMode } from '../../hooks/platform/usePersonaViewMode'; import { useIsAiMode } from '../../hooks/useAppMode'; import { useMarketplaceStore } from '../../hooks/useMarketplaceStore'; import dataProductClassBase from '../../utils/DataProduct/DataProductClassBase'; import { getEntityName } from '../../utils/EntityNameUtils'; import { getEntityAvatarProps } from '../../utils/IconUtils'; +import { getViewPageSizeOptions } from '../../utils/PaginationUtils'; import { renderBreakableTooltip } from '../../utils/TooltipUtils'; import { useDelete } from '../common/atoms/actions/useDelete'; import { @@ -153,7 +155,26 @@ const DataProductListPage = ({ const { isMarketplace, dataProductBasePath } = useMarketplaceStore(); const { t } = useTranslation(); const isAiMode = useIsAiMode(); - const dataProductListing = useDataProductListingData({ enableNlq: isAiMode }); + const personaView = usePersonaViewMode(VIEW_MODE_PAGE.DataProducts); + const [selectedView, setView] = useState(); + const view = selectedView ?? personaView; + const listingPageSizeOptions = getViewPageSizeOptions(view); + const { savedPageSize: listingPageSize } = useSavedPageSize( + listingPageSizeOptions + ); + const dataProductListing = useDataProductListingData({ + enableNlq: isAiMode, + pageSize: listingPageSize, + pageSizeOptions: listingPageSizeOptions, + }); + const { resetPaging } = dataProductListing; + const handleViewChange = useCallback( + (nextView: PageViewMode) => { + setView(nextView); + resetPaging?.(); + }, + [resetPaging] + ); const { permissions } = usePermissionProvider(); const { quickFilters, defaultFilters } = useDataProductFilters({ aggregations: dataProductListing.aggregations || undefined, @@ -233,9 +254,6 @@ const DataProductListPage = ({ loading: dataProductListing.loading, }); - const personaView = usePersonaViewMode(VIEW_MODE_PAGE.DataProducts); - const [selectedView, setView] = useState(); - const view = selectedView ?? personaView; const { renderDataProductCard } = useDomainCardTemplates(); const extraColumns = dataProductClassBase.getListingExtraColumns(); @@ -457,7 +475,7 @@ const DataProductListPage = ({ )} {quickFilters} - + {deleteIconButton} {filterSelectionDisplay} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/hooks/useDataProductListingData.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/hooks/useDataProductListingData.tsx index 0ea35431c75a..bef03adeb2d9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/hooks/useDataProductListingData.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/hooks/useDataProductListingData.tsx @@ -13,7 +13,7 @@ import { Avatar, Typography } from '@openmetadata/ui-core-components'; import { useCallback, useMemo } from 'react'; -import { TABLE_CARD_PAGE_SIZE } from '../../../constants/constants'; +import { PAGE_SIZE_BASE } from '../../../constants/constants'; import { SearchIndex } from '../../../enums/search.enum'; import { DataProduct } from '../../../generated/entity/domains/dataProduct'; import { useMarketplaceStore } from '../../../hooks/useMarketplaceStore'; @@ -33,7 +33,13 @@ import { export const useDataProductListingData = ({ enableNlq, -}: { enableNlq?: boolean } = {}): ListingData => { + pageSize = PAGE_SIZE_BASE, + pageSizeOptions, +}: { + enableNlq?: boolean; + pageSize?: number; + pageSizeOptions?: number[]; +} = {}): ListingData => { const { dataProductBasePath } = useMarketplaceStore(); // Memoized because a ClassBase override may build these per call, and // `filterConfigs` feeds a useMemo dep list down in useUrlState. @@ -117,7 +123,8 @@ export const useDataProductListingData = ({ const listingData = useListingData({ searchIndex: SearchIndex.DATA_PRODUCT, baseFilter: '', // No parent filter for data products - pageSize: TABLE_CARD_PAGE_SIZE, + pageSize, + pageSizeOptions, filterKeys, filterConfigs, columns, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/InputOutputPortsTab/PortsListView/PortsListView.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/InputOutputPortsTab/PortsListView/PortsListView.component.tsx index 1b1e2ccd06f5..e6d6e0ecbb8c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/InputOutputPortsTab/PortsListView/PortsListView.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/InputOutputPortsTab/PortsListView/PortsListView.component.tsx @@ -25,7 +25,6 @@ import { } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as DeleteIcon } from '../../../../assets/svg/ic-delete.svg'; -import { PAGE_SIZE_BASE } from '../../../../constants/constants'; import { EntityReference } from '../../../../generated/type/entityReference'; import { usePaging } from '../../../../hooks/paging/usePaging'; import { @@ -74,7 +73,7 @@ const PortsListView = forwardRef( handlePageSizeChange, handlePagingChange, showPagination, - } = usePaging(PAGE_SIZE_BASE); + } = usePaging(); const fetchPorts = useCallback( async (page = 1) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ColumnProfileTable/ColumnProfileTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ColumnProfileTable/ColumnProfileTable.tsx index 60a3a4b09c36..68245ea2542c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ColumnProfileTable/ColumnProfileTable.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ColumnProfileTable/ColumnProfileTable.tsx @@ -22,7 +22,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import type { SortDescriptor } from 'react-aria-components'; import { useTranslation } from 'react-i18next'; import { Link, useParams } from 'react-router-dom'; -import { PAGE_SIZE_LARGE } from '../../../../../constants/constants'; import { ERROR_PLACEHOLDER_TYPE } from '../../../../../enums/common.enum'; import { EntityTabs, @@ -101,7 +100,7 @@ const ColumnProfileTable = () => { handlePageChange, handlePageSizeChange, handlePagingChange, - } = usePaging(PAGE_SIZE_LARGE); + } = usePaging(); const tableDetailsWithColumns = useMemo( () => ({ ...tableProfiler, columns: data as Column[] } as TableType), diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx index c0fde233b131..2bb1a6e4c6ac 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx @@ -34,7 +34,6 @@ import { ReactComponent as IconSort } from '../../../assets/svg/ic-sort-both.svg import { INITIAL_PAGING_VALUE, NO_DATA_PLACEHOLDER, - PAGE_SIZE_LARGE, } from '../../../constants/constants'; import { COLUMN_CONSTRAINT_TYPE_OPTIONS, @@ -155,7 +154,7 @@ const SchemaTable = () => { showPagination, paging, handlePagingChange, - } = usePaging(PAGE_SIZE_LARGE); + } = usePaging(); const { filters, setFilters } = useTableFilters({ columnSearch: undefined as string | undefined, @@ -921,8 +920,8 @@ const SchemaTable = () => { entity: t('label.constraint'), }), })} - selectedKey={editConstraint ?? null} - onSelectionChange={(key) => + value={editConstraint ?? null} + onChange={(key) => setEditConstraint( key === NO_CONSTRAINT_KEY ? undefined : (key as Constraint) ) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.test.tsx index a253334fd58e..6bc956ac67e2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.test.tsx @@ -429,7 +429,7 @@ describe('Test EntityTable Component', () => { MOCK_TABLE.fullyQualifiedName, { fields: 'tags,customMetrics,extension', - limit: 50, + limit: 15, offset: 0, sortBy: 'name', sortOrder: 'asc', @@ -454,7 +454,7 @@ describe('Test EntityTable Component', () => { MOCK_TABLE.fullyQualifiedName, { fields: 'tags,customMetrics,extension', - limit: 50, + limit: 15, offset: 0, sortBy: 'name', sortOrder: 'asc', diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/TableQueries/TableQueries.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/TableQueries/TableQueries.tsx index 67bf6ae195a5..1803c32761e4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/TableQueries/TableQueries.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/TableQueries/TableQueries.tsx @@ -30,10 +30,7 @@ import { FC, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { WILD_CARD_CHAR } from '../../../constants/char.constants'; -import { - INITIAL_PAGING_VALUE, - PAGE_SIZE_BASE, -} from '../../../constants/constants'; +import { INITIAL_PAGING_VALUE } from '../../../constants/constants'; import { USAGE_DOCS } from '../../../constants/docs.constants'; import { NO_PERMISSION_FOR_ACTION } from '../../../constants/HelperTextUtil'; import { @@ -146,7 +143,7 @@ const TableQueries: FC = ({ paging, handlePagingChange, showPagination, - } = usePaging(PAGE_SIZE_BASE); + } = usePaging(); const { getEntityPermission, permissions } = usePermissionProvider(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/SubDomainsTable/SubDomainsTable.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/SubDomainsTable/SubDomainsTable.component.tsx index 579f9c35c52c..88457bb0d117 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/SubDomainsTable/SubDomainsTable.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/SubDomainsTable/SubDomainsTable.component.tsx @@ -23,7 +23,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { VIEW_MODE_PAGE } from '../../../constants/platform/personaAppLayout.constants'; import { PageViewMode } from '../../../generated/type/personaPreferences'; +import { useSavedPageSize } from '../../../hooks/paging/useSavedPageSize'; import { usePersonaViewMode } from '../../../hooks/platform/usePersonaViewMode'; +import { getViewPageSizeOptions } from '../../../utils/PaginationUtils'; import { useDelete } from '../../common/atoms/actions/useDelete'; import { useDomainCardTemplates } from '../../common/atoms/domain/ui/useDomainCardTemplates'; import { useDomainFilters } from '../../common/atoms/domain/ui/useDomainFilters'; @@ -47,9 +49,26 @@ const SubDomainsTable = ({ onDeleteSubDomain, }: SubDomainsTableProps) => { const { t } = useTranslation(); + const personaView = usePersonaViewMode(VIEW_MODE_PAGE.SubDomains); + const [selectedView, setView] = useState(); + const view = selectedView ?? personaView; + const listingPageSizeOptions = getViewPageSizeOptions(view); + const { savedPageSize: listingPageSize } = useSavedPageSize( + listingPageSizeOptions + ); const subdomainListing = useSubdomainListingData({ parentDomainFqn: domainFqn, + pageSize: listingPageSize, + pageSizeOptions: listingPageSizeOptions, }); + const { resetPaging } = subdomainListing; + const handleViewChange = useCallback( + (nextView: PageViewMode) => { + setView(nextView); + resetPaging?.(); + }, + [resetPaging] + ); const { quickFilters, defaultFilters } = useDomainFilters({ isSubDomain: true, @@ -85,9 +104,6 @@ const SubDomainsTable = ({ }; }, [debouncedSearch]); - const personaView = usePersonaViewMode(VIEW_MODE_PAGE.SubDomains); - const [selectedView, setView] = useState(); - const view = selectedView ?? personaView; const { renderDomainCard } = useDomainCardTemplates(); const { columns: subDomainColumns, renderCell: renderSubDomainCell } = @@ -246,7 +262,7 @@ const SubDomainsTable = ({ /> {quickFilters} - + {deleteIconButton} {filterSelectionDisplay} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/SubDomainsTable/hooks/useSubdomainListingData.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/SubDomainsTable/hooks/useSubdomainListingData.tsx index 8752e1c2b4ed..29f1d178bd5a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/SubDomainsTable/hooks/useSubdomainListingData.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/SubDomainsTable/hooks/useSubdomainListingData.tsx @@ -18,10 +18,14 @@ import { useMarketplaceStore } from '../../../../hooks/useMarketplaceStore'; interface UseSubdomainListingDataProps { parentDomainFqn: string; + pageSize?: number; + pageSizeOptions?: number[]; } export const useSubdomainListingData = ({ parentDomainFqn, + pageSize, + pageSizeOptions, }: UseSubdomainListingDataProps): ListingData => { const { domainBasePath } = useMarketplaceStore(); @@ -45,5 +49,7 @@ export const useSubdomainListingData = ({ basePath: domainBasePath, isSubDomain: true, searchKey: 'sub-domain', + pageSize, + pageSizeOptions, }); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/DomainListPage.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/DomainListPage.tsx index 0014fd62ab1e..217173af75b4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/DomainListPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/DomainListPage.tsx @@ -32,9 +32,11 @@ import { LEARNING_PAGE_IDS } from '../../constants/Learning.constants'; import { VIEW_MODE_PAGE } from '../../constants/platform/personaAppLayout.constants'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { PageViewMode } from '../../generated/type/personaPreferences'; +import { useSavedPageSize } from '../../hooks/paging/useSavedPageSize'; import { usePersonaViewMode } from '../../hooks/platform/usePersonaViewMode'; import { useIsAiMode } from '../../hooks/useAppMode'; import { useMarketplaceStore } from '../../hooks/useMarketplaceStore'; +import { getViewPageSizeOptions } from '../../utils/PaginationUtils'; import { useDelete } from '../common/atoms/actions/useDelete'; import { useDomainCardTemplates } from '../common/atoms/domain/ui/useDomainCardTemplates'; import { useDomainFilters } from '../common/atoms/domain/ui/useDomainFilters'; @@ -59,7 +61,26 @@ const DomainListPage = ({ renderPageHeader }: DomainListPageProps) => { const { isMarketplace, domainBasePath } = useMarketplaceStore(); const { t } = useTranslation(); const isAiMode = useIsAiMode(); - const domainListing = useDomainListingData({ enableNlq: isAiMode }); + const personaView = usePersonaViewMode(VIEW_MODE_PAGE.Domains); + const [selectedView, setView] = useState(); + const view = selectedView ?? personaView; + const listingPageSizeOptions = getViewPageSizeOptions(view); + const { savedPageSize: listingPageSize } = useSavedPageSize( + listingPageSizeOptions + ); + const domainListing = useDomainListingData({ + enableNlq: isAiMode, + pageSize: listingPageSize, + pageSizeOptions: listingPageSizeOptions, + }); + const { resetPaging } = domainListing; + const handleViewChange = useCallback( + (nextView: PageViewMode) => { + setView(nextView); + resetPaging?.(); + }, + [resetPaging] + ); const { permissions } = usePermissionProvider(); const [treeRefreshToken, setTreeRefreshToken] = useState(0); @@ -151,9 +172,6 @@ const DomainListPage = ({ renderPageHeader }: DomainListPageProps) => { loading: domainListing.loading, }); - const personaView = usePersonaViewMode(VIEW_MODE_PAGE.Domains); - const [selectedView, setView] = useState(); - const view = selectedView ?? personaView; const isTreeView = view === PageViewMode.Tree; const { renderDomainCard } = useDomainCardTemplates(); @@ -352,7 +370,7 @@ const DomainListPage = ({ renderPageHeader }: DomainListPageProps) => { {deleteIconButton} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/hooks/useDomainListingData.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/hooks/useDomainListingData.tsx index 2c24f2f3fca9..4afb8eff73ea 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/hooks/useDomainListingData.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DomainListing/hooks/useDomainListingData.tsx @@ -21,7 +21,13 @@ import { QueryFieldInterface } from '../../../pages/ExplorePage/ExplorePage.inte export const useDomainListingData = ({ enableNlq, -}: { enableNlq?: boolean } = {}): ListingData => { + pageSize, + pageSizeOptions, +}: { + enableNlq?: boolean; + pageSize?: number; + pageSizeOptions?: number[]; +} = {}): ListingData => { const { domainBasePath } = useMarketplaceStore(); const { userDomains, isDomainRestricted } = useDomainStore(); @@ -63,5 +69,7 @@ export const useDomainListingData = ({ nameLabelKey: 'label.domain', basePath: domainBasePath, enableNlq, + pageSize, + pageSizeOptions, }); }; 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 26bfed3ded26..608d8c5de35d 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 @@ -621,7 +621,8 @@ const GlossaryTermTab = ({ isGlossary, className }: GlossaryTermTabProps) => { const [confirmCheckboxChecked, setConfirmCheckboxChecked] = useState(false); const [totalTermsCount, setTotalTermsCount] = useState(0); - const { paging, handlePagingChange, pageSize } = usePaging(PAGE_SIZE_LARGE); + const { paging, handlePagingChange, pageSize, handlePageSizeChange } = + usePaging(); const [currentPage, setCurrentPage] = useState(1); const [loadingChildren, setLoadingChildren] = useState< Record @@ -1953,8 +1954,8 @@ const GlossaryTermTab = ({ isGlossary, className }: GlossaryTermTabProps) => { }; }, [isDraggingTerm, moveDraggedGlossaryTermToRoot]); - // Trigger new fetch when search term or status filter changes. Both reset - // pagination back to the first page. + // Trigger new fetch when the search term, status filter or page size + // changes. Each resets pagination back to the first page. useEffect(() => { if ( activeGlossary && @@ -1968,7 +1969,7 @@ const GlossaryTermTab = ({ isGlossary, className }: GlossaryTermTabProps) => { })); fetchAllTerms(); } - }, [searchTerm, selectedStatus]); + }, [searchTerm, selectedStatus, pageSize]); // Check if this is due to search or filter returning no results const isSearchActive = hasActiveSearchTerm(searchTerm); @@ -2089,6 +2090,7 @@ const GlossaryTermTab = ({ isGlossary, className }: GlossaryTermTabProps) => { pageSize={pageSize} paging={paging} pagingHandler={handleGlossaryTermPageChange} + onShowSizeChange={handlePageSizeChange} /> )} 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 ae4373b597c2..cadf7f4caf31 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 @@ -1139,6 +1139,39 @@ describe('Test GlossaryTermTab component', () => { }); }); + it('reloads the terms with the page size picked in the pager', async () => { + mockUseGlossaryStore.glossaryChildTerms = mockedGlossaryTerms; + mockGetFirstLevelGlossaryTermsPaginated.mockResolvedValue({ + data: mockedGlossaryTerms, + paging: { after: null }, + }); + + render(, { + wrapper: MemoryRouter, + }); + + const pageSizeButton = await screen.findByTestId( + 'page-size-selection-dropdown' + ); + fireEvent.keyDown(pageSizeButton, { key: 'Enter' }); + fireEvent.keyUp(pageSizeButton, { key: 'Enter' }); + fireEvent.click( + await screen.findByRole('menuitem', { name: '50 / label.page' }) + ); + + await waitFor(() => { + expect( + mockGetFirstLevelGlossaryTermsPaginated + ).toHaveBeenLastCalledWith( + expect.anything(), + 50, + undefined, + expect.anything(), + undefined + ); + }); + }); + it('should stop loading when no more terms are available', async () => { mockGetFirstLevelGlossaryTermsPaginated.mockResolvedValue({ data: mockedGlossaryTerms, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryUpdateConfirmationModal/GlossaryUpdateConfirmationModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryUpdateConfirmationModal/GlossaryUpdateConfirmationModal.tsx index ca3ff8f8cbdf..328ffc4f4ae5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryUpdateConfirmationModal/GlossaryUpdateConfirmationModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryUpdateConfirmationModal/GlossaryUpdateConfirmationModal.tsx @@ -27,6 +27,10 @@ import { AxiosError } from 'axios'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; +import { + LIST_PAGE_SIZE_OPTIONS, + PAGE_SIZE_BASE, +} from '../../../constants/constants'; import { ClientErrors } from '../../../enums/Axios.enum'; import { EntityType } from '../../../enums/entity.enum'; import { GlossaryTerm } from '../../../generated/entity/data/glossaryTerm'; @@ -101,7 +105,8 @@ const renderFailedContent = ( columns={columns} dataSource={failedStatus?.failedRequest ?? []} pagination={{ - pageSize: 5, + pageSize: PAGE_SIZE_BASE, + pageSizeOptions: LIST_PAGE_SIZE_OPTIONS, showSizeChanger: true, }} rowKey={(record) => record.request?.id} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/LineageTable/LineageTable.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/LineageTable/LineageTable.test.tsx index 263e42df7acb..2a013ed9790a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/LineageTable/LineageTable.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/LineageTable/LineageTable.test.tsx @@ -579,7 +579,8 @@ describe('LineageTable', () => { render(, { wrapper: MemoryRouter }); - expect(mockUsePaging).toHaveBeenCalledWith(50); + // No fixed default: the table starts from the user's saved page size. + expect(mockUsePaging).toHaveBeenCalledWith(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/LineageTable/LineageTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/LineageTable/LineageTable.tsx index 8ce6d8ecb02b..3e497627a04b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/LineageTable/LineageTable.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/LineageTable/LineageTable.tsx @@ -153,7 +153,7 @@ const LineageTable: FC<{ entity: SourceType }> = ({ entity }) => { handlePageChange, handlePagingChange, handlePageSizeChange, - } = usePaging(PAGE_SIZE_LARGE); + } = usePaging(); const paginationInfoKeyRef = useRef(null); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppLiveIndexing/AppLiveIndexing.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppLiveIndexing/AppLiveIndexing.component.tsx index 0212e35a1313..201da981bda2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppLiveIndexing/AppLiveIndexing.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppLiveIndexing/AppLiveIndexing.component.tsx @@ -18,9 +18,13 @@ import { } from '@openmetadata/ui-core-components'; import { CheckCircle } from '@openmetadata/ui-core-components/icons'; import { AxiosError } from 'axios'; +import { isEmpty } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { NO_DATA_PLACEHOLDER } from '../../../../constants/constants'; +import { + LIST_PAGE_SIZE_OPTIONS, + NO_DATA_PLACEHOLDER, +} from '../../../../constants/constants'; import { Paging } from '../../../../generated/type/paging'; import { usePaging } from '../../../../hooks/paging/usePaging'; import { useFqn } from '../../../../hooks/useFqn'; @@ -45,8 +49,6 @@ const STATUS_TYPE_MAP: Record = { FAILED: StatusType.Failure, }; -const PAGE_SIZE = 15; - const AppLiveIndexing = ({ appData }: AppLiveIndexingProps) => { const { t } = useTranslation(); const { fqn: routeFqn } = useFqn(); @@ -61,7 +63,7 @@ const AppLiveIndexing = ({ appData }: AppLiveIndexingProps) => { handlePagingChange, handlePageChange, handlePageSizeChange, - } = usePaging(PAGE_SIZE); + } = usePaging(); const fetchRetryQueue = useCallback( async (offset = 0) => { @@ -171,7 +173,6 @@ const AppLiveIndexing = ({ appData }: AppLiveIndexingProps) => { { ), }} - pageSize={pageSize} - pagination={{ - total: paging.total, - pageSize, - current: currentPage, - showSizeChanger: true, - pageSizeOptions: ['10', '15', '25', '50'], - onChange: (page) => - handlePageChangeHandler({ currentPage: page, cursorType: '' }), - onShowSizeChange: (_, size) => handlePageSizeChange(size), - }} - paginationVisible={records.length > 0 && paging.total > pageSize} + pagination={ + isEmpty(records) && currentPage === 1 + ? false + : { + total: paging.total, + pageSize, + current: currentPage, + showSizeChanger: true, + pageSizeOptions: LIST_PAGE_SIZE_OPTIONS, + onChange: (page) => + handlePageChangeHandler({ currentPage: page }), + onShowSizeChange: (_, size) => handlePageSizeChange(size), + } + } rowKey={(record) => `${record.entityId}-${record.entityFqn}`} size="small" /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppLogsViewer/ReindexFailures.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppLogsViewer/ReindexFailures.component.tsx index a577f30899a3..0b7f303d8bec 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppLogsViewer/ReindexFailures.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppLogsViewer/ReindexFailures.component.tsx @@ -30,6 +30,7 @@ import { } from 'react'; import { useTranslation } from 'react-i18next'; import { RDF_INDEX_APP_NAME } from '../../../../constants/Applications.constant'; +import { useSavedPageSize } from '../../../../hooks/paging/useSavedPageSize'; import { getRdfReindexFailures } from '../../../../rest/rdfAPI'; import { getReindexFailures } from '../../../../rest/searchAPI'; import { formatDateTimeWithTimezone } from '../../../../utils/date-time/DateTimeUtils'; @@ -42,7 +43,6 @@ import { ReindexFailuresProps, } from './ReindexFailures.interface'; -const PAGE_SIZE = 20; const ALL_ENTITY_TYPES_KEY = '__all__'; const ErrorMessage = ({ text }: { text: string }) => { @@ -90,6 +90,7 @@ const ReindexFailures = ({ const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [currentPage, setCurrentPage] = useState(1); + const { savedPageSize: pageSize } = useSavedPageSize(); const [entityTypeFilter, setEntityTypeFilter] = useState( undefined ); @@ -109,8 +110,8 @@ const ReindexFailures = ({ ? getRdfReindexFailures : getReindexFailures; const response = await fetcher({ - offset: (page - 1) * PAGE_SIZE, - limit: PAGE_SIZE, + offset: (page - 1) * pageSize, + limit: pageSize, entityType, }); if (requestId !== requestSequence.current) { @@ -133,7 +134,7 @@ const ReindexFailures = ({ } } }, - [appName] + [appName, pageSize] ); useEffect(() => { @@ -289,7 +290,7 @@ const ReindexFailures = ({ loading={loading} pagination={{ current: currentPage, - pageSize: PAGE_SIZE, + pageSize, showSizeChanger: false, total, }} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.constants.ts b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.constants.ts new file mode 100644 index 000000000000..a53d70487507 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.constants.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +export const NO_URL_FILTERS = {}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.paging.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.paging.test.tsx new file mode 100644 index 000000000000..8eacef5570a5 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.paging.test.tsx @@ -0,0 +1,120 @@ +/* + * 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 { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { ReactNode } from 'react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { PIPELINE_SERVICE_PLATFORM } from '../../../constants/Services.constant'; +import { ServiceCategory } from '../../../enums/service.enum'; +import { getServices } from '../../../rest/serviceAPI'; +import Services from './Services'; + +jest.mock('../../../rest/serviceAPI', () => ({ + getServices: jest.fn().mockResolvedValue({ + data: [ + { + id: 'service-1', + name: 'airbyte', + fullyQualifiedName: 'airbyte', + serviceType: 'Airbyte', + }, + ], + paging: { total: 40, after: 'next-cursor' }, + }), + searchService: jest.fn(), +})); + +jest.mock('../../../context/PermissionProvider/PermissionProvider', () => ({ + usePermissionProvider: () => ({ permissions: {} }), +})); + +jest.mock( + '../../../context/AirflowStatusProvider/AirflowStatusProvider', + () => ({ + useAirflowStatus: () => ({ + isFetchingStatus: false, + isAirflowAvailable: true, + fetchAirflowStatus: jest.fn(), + platform: PIPELINE_SERVICE_PLATFORM, + }), + }) +); + +jest.mock('../../../utils/PermissionsUtils', () => ({ + checkPermission: () => true, +})); + +jest.mock('../../../hoc/LimitWrapper', () => + jest.fn(({ children }: { children: ReactNode }) => <>{children}) +); + +jest.mock('../../PageHeader/PageHeader.component', () => + jest.fn(() =>
) +); + +jest.mock('../../common/DomainFilterChip/DomainFilterChip.component', () => ({ + DomainFilterChip: () => null, +})); + +const mockGetServices = getServices as jest.Mock; + +const LocationProbe = () => { + const { search } = useLocation(); + + return
{search}
; +}; + +const renderServices = (url: string) => + render( + + + + + ); + +describe('Services paging across a view switch', () => { + beforeEach(() => { + mockGetServices.mockClear(); + }); + + it('pages the card view on the grid scale from page 1 and drops the list paging from the URL', async () => { + await act(async () => { + renderServices('/settings/services/pipelines?currentPage=2&pageSize=25'); + }); + + expect(mockGetServices).toHaveBeenLastCalledWith( + expect.objectContaining({ limit: 25 }) + ); + + await act(async () => { + fireEvent.click(screen.getByTestId('grid')); + }); + + await waitFor(() => { + expect(mockGetServices).toHaveBeenLastCalledWith( + expect.objectContaining({ limit: 12 }) + ); + }); + + const search = screen.getByTestId('location-search').textContent ?? ''; + + expect(search).not.toContain('currentPage'); + expect(search).not.toContain('pageSize'); + expect(screen.getByTestId('page-indicator')).toHaveTextContent('1'); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.test.tsx index 1d016fe16dc9..11a7dce3bf39 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.test.tsx @@ -19,6 +19,7 @@ import { useAirflowStatus } from '../../../context/AirflowStatusProvider/Airflow import { ServiceCategory } from '../../../enums/service.enum'; import { PipelineServiceType } from '../../../generated/entity/data/pipeline'; import LimitWrapper from '../../../hoc/LimitWrapper'; +import { usePaging } from '../../../hooks/paging/usePaging'; import { getServices, searchService } from '../../../rest/serviceAPI'; import { checkPermission } from '../../../utils/PermissionsUtils'; import { ListView } from '../../common/ListView/ListView.component'; @@ -113,6 +114,13 @@ jest.mock('../../../context/PermissionProvider/PermissionProvider', () => ({ }), })); +jest.mock('../../../hooks/useTableFilters', () => ({ + useTableFilters: () => ({ + filters: {}, + setFilters: jest.fn(), + }), +})); + jest.mock('../../../hooks/paging/usePaging', () => ({ usePaging: jest.fn().mockReturnValue({ currentPage: 1, @@ -306,6 +314,77 @@ jest.mock('../../../hoc/LimitWrapper', () => { }); describe('Services', () => { + it('shows the pager when there are services, even a single page of them', async () => { + (getServices as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ data: mockGetServicesData, paging: { total: 1 } }) + ); + const pagingState = (usePaging as jest.Mock)(); + (usePaging as jest.Mock).mockReturnValue({ + ...pagingState, + showPagination: false, + }); + + await act(async () => { + render(); + }); + + expect(ListView).toHaveBeenLastCalledWith( + expect.objectContaining({ + customPaginationProps: expect.objectContaining({ + showPagination: true, + }), + }), + expect.anything() + ); + + (usePaging as jest.Mock).mockReturnValue(pagingState); + }); + + it('keeps the pager on an empty later page so the user can page back', async () => { + (getServices as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ data: [], paging: { total: 20 } }) + ); + const pagingState = (usePaging as jest.Mock)(); + (usePaging as jest.Mock).mockReturnValue({ + ...pagingState, + currentPage: 2, + }); + + await act(async () => { + render(); + }); + + expect(ListView).toHaveBeenLastCalledWith( + expect.objectContaining({ + customPaginationProps: expect.objectContaining({ + showPagination: true, + }), + }), + expect.anything() + ); + + (usePaging as jest.Mock).mockReturnValue(pagingState); + }); + + it('hides the pager when there are no services', async () => { + (getServices as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ data: [], paging: { total: 0 } }) + ); + + await act(async () => { + render(); + }); + + expect(ListView).toHaveBeenLastCalledWith( + expect.objectContaining({ + customPaginationProps: expect.objectContaining({ + showPagination: false, + }), + }), + expect.anything() + ); + }); + it('should render Services', async () => { await act(async () => { render(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx index 30f50f6e7836..3125e06a7ed4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx @@ -27,7 +27,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate } from 'react-router-dom'; import { + GRID_PAGE_SIZE_OPTIONS, INITIAL_PAGING_VALUE, + LIST_PAGE_SIZE_OPTIONS, pagingObject, } from '../../../constants/constants'; import { NO_PERMISSION_FOR_ACTION } from '../../../constants/HelperTextUtil'; @@ -47,6 +49,7 @@ import { Operation } from '../../../generated/entity/policies/policy'; import { Include } from '../../../generated/type/include'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { usePaging } from '../../../hooks/paging/usePaging'; +import { useTableFilters } from '../../../hooks/useTableFilters'; import { DatabaseServiceSearchSource } from '../../../interface/search.interface'; import { ServicesType } from '../../../interface/service.interface'; import { getServices, searchService } from '../../../rest/serviceAPI'; @@ -74,6 +77,7 @@ import { showErrorToast } from '../../../utils/ToastUtils'; import { DomainFilterChip } from '../../common/DomainFilterChip/DomainFilterChip.component'; import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import { ListView } from '../../common/ListView/ListView.component'; +import { ListViewOptions } from '../../common/ListView/ListView.interface'; import { PagingHandlerParams } from '../../common/NextPrevious/NextPrevious.interface'; import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import RichTextEditorPreviewerNew from '../../common/RichTextEditor/RichTextEditorPreviewNew'; @@ -81,6 +85,7 @@ import ButtonSkeleton from '../../common/Skeleton/CommonSkeletons/ControlElement import { ColumnsType, TableProps } from '../../common/Table/Table.interface'; import { ColumnFilter } from '../../Database/ColumnFilter/ColumnFilter.component'; import PageHeader from '../../PageHeader/PageHeader.component'; +import { NO_URL_FILTERS } from './Services.constants'; interface ServicesProps { serviceName: ServiceCategory; @@ -100,6 +105,11 @@ const Services = ({ serviceName }: ServicesProps) => { const [searchTerm, setSearchTerm] = useState(''); const [serviceTypeFilter, setServiceTypeFilter] = useState>(); + const [currentView, setCurrentView] = useState(ListViewOptions.TABLE); + const isCardView = currentView === ListViewOptions.CARD; + const pageSizeOptions = isCardView + ? GRID_PAGE_SIZE_OPTIONS + : LIST_PAGE_SIZE_OPTIONS; const { paging, handlePagingChange, @@ -107,9 +117,23 @@ const Services = ({ serviceName }: ServicesProps) => { handlePageChange, pageSize, handlePageSizeChange, - showPagination, pagingCursor, - } = usePaging(); + } = usePaging(undefined, pageSizeOptions); + + const { setFilters: setPagingUrlParams } = useTableFilters(NO_URL_FILTERS); + + const handleViewChange = useCallback( + (view: ListViewOptions) => { + setCurrentView(view); + setPagingUrlParams({ + currentPage: null, + cursorType: null, + cursorValue: null, + pageSize: null, + }); + }, + [setPagingUrlParams] + ); const [deleted, setDeleted] = useState(false); const { permissions } = usePermissionProvider(); @@ -353,22 +377,24 @@ const Services = ({ serviceName }: ServicesProps) => { const customPaginationTableProps = useMemo( () => ({ - showPagination, + showPagination: !isEmpty(serviceDetails) || currentPage > 1, currentPage, isLoading, isNumberBased: !isEmpty(searchTerm) || !isEmpty(serviceTypeFilter), pageSize, + pageSizeOptions, paging, pagingHandler: handleServicePageChange, onShowSizeChange: handlePageSizeChange, }), [ - showPagination, + serviceDetails, currentPage, isLoading, searchTerm, serviceTypeFilter, pageSize, + pageSizeOptions, paging, handleServicePageChange, handlePageSizeChange, @@ -613,6 +639,7 @@ const Services = ({ serviceName }: ServicesProps) => { cardRenderer={serviceCardRenderer} + currentView={currentView} customPaginationProps={customPaginationTableProps} deleted={deleted} handleDeletedSwitchChange={handleDeletedSwitchChange} @@ -632,6 +659,7 @@ const Services = ({ serviceName }: ServicesProps) => { size: 'small', onChange: handleTableChange, }} + onViewChange={handleViewChange} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/TestLibrary/TestDefinitionList/useTestDefinitionListPage.ts b/openmetadata-ui/src/main/resources/ui/src/components/TestLibrary/TestDefinitionList/useTestDefinitionListPage.ts index c53b2bf6188b..0f74eeade26b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/TestLibrary/TestDefinitionList/useTestDefinitionListPage.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/TestLibrary/TestDefinitionList/useTestDefinitionListPage.ts @@ -11,10 +11,7 @@ * limitations under the License. */ import { useMemo } from 'react'; -import { - INITIAL_PAGING_VALUE, - PAGE_SIZE_BASE, -} from '../../../constants/constants'; +import { INITIAL_PAGING_VALUE } from '../../../constants/constants'; import { TEST_DEFINITION_COLUMN_BY_SORT_FIELD } from '../../../constants/TestDefinition.constants'; import { usePaging } from '../../../hooks/paging/usePaging'; import { PagingHandlerParams } from '../../common/NextPrevious/NextPrevious.interface'; @@ -39,7 +36,7 @@ export const useTestDefinitionListPage = () => { handlePageSizeChange, showPagination, pagingCursor, - } = usePaging(PAGE_SIZE_BASE); + } = usePaging(); const { urlParams, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/ListView/ListView.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/ListView/ListView.component.tsx index 96126cbb68d6..92060a212aaa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/ListView/ListView.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/ListView/ListView.component.tsx @@ -21,7 +21,7 @@ import { import { getLayoutGutter } from '../../../utils/common/layout.utils'; import { isEmpty, isUndefined } from 'lodash'; -import { ReactNode, useMemo, useState } from 'react'; +import { ReactNode, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as GridIcon } from '../../../assets/svg/ic-grid.svg'; import { ReactComponent as ListIcon } from '../../../assets/svg/ic-list.svg'; @@ -46,10 +46,9 @@ export const ListView = >({ handleDeletedSwitchChange, deleted = false, customPaginationProps, + currentView, + onViewChange, }: ListViewProps) => { - const [currentView, setCurrentView] = useState( - ListViewOptions.TABLE - ); const { t } = useTranslation(); const listViewOptions = [ @@ -121,7 +120,7 @@ export const ListView = >({ onSelectionChange={(keys) => { const selected = [...keys][0]; if (selected) { - setCurrentView(selected as ListViewOptions); + onViewChange(selected as ListViewOptions); } }}> {listViewOptions.map(({ label, value, ariaLabel }) => ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/ListView/ListView.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/ListView/ListView.interface.ts index 8bffe1c1844e..9dd6fb5b1a97 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/ListView/ListView.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/ListView/ListView.interface.ts @@ -28,6 +28,8 @@ export interface ListViewProps { customPaginationProps: NextPreviousProps & { showPagination: boolean; }; + currentView: ListViewOptions; + onViewChange: (view: ListViewOptions) => void; } export enum ListViewOptions { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/ListView/ListView.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/ListView/ListView.test.tsx index f80fe820536a..903bfb635023 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/ListView/ListView.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/ListView/ListView.test.tsx @@ -11,10 +11,30 @@ * limitations under the License. */ import { act, fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; import { pagingObject } from '../../../constants/constants'; import { CursorType } from '../../../enums/pagination.enum'; import { PagingHandlerParams } from '../NextPrevious/NextPrevious.interface'; import { ListView } from './ListView.component'; +import { ListViewOptions, ListViewProps } from './ListView.interface'; + +// ListView's view is owned by its parent (the parent picks the page-size scale for it). +const ControlledListView = ( + props: Omit< + ListViewProps>, + 'currentView' | 'onViewChange' + > +) => { + const [currentView, setCurrentView] = useState(ListViewOptions.TABLE); + + return ( + + ); +}; const mockCardRenderer = jest.fn().mockImplementation(() => <>Card); const mockOnSearch = jest.fn(); @@ -67,7 +87,7 @@ const mockCustomPaginationProps = { describe('ListView component', () => { it('should render toggle button for card and table', async () => { render( - { it('should render table by default', async () => { render( - { it('should render card when card is selected', async () => { render( - { it('should call handleDeletedSwitchChange after switch toggle', async () => { render( - { it('should call pagingHandler in ListView', async () => { render( - { it('should not visible pagination in TableView', async () => { render( - = | { - customPaginationProps?: undefined; - pagination?: TableComponentProps['pagination']; + customPaginationProps?: never; + pagination?: NonNullable['pagination']>; } | { customPaginationProps: CustomPaginationProps; pagination: false }; @@ -161,7 +162,7 @@ type TableV2Props = Omit< PaginationContract & TableV2ExtensionProps; -const DEFAULT_PAGE_SIZE = 10; +const DEFAULT_PAGE_SIZE = PAGE_SIZE_BASE; const DEFAULT_INDENT_PX = 12; const EXPANDER_GUTTER_PX = 16; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/compositions/useListingData.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/compositions/useListingData.test.tsx index 6cc7717522e8..5c1ebb666441 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/compositions/useListingData.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/compositions/useListingData.test.tsx @@ -84,6 +84,7 @@ describe('useListingData', () => { setPageSize: mockSetPageSize, resetFilters: jest.fn(), resetAll: jest.fn(), + resetPaging: jest.fn(), }); mockUseDataFetching.mockReturnValue({ @@ -205,6 +206,7 @@ describe('useListingData', () => { setPageSize: mockSetPageSize, resetFilters: jest.fn(), resetAll: jest.fn(), + resetPaging: jest.fn(), }); act(() => { @@ -234,6 +236,42 @@ describe('useListingData', () => { }); describe('effectivePageSize fallback', () => { + it('ignores a URL size the current view does not offer', () => { + // `size=25` was left by the table view; the card view pages on 12/24/48. + mockUseUrlState.mockReturnValue({ + urlState: { + searchQuery: '', + filters: {}, + currentPage: 1, + pageSize: 25, + }, + parsedFilters: [], + setSearchQuery: mockSetSearchQuery, + setFilters: mockSetFilters, + setCurrentPage: mockSetCurrentPage, + setPageSize: mockSetPageSize, + resetFilters: jest.fn(), + resetAll: jest.fn(), + resetPaging: jest.fn(), + }); + + renderHook( + () => + useListingData({ + searchIndex: SearchIndex.TABLE, + filterKeys: [], + columns: [], + pageSize: 12, + pageSizeOptions: [12, 24, 48], + }), + { wrapper } + ); + + expect(mockUseDataFetching).toHaveBeenLastCalledWith( + expect.objectContaining({ pageSize: 12 }) + ); + }); + it('should use urlState.pageSize when available', () => { mockUseUrlState.mockReturnValueOnce({ urlState: { @@ -249,6 +287,7 @@ describe('useListingData', () => { setPageSize: mockSetPageSize, resetFilters: jest.fn(), resetAll: jest.fn(), + resetPaging: jest.fn(), }); mockUsePaginationState.mockReturnValueOnce({ @@ -293,6 +332,7 @@ describe('useListingData', () => { setPageSize: mockSetPageSize, resetFilters: jest.fn(), resetAll: jest.fn(), + resetPaging: jest.fn(), }); mockUsePaginationState.mockReturnValueOnce({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/compositions/useListingData.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/compositions/useListingData.tsx index 25045d980b15..5e56e0dc519c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/compositions/useListingData.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/compositions/useListingData.tsx @@ -11,7 +11,9 @@ * limitations under the License. */ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { PAGE_SIZE_BASE } from '../../../../constants/constants'; import { useIsRouteVisible } from '../../../../context/RouteVisibilityProvider/RouteVisibilityProvider'; import { SearchIndex } from '../../../../enums/search.enum'; import { useQuickFilterLabels } from '../../../../hooks/useQuickFilterLabels'; @@ -28,6 +30,7 @@ interface UseListingDataProps { searchIndex: SearchIndex; baseFilter?: string; pageSize?: number; + pageSizeOptions?: number[]; filterKeys: string[]; filterConfigs?: ExploreQuickFilterField[]; columns: ColumnConfig[]; @@ -50,7 +53,8 @@ export const useListingData = < const { searchIndex, baseFilter = '', - pageSize = 10, + pageSize = PAGE_SIZE_BASE, + pageSizeOptions, filterKeys, filterConfigs, columns, @@ -83,9 +87,51 @@ export const useListingData = < setCurrentPage, setPageSize, resetAll, + resetPaging, } = urlStateHook; - const effectivePageSize = urlState.pageSize || pageSize; + const isOffScaleUrlSize = Boolean( + pageSizeOptions && !pageSizeOptions.includes(urlState.pageSize) + ); + const effectivePageSize = isOffScaleUrlSize + ? pageSize + : urlState.pageSize || pageSize; + const [searchParams] = useSearchParams(); + const hasUrlPageSize = + Number(searchParams.get('size')) > 0 && !isOffScaleUrlSize; + const [pagedAtSize, setPagedAtSize] = useState(pageSize); + const isPageFromOtherSize = !hasUrlPageSize && pagedAtSize !== pageSize; + const effectivePage = + isOffScaleUrlSize || isPageFromOtherSize ? 1 : urlState.currentPage; + + // Keep-alive keeps a visited listing mounted while another is on screen, and + // every listing reads the same `q` param — so without this a hidden page + // re-queries its own index on each keystroke and throws the answer away. + const isRouteVisible = useIsRouteVisible(); + + useEffect(() => { + if (isRouteVisible && isOffScaleUrlSize) { + resetPaging(); + } + }, [isRouteVisible, isOffScaleUrlSize, resetPaging]); + + useEffect(() => { + if (!isRouteVisible || pagedAtSize === pageSize) { + return; + } + if (isPageFromOtherSize && urlState.currentPage > 1) { + setCurrentPage(1); + } else { + setPagedAtSize(pageSize); + } + }, [ + isRouteVisible, + pagedAtSize, + pageSize, + isPageFromOtherSize, + urlState.currentPage, + setCurrentPage, + ]); const dataFetching = useDataFetching({ searchIndex, @@ -95,7 +141,7 @@ export const useListingData = < }); const paginationState = usePaginationState({ - currentPage: urlState.currentPage, + currentPage: effectivePage, totalEntities: dataFetching.totalEntities, pageSize: effectivePageSize, onPageChange: setCurrentPage, @@ -113,10 +159,6 @@ export const useListingData = < onCustomAddClick, }); - // Keep-alive keeps a visited listing mounted while another is on screen, and - // every listing reads the same `q` param — so without this a hidden page - // re-queries its own index on each keystroke and throws the answer away. - const isRouteVisible = useIsRouteVisible(); const lastFetchedRef = useRef(''); useEffect(() => { @@ -127,10 +169,10 @@ export const useListingData = < // against what was actually last fetched rather than just reacting to the // visibility flip. const signature = JSON.stringify([ - urlState.currentPage, + effectivePage, urlState.searchQuery, urlState.filters, - urlState.pageSize, + effectivePageSize, ]); if (signature === lastFetchedRef.current) { return; @@ -138,15 +180,15 @@ export const useListingData = < lastFetchedRef.current = signature; dataFetching.searchEntities( - urlState.currentPage, + effectivePage, urlState.searchQuery, urlState.filters ); }, [ - urlState.currentPage, + effectivePage, urlState.searchQuery, urlState.filters, - urlState.pageSize, + effectivePageSize, // `useNlq` is left out on purpose: flipping the NLQ toggle must not run a // query by itself. As on Explore, NLQ runs when the user submits. isRouteVisible, @@ -203,16 +245,11 @@ export const useListingData = < // Refetch function to reload data with current filters const refetch = useCallback(() => { dataFetching.searchEntities( - urlState.currentPage, + effectivePage, urlState.searchQuery, urlState.filters ); - }, [ - urlState.currentPage, - urlState.searchQuery, - urlState.filters, - dataFetching, - ]); + }, [effectivePage, urlState.searchQuery, urlState.filters, dataFetching]); return { entities: dataFetching.entities, @@ -240,6 +277,7 @@ export const useListingData = < handleClearAll: resetAll, handlePageChange, handlePageSizeChange, + resetPaging, refetch, }; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/compositions/useListingData.url.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/compositions/useListingData.url.test.tsx new file mode 100644 index 000000000000..832d386c90a5 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/compositions/useListingData.url.test.tsx @@ -0,0 +1,240 @@ +/* + * 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 { act, renderHook, waitFor } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'; +import { RouteVisibilityProvider } from '../../../../context/RouteVisibilityProvider/RouteVisibilityProvider'; +import { SearchIndex } from '../../../../enums/search.enum'; +import { searchQuery } from '../../../../rest/searchAPI'; +import { useListingData } from './useListingData'; + +jest.mock('../../../../rest/searchAPI', () => ({ + searchQuery: jest.fn().mockResolvedValue({ + hits: { hits: [], total: { value: 20 } }, + }), + nlqSearch: jest.fn(), +})); + +const mockSearchQuery = searchQuery as jest.Mock; +const FILTER_KEYS: string[] = []; +const COLUMNS: [] = []; +const LIST_OPTIONS = [15, 25, 50]; +const GRID_OPTIONS = [12, 24, 48]; + +const renderListing = ( + url: string, + { isVisible = true, pageSize = 15, pageSizeOptions = LIST_OPTIONS } = {} +) => + renderHook( + () => ({ + listing: useListingData({ + searchIndex: SearchIndex.DOMAIN, + filterKeys: FILTER_KEYS, + columns: COLUMNS, + pageSize, + pageSizeOptions, + }), + location: useLocation(), + }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + + + {children} + + + ), + } + ); + +describe('useListingData with a page size from an old link', () => { + beforeEach(() => { + mockSearchQuery.mockClear(); + }); + + it('opens an off-scale size link on page 1 and drops its paging from the URL', async () => { + const { result } = renderListing('/domain?page=3&size=9'); + + await waitFor(() => { + expect(result.current.location.search).toBe(''); + }); + + expect(mockSearchQuery).toHaveBeenCalledTimes(1); + expect(mockSearchQuery).toHaveBeenCalledWith( + expect.objectContaining({ pageNumber: 1, pageSize: 15 }) + ); + expect(result.current.listing.currentPage).toBe(1); + }); + + it('keeps the page of a link whose size is on the scale', async () => { + const { result } = renderListing('/domain?page=2&size=25'); + + await waitFor(() => { + expect(mockSearchQuery).toHaveBeenCalledWith( + expect.objectContaining({ pageNumber: 2, pageSize: 25 }) + ); + }); + + expect(result.current.location.search).toBe('?page=2&size=25'); + }); + + it('leaves the URL paging alone while its route is hidden', async () => { + const { result } = renderListing('/data-products?page=2&size=25', { + isVisible: false, + pageSize: 12, + pageSizeOptions: GRID_OPTIONS, + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.location.search).toBe('?page=2&size=25'); + }); + + it('returns to page 1 when the saved size changes, without requesting the old page', async () => { + const { result, rerender } = renderHook( + ({ pageSize }: { pageSize: number }) => ({ + listing: useListingData({ + searchIndex: SearchIndex.DOMAIN, + filterKeys: FILTER_KEYS, + columns: COLUMNS, + pageSize, + pageSizeOptions: LIST_OPTIONS, + }), + location: useLocation(), + }), + { + initialProps: { pageSize: 15 }, + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + } + ); + + await waitFor(() => { + expect(mockSearchQuery).toHaveBeenCalledWith( + expect.objectContaining({ pageNumber: 3, pageSize: 15 }) + ); + }); + + mockSearchQuery.mockClear(); + rerender({ pageSize: 50 }); + + await waitFor(() => { + expect(result.current.location.search).toBe(''); + }); + + expect(mockSearchQuery).toHaveBeenCalledWith( + expect.objectContaining({ pageNumber: 1, pageSize: 50 }) + ); + expect(mockSearchQuery).not.toHaveBeenCalledWith( + expect.objectContaining({ pageNumber: 3 }) + ); + }); + + it('keeps the page of a URL size it navigates back to', async () => { + const { result, rerender } = renderHook( + ({ pageSize }: { pageSize: number }) => ({ + listing: useListingData({ + searchIndex: SearchIndex.DOMAIN, + filterKeys: FILTER_KEYS, + columns: COLUMNS, + pageSize, + pageSizeOptions: LIST_OPTIONS, + }), + location: useLocation(), + navigate: useNavigate(), + }), + { + initialProps: { pageSize: 15 }, + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + } + ); + + await waitFor(() => { + expect(mockSearchQuery).toHaveBeenCalledWith( + expect.objectContaining({ pageNumber: 2, pageSize: 25 }) + ); + }); + + act(() => result.current.navigate('/domain?size=50')); + rerender({ pageSize: 50 }); + await waitFor(() => { + expect(mockSearchQuery).toHaveBeenLastCalledWith( + expect.objectContaining({ pageNumber: 1, pageSize: 50 }) + ); + }); + + mockSearchQuery.mockClear(); + act(() => result.current.navigate(-1)); + + await waitFor(() => { + expect(mockSearchQuery).toHaveBeenLastCalledWith( + expect.objectContaining({ pageNumber: 2, pageSize: 25 }) + ); + }); + + expect(result.current.location.search).toBe('?page=2&size=25'); + expect(mockSearchQuery).not.toHaveBeenCalledWith( + expect.objectContaining({ pageNumber: 1, pageSize: 25 }) + ); + }); + + it('treats an empty size in the URL as no size when the saved size changes', async () => { + const { rerender } = renderHook( + ({ pageSize }: { pageSize: number }) => + useListingData({ + searchIndex: SearchIndex.DOMAIN, + filterKeys: FILTER_KEYS, + columns: COLUMNS, + pageSize, + pageSizeOptions: LIST_OPTIONS, + }), + { + initialProps: { pageSize: 15 }, + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + } + ); + + await waitFor(() => { + expect(mockSearchQuery).toHaveBeenCalledWith( + expect.objectContaining({ pageNumber: 3, pageSize: 15 }) + ); + }); + + mockSearchQuery.mockClear(); + rerender({ pageSize: 50 }); + + await waitFor(() => { + expect(mockSearchQuery).toHaveBeenLastCalledWith( + expect.objectContaining({ pageNumber: 1, pageSize: 50 }) + ); + }); + + expect(mockSearchQuery).not.toHaveBeenCalledWith( + expect.objectContaining({ pageNumber: 3 }) + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/data/useDataFetching.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/data/useDataFetching.tsx index a9f4538c5b45..3e009fa30b01 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/data/useDataFetching.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/data/useDataFetching.tsx @@ -13,6 +13,7 @@ import { AxiosError } from 'axios'; import { useCallback, useRef, useState } from 'react'; +import { PAGE_SIZE_BASE } from '../../../../constants/constants'; import { SearchIndex } from '../../../../enums/search.enum'; import { Aggregations, @@ -57,7 +58,7 @@ export const useDataFetching = ( const { searchIndex, baseFilter = '', - pageSize = 10, + pageSize = PAGE_SIZE_BASE, transform, useNlq = false, } = config; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/data/useUrlState.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/data/useUrlState.test.tsx index 863e7ba55efc..65b0dc2289de 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/data/useUrlState.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/data/useUrlState.test.tsx @@ -176,4 +176,28 @@ describe('useUrlState', () => { expect(newParams.get('page')).toBe('1'); }); }); + + describe('resetPaging', () => { + it('drops the page and page size but keeps search and filters', () => { + const { result } = renderHook( + () => useUrlState({ filterKeys: ['owner'], defaultPageSize: 12 }), + { wrapper } + ); + + act(() => { + result.current.resetPaging(); + }); + + const updateFn = mockSetSearchParams.mock.calls[0][0]; + const newParams = updateFn( + new URLSearchParams('q=sales&owner=alice&page=3&size=25') + ); + + // The listing then starts on page 1 at its own default size. + expect(newParams.get('page')).toBeNull(); + expect(newParams.get('size')).toBeNull(); + expect(newParams.get('q')).toBe('sales'); + expect(newParams.get('owner')).toBe('alice'); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/data/useUrlState.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/data/useUrlState.tsx index d07831970e94..cd19c9854e8a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/data/useUrlState.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/data/useUrlState.tsx @@ -13,6 +13,7 @@ import { useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; +import { PAGE_SIZE_BASE } from '../../../../constants/constants'; import { ExploreQuickFilterField } from '../../../Explore/ExplorePage.interface'; import { UrlState, UrlStateConfig, UrlStateHook } from '../shared/types'; @@ -26,7 +27,7 @@ export const useUrlState = ( filterKeys, pageKey = 'page', pageSizeKey = 'size', - defaultPageSize = 10, + defaultPageSize = PAGE_SIZE_BASE, filterConfigs, } = config; @@ -170,6 +171,10 @@ export const useUrlState = ( updateUrlParams(updates); }, [searchKey, pageKey, filterKeys, updateUrlParams]); + const resetPaging = useCallback(() => { + updateUrlParams({ [pageKey]: null, [pageSizeKey]: null }); + }, [pageKey, pageSizeKey, updateUrlParams]); + return { urlState, parsedFilters, @@ -179,5 +184,6 @@ export const useUrlState = ( setPageSize, resetFilters, resetAll, + resetPaging, }; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/domain/compositions/useDomainListing.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/domain/compositions/useDomainListing.tsx index e0bd356e504d..1ffce40d2fd4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/domain/compositions/useDomainListing.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/domain/compositions/useDomainListing.tsx @@ -12,7 +12,7 @@ */ import { useMemo } from 'react'; -import { TABLE_CARD_PAGE_SIZE } from '../../../../../constants/constants'; +import { PAGE_SIZE_BASE } from '../../../../../constants/constants'; import { SearchIndex } from '../../../../../enums/search.enum'; import { Domain } from '../../../../../generated/entity/domains/domain'; import domainClassBase from '../../../../../utils/Domain/DomainClassBase'; @@ -27,6 +27,7 @@ interface UseDomainListingConfig { enableNlq?: boolean; nameLabelKey?: string; pageSize?: number; + pageSizeOptions?: number[]; basePath?: string; includeOwners?: boolean; includeGlossaryTerms?: boolean; @@ -46,7 +47,8 @@ export const useDomainListing = ( baseFilter = '', enableNlq = false, nameLabelKey = 'label.domain', - pageSize = TABLE_CARD_PAGE_SIZE, + pageSize = PAGE_SIZE_BASE, + pageSizeOptions, basePath = '/domain', includeOwners = true, includeGlossaryTerms = true, @@ -103,6 +105,7 @@ export const useDomainListing = ( searchIndex: SearchIndex.DOMAIN, baseFilter, pageSize, + pageSizeOptions, filterKeys, filterConfigs, columns, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/shared/types.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/shared/types.ts index 5700899fe2f1..747cb2a7696c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/shared/types.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/shared/types.ts @@ -44,6 +44,7 @@ export interface UrlStateHook { setPageSize: (pageSize: number) => void; resetFilters: () => void; resetAll: () => void; + resetPaging: () => void; } export interface DataFetchingConfig { @@ -154,6 +155,7 @@ export interface ListingData { handleClearAll: () => void; handlePageChange: (page: number) => void; handlePageSizeChange?: (pageSize: number) => void; + resetPaging?: () => void; refetch: (options?: { silent?: boolean }) => void | Promise; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/access-control/AccessControlAuditLogsPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/access-control/AccessControlAuditLogsPanel.tsx index 4b11558157aa..0ff0b93ea543 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/access-control/AccessControlAuditLogsPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/access-control/AccessControlAuditLogsPanel.tsx @@ -46,6 +46,7 @@ import '../../../../../../components/common/atoms/filters/FilterSelection.less'; import Banner from '../../../../../../components/common/Banner/Banner'; import { CSVExportWebsocketResponse } from '../../../../../../components/Entity/EntityExportModalProvider/EntityExportModalProvider.interface'; import { + LIST_PAGE_SIZE_OPTIONS, PAGE_SIZE_BASE, PAGE_SIZE_LARGE, PAGE_SIZE_MEDIUM, @@ -53,6 +54,7 @@ import { } from '../../../../../../constants/constants'; import { useWebSocketConnector } from '../../../../../../context/WebSocketProvider/WebSocketProvider'; import { Paging } from '../../../../../../generated/type/paging'; +import { useSavedPageSize } from '../../../../../../hooks/paging/useSavedPageSize'; import { useHashPagingParams } from '../../../../../../hooks/useSettingsHash'; import { exportAuditLogs, @@ -127,10 +129,6 @@ async function walkToPageCursor( return { cursor, discoveredCursors }; } -function resolvePageSize(hash: number | undefined): number { - return hash || PAGE_SIZE_MEDIUM; -} - interface AccessControlAuditLogsPanelProps { /** Callback to inject action buttons into the page header. */ onSetHeaderActions?: (actions: React.ReactNode) => void; @@ -148,7 +146,8 @@ const AccessControlAuditLogsPanel: React.FC< cursor: hashCursor, cursorType: hashCursorType, setPage: setHashPage, - } = useHashPagingParams(); + } = useHashPagingParams(LIST_PAGE_SIZE_OPTIONS); + const { savePageSize, resolvePageSize } = useSavedPageSize(); const pageSize = resolvePageSize(hashPageSize); const [logs, setLogs] = useState([]); @@ -240,9 +239,10 @@ const AccessControlAuditLogsPanel: React.FC< const handlePageSizeChange = useCallback( (size: number) => { pageCursorsRef.current = {}; + savePageSize(size); setHashPage(1, undefined, undefined, size); }, - [setHashPage] + [savePageSize, setHashPage] ); const handleFiltersChange = useCallback( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/access-control/AccessControlPoliciesPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/access-control/AccessControlPoliciesPanel.tsx index 812889d4a1ba..c058863346ca 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/access-control/AccessControlPoliciesPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/access-control/AccessControlPoliciesPanel.tsx @@ -36,6 +36,7 @@ import React, { } from 'react'; import { useTranslation } from 'react-i18next'; import { + LIST_PAGE_SIZE_OPTIONS, PAGE_SIZE_BASE, PAGE_SIZE_LARGE, PAGE_SIZE_MEDIUM, @@ -53,6 +54,7 @@ import { } from '../../../../../../generated/entity/policies/policy'; import { EntityReference } from '../../../../../../generated/entity/type'; import { Paging } from '../../../../../../generated/type/paging'; +import { useSavedPageSize } from '../../../../../../hooks/paging/useSavedPageSize'; import { useHashPagingParams } from '../../../../../../hooks/useSettingsHash'; import { getPolicies } from '../../../../../../rest/rolesAPIV1'; import { hardDeleteEntity } from '../../../../../../utils/DeleteWidget/DeleteWidgetUtils'; @@ -86,8 +88,9 @@ const AccessControlPoliciesPanel: React.FC = ({ cursor: hashCursor, cursorType: hashCursorType, setPage: setHashPage, - } = useHashPagingParams(); - const pageSize = hashPageSize || PAGE_SIZE_BASE; + } = useHashPagingParams(LIST_PAGE_SIZE_OPTIONS); + const { savePageSize, resolvePageSize } = useSavedPageSize(); + const pageSize = resolvePageSize(hashPageSize); const [policies, setPolicies] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -283,6 +286,7 @@ const AccessControlPoliciesPanel: React.FC = ({ }; const handlePageSizeChange = (size: number) => { + savePageSize(size); setHashPage(1, undefined, undefined, size); setCursorCache(new Map()); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/access-control/AccessControlRolesPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/access-control/AccessControlRolesPanel.tsx index 35876ee555cb..c3cab08f7cdf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/access-control/AccessControlRolesPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/access-control/AccessControlRolesPanel.tsx @@ -36,6 +36,7 @@ import React, { } from 'react'; import { useTranslation } from 'react-i18next'; import { + LIST_PAGE_SIZE_OPTIONS, PAGE_SIZE_BASE, PAGE_SIZE_LARGE, PAGE_SIZE_MEDIUM, @@ -51,6 +52,7 @@ import { Operation } from '../../../../../../generated/entity/policies/policy'; import { Role } from '../../../../../../generated/entity/teams/role'; import { EntityReference } from '../../../../../../generated/entity/type'; import { Paging } from '../../../../../../generated/type/paging'; +import { useSavedPageSize } from '../../../../../../hooks/paging/useSavedPageSize'; import { useHashPagingParams } from '../../../../../../hooks/useSettingsHash'; import { getRoles } from '../../../../../../rest/rolesAPIV1'; import { hardDeleteEntity } from '../../../../../../utils/DeleteWidget/DeleteWidgetUtils'; @@ -84,8 +86,9 @@ const AccessControlRolesPanel: React.FC = ({ cursor: hashCursor, cursorType: hashCursorType, setPage: setHashPage, - } = useHashPagingParams(); - const pageSize = hashPageSize || PAGE_SIZE_BASE; + } = useHashPagingParams(LIST_PAGE_SIZE_OPTIONS); + const { savePageSize, resolvePageSize } = useSavedPageSize(); + const pageSize = resolvePageSize(hashPageSize); const [roles, setRoles] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -283,6 +286,7 @@ const AccessControlRolesPanel: React.FC = ({ }; const handlePageSizeChange = (size: number) => { + savePageSize(size); setHashPage(1, undefined, undefined, size); setCursorCache(new Map()); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/applications/ApplicationsList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/applications/ApplicationsList.tsx index c7bfcdb8911b..9a45c4eb2735 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/applications/ApplicationsList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/applications/ApplicationsList.tsx @@ -24,11 +24,7 @@ import { import { GridView } from '@openmetadata/ui-core-components/icons'; import { FC, ReactNode, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { - PAGE_SIZE_BASE, - PAGE_SIZE_LARGE, - PAGE_SIZE_MEDIUM, -} from '../../../../../../constants/constants'; +import { GRID_PAGE_SIZE_OPTIONS } from '../../../../../../constants/constants'; import { Include } from '../../../../../../generated/type/include'; import LimitWrapper from '../../../../../../hoc/LimitWrapper'; import { useAuth } from '../../../../../../hooks/authHooks'; @@ -45,12 +41,6 @@ import { getRuntimeDisabledReasonKey } from './Applications.utils'; /** Two cards per row: half of the 24-column core grid. */ export const CARD_SPAN = 12; -export const PAGE_SIZE_OPTIONS = [ - PAGE_SIZE_BASE, - PAGE_SIZE_MEDIUM, - PAGE_SIZE_LARGE, -]; - export const ApplicationsGrid: FC<{ isLoading: boolean; children: ReactNode; @@ -77,7 +67,7 @@ export const ApplicationsPagination: FC< minimal page={page} pageSize={pageSize} - pageSizeOptions={PAGE_SIZE_OPTIONS} + pageSizeOptions={GRID_PAGE_SIZE_OPTIONS} total={totalPages} onPageChange={onPageChange} onPageSizeChange={onPageSizeChange} @@ -105,7 +95,7 @@ const ApplicationsList: FC = ({ isLoading, showPagination, ...pagination - } = useCursorPagedList(fetchPage); + } = useCursorPagedList(fetchPage, GRID_PAGE_SIZE_OPTIONS); useEffect(() => { onHeaderChange({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/applications/MarketplaceList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/applications/MarketplaceList.tsx index c80030a7182c..de64e33c3cdd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/applications/MarketplaceList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/applications/MarketplaceList.tsx @@ -20,6 +20,7 @@ import { import { GridView } from '@openmetadata/ui-core-components/icons'; import { FC, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { GRID_PAGE_SIZE_OPTIONS } from '../../../../../../constants/constants'; import { useCursorPagedList } from '../../../../../../hooks/useCursorPagedList'; import { getMarketPlaceApplicationList } from '../../../../../../rest/applicationMarketPlaceAPI'; import { getEntityName } from '../../../../../../utils/EntityNameUtils'; @@ -41,7 +42,7 @@ const MarketplaceList: FC = ({ isLoading, showPagination, ...pagination - } = useCursorPagedList(getMarketPlaceApplicationList); + } = useCursorPagedList(getMarketPlaceApplicationList, GRID_PAGE_SIZE_OPTIONS); useEffect(() => { onHeaderChange({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/bots/BotsListPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/bots/BotsListPanel.test.tsx index 6908c7ba5bf0..567fd9b7689c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/bots/BotsListPanel.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/bots/BotsListPanel.test.tsx @@ -39,6 +39,13 @@ jest.mock('../../../../../../hooks/authHooks', () => ({ })); jest.mock('../../../../../../hooks/useSettingsHash', () => ({ + useHashPagingParams: () => ({ + page: 1, + pageSize: undefined, + cursor: undefined, + cursorType: undefined, + setPage: jest.fn(), + }), useSettingsHash: () => ({ state: { tab: 'bots', subPath: '', params: {} }, updateParams: mockUpdateParams, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/bots/BotsListPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/bots/BotsListPanel.tsx index 368ecc2a539c..8565ac79da64 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/bots/BotsListPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/bots/BotsListPanel.tsx @@ -39,6 +39,7 @@ import React, { } from 'react'; import { useTranslation } from 'react-i18next'; import { + LIST_PAGE_SIZE_OPTIONS, PAGE_SIZE_BASE, PAGE_SIZE_LARGE, PAGE_SIZE_MEDIUM, @@ -51,7 +52,11 @@ import { User } from '../../../../../../generated/entity/teams/user'; import { Include } from '../../../../../../generated/type/include'; import { Paging } from '../../../../../../generated/type/paging'; import { useAuth } from '../../../../../../hooks/authHooks'; -import { useSettingsHash } from '../../../../../../hooks/useSettingsHash'; +import { useSavedPageSize } from '../../../../../../hooks/paging/useSavedPageSize'; +import { + useHashPagingParams, + useSettingsHash, +} from '../../../../../../hooks/useSettingsHash'; import { getBots } from '../../../../../../rest/botsAPI'; import { searchQuery } from '../../../../../../rest/searchAPI'; import { formatUsersResponse } from '../../../../../../utils/APIUtils'; @@ -91,7 +96,7 @@ const BotsListPanel: React.FC = ({ const { t } = useTranslation(); const { isAdminUser } = useAuth(); const { getResourceLimit } = useLimitStore(); - const { state: hashState, updateParams } = useSettingsHash(); + const { updateParams } = useSettingsHash(); const [bots, setBots] = useState([]); const [searchedData, setSearchedData] = useState([]); @@ -100,12 +105,15 @@ const BotsListPanel: React.FC = ({ const [showDeleted, setShowDeleted] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [paging, setPaging] = useState({ total: 0 }); - const [currentPage, setCurrentPage] = useState( - Number(hashState.params.page) || 1 - ); - const [pageSize, setPageSize] = useState( - Number(hashState.params.pageSize) || PAGE_SIZE_BASE - ); + const { + page: hashPage, + pageSize: hashPageSize, + cursor: hashCursor, + cursorType: hashCursorType, + } = useHashPagingParams(LIST_PAGE_SIZE_OPTIONS); + const [currentPage, setCurrentPage] = useState(hashPage); + const { savePageSize, resolvePageSize } = useSavedPageSize(); + const [pageSize, setPageSize] = useState(resolvePageSize(hashPageSize)); const [reloadKey, setReloadKey] = useState(0); const fetchRequestIdRef = useRef(0); const latestSearchRequest = useRef(0); @@ -345,6 +353,7 @@ const BotsListPanel: React.FC = ({ const handlePageSizeChange = useCallback( (newSize: number) => { setPageSize(newSize); + savePageSize(newSize); setCurrentPage(1); updateParams({ page: '1', @@ -353,7 +362,7 @@ const BotsListPanel: React.FC = ({ pageSize: String(newSize), }); }, - [updateParams] + [savePageSize, updateParams] ); const handleShowDeletedToggle = useCallback( @@ -370,12 +379,15 @@ const BotsListPanel: React.FC = ({ ); const initialFetchRef = useRef(true); - const initialParamsRef = useRef(hashState.params); + const initialCursorRef = useRef({ + cursor: hashCursor, + cursorType: hashCursorType, + }); useEffect(() => { if (initialFetchRef.current) { initialFetchRef.current = false; - const { cursor, cursorType } = initialParamsRef.current; + const { cursor, cursorType } = initialCursorRef.current; fetchBots(cursor && cursorType ? { [cursorType]: cursor } : undefined); } else { fetchBots(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/bots/BotsPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/bots/BotsPanel.test.tsx index a809e2ad8fa7..e98895be5940 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/bots/BotsPanel.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/bots/BotsPanel.test.tsx @@ -20,6 +20,13 @@ const mockClearHash = jest.fn(); const mockUpdateParams = jest.fn(); jest.mock('../../../../../../hooks/useSettingsHash', () => ({ + useHashPagingParams: () => ({ + page: 1, + pageSize: undefined, + cursor: undefined, + cursorType: undefined, + setPage: jest.fn(), + }), useSettingsHash: () => ({ state: { tab: 'bots', subPath: '', params: {} }, setHash: mockSetHash, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/governance/GovernanceGlossaryList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/governance/GovernanceGlossaryList.tsx index cdf64c85c3d1..07e20a2381a6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/governance/GovernanceGlossaryList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/governance/GovernanceGlossaryList.tsx @@ -31,6 +31,7 @@ import { PAGE_SIZE_MEDIUM, } from '../../../../../../constants/constants'; import { RelationshipType } from '../../../../../../generated/entity/data/relationshipType'; +import { useSavedPageSize } from '../../../../../../hooks/paging/useSavedPageSize'; import RelationshipTypeTable from '../../../../../../pages/GlossaryTermRelationSettings/RelationshipTypeTable'; import { getRelationTypeUsageCounts } from '../../../../../../rest/glossaryAPI'; import { @@ -78,7 +79,8 @@ const GovernanceGlossaryList: FC = ({ const [usageCounts, setUsageCounts] = useState>({}); const [deleteTarget, setDeleteTarget] = useState(); const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(PAGE_SIZE_BASE); + const { savedPageSize, savePageSize } = useSavedPageSize(); + const [pageSize, setPageSize] = useState(savedPageSize); const fetchData = useCallback(async () => { setIsLoading(true); @@ -181,6 +183,7 @@ const GovernanceGlossaryList: FC = ({ onPageChange={setCurrentPage} onPageSizeChange={(next) => { setPageSize(next); + savePageSize(next); setCurrentPage(1); }} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationAlertsPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationAlertsPanel.test.tsx index 15058e08d07c..7166eeab4913 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationAlertsPanel.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationAlertsPanel.test.tsx @@ -192,6 +192,7 @@ jest.mock( ); jest.mock('../../../../../../constants/constants', () => ({ + ...jest.requireActual('../../../../../../constants/constants'), PAGE_SIZE_BASE: 10, PAGE_SIZE_MEDIUM: 25, PAGE_SIZE_LARGE: 50, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationAlertsPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationAlertsPanel.tsx index 7e35ca6daeda..462c2487a4fb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationAlertsPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationAlertsPanel.tsx @@ -37,6 +37,7 @@ import React, { } from 'react'; import { useTranslation } from 'react-i18next'; import { + LIST_PAGE_SIZE_OPTIONS, PAGE_SIZE_BASE, PAGE_SIZE_LARGE, PAGE_SIZE_MEDIUM, @@ -50,6 +51,7 @@ import { ProviderType, } from '../../../../../../generated/events/eventSubscription'; import { Paging } from '../../../../../../generated/type/paging'; +import { useSavedPageSize } from '../../../../../../hooks/paging/useSavedPageSize'; import { useHashPagingParams } from '../../../../../../hooks/useSettingsHash'; import { getAlertsFromName, @@ -82,8 +84,9 @@ const NotificationAlertsPanel: React.FC = ({ cursor: hashCursor, cursorType: hashCursorType, setPage: setHashPage, - } = useHashPagingParams(); - const pageSize = hashPageSize || PAGE_SIZE_BASE; + } = useHashPagingParams(LIST_PAGE_SIZE_OPTIONS); + const { savePageSize, resolvePageSize } = useSavedPageSize(); + const pageSize = resolvePageSize(hashPageSize); const [alerts, setAlerts] = useState([]); const [isLoading, setIsLoading] = useState(false); const [selectedAlert, setSelectedAlert] = useState(); @@ -327,6 +330,7 @@ const NotificationAlertsPanel: React.FC = ({ }; const handlePageSizeChange = (size: number) => { + savePageSize(size); setHashPage(1, undefined, undefined, size); setCursorCache(new Map()); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationRecentEvents.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationRecentEvents.test.tsx index ffe7b2555ec2..444a5a028f9b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationRecentEvents.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationRecentEvents.test.tsx @@ -81,6 +81,13 @@ jest.mock('../../../../../../rest/alertsAPI', () => ({ })); jest.mock('../../../../../../hooks/useSettingsHash', () => ({ + useHashPagingParams: () => ({ + page: 1, + pageSize: undefined, + cursor: undefined, + cursorType: undefined, + setPage: jest.fn(), + }), useSettingsHash: () => ({ state: { params: {} }, updateParams: jest.fn(), @@ -136,6 +143,7 @@ jest.mock('../../../../../../assets/svg/ic-filter-off.svg', () => ({ })); jest.mock('../../../../../../constants/constants', () => ({ + ...jest.requireActual('../../../../../../constants/constants'), PAGE_SIZE_BASE: 10, PAGE_SIZE_MEDIUM: 25, PAGE_SIZE_LARGE: 50, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationRecentEvents.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationRecentEvents.tsx index 054230b36ba3..dd34e5f1af8a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationRecentEvents.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/notification/NotificationRecentEvents.tsx @@ -32,6 +32,7 @@ import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as FilterOffIcon } from '../../../../../../assets/svg/ic-filter-off.svg'; import { + LIST_PAGE_SIZE_OPTIONS, PAGE_SIZE_BASE, PAGE_SIZE_LARGE, PAGE_SIZE_MEDIUM, @@ -45,7 +46,11 @@ import { } from '../../../../../../generated/events/api/typedEvent'; import { EventSubscription } from '../../../../../../generated/events/eventSubscription'; import { Paging } from '../../../../../../generated/type/paging'; -import { useSettingsHash } from '../../../../../../hooks/useSettingsHash'; +import { useSavedPageSize } from '../../../../../../hooks/paging/useSavedPageSize'; +import { + useHashPagingParams, + useSettingsHash, +} from '../../../../../../hooks/useSettingsHash'; import { getAlertEventsFromId } from '../../../../../../rest/alertsAPI'; import { getAlertStatusIcon } from '../../../../../../utils/Alerts/AlertsUtil'; import { @@ -86,9 +91,12 @@ function NotificationRecentEvents({ const [filter, setFilter] = useState( AlertRecentEventFilters.ALL ); - const { state: hashState, updateParams } = useSettingsHash(); - const initialPage = Number(hashState.params.page) || 1; - const initialPageSize = Number(hashState.params.pageSize) || PAGE_SIZE_BASE; + const { updateParams } = useSettingsHash(); + const { page: initialPage, pageSize: hashPageSize } = useHashPagingParams( + LIST_PAGE_SIZE_OPTIONS + ); + const { savePageSize, resolvePageSize } = useSavedPageSize(); + const initialPageSize = resolvePageSize(hashPageSize); const [currentPage, setCurrentPage] = useState(initialPage); const [pageSize, setPageSize] = useState(initialPageSize); @@ -154,10 +162,11 @@ function NotificationRecentEvents({ (size: number) => { setCurrentPage(1); setPageSize(size); + savePageSize(size); updateParams({ page: '1', pageSize: String(size) }); getAlertRecentEvents(0, size); }, - [updateParams, getAlertRecentEvents] + [savePageSize, updateParams, getAlertRecentEvents] ); const showPagination = useMemo( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/personas/PersonasLanding.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/personas/PersonasLanding.test.tsx index f7e7a170fc1e..bfd7d8b1c601 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/personas/PersonasLanding.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/personas/PersonasLanding.test.tsx @@ -63,7 +63,7 @@ jest.mock('@openmetadata/ui-core-components', () => ({ -
@@ -116,7 +116,7 @@ describe('PersonasLanding', () => { ); expect(getAllPersonas).toHaveBeenCalledWith({ - limit: 15, + limit: 12, fields: 'users', after: undefined, before: undefined, @@ -184,7 +184,7 @@ describe('PersonasLanding', () => { ); - expect(await screen.findByTestId('pagination')).toHaveTextContent('1/3'); + expect(await screen.findByTestId('pagination')).toHaveTextContent('1/4'); (getAllPersonas as jest.Mock).mockResolvedValue({ data: PERSONAS, @@ -192,7 +192,7 @@ describe('PersonasLanding', () => { }); fireEvent.click(screen.getByText('next')); - expect(mockSetPage).toHaveBeenCalledWith(2, 'after', 'cursor-2', 15); + expect(mockSetPage).toHaveBeenCalledWith(2, 'after', 'cursor-2', 12); await waitFor(() => expect(getAllPersonas).toHaveBeenLastCalledWith( @@ -205,11 +205,11 @@ describe('PersonasLanding', () => { fireEvent.click(screen.getByText('next')); - expect(mockSetPage).toHaveBeenLastCalledWith(3, 'after', 'cursor-3', 15); + expect(mockSetPage).toHaveBeenLastCalledWith(3, 'after', 'cursor-3', 12); fireEvent.click(screen.getByText('previous')); - expect(mockSetPage).toHaveBeenLastCalledWith(1, undefined, undefined, 15); + expect(mockSetPage).toHaveBeenLastCalledWith(1, undefined, undefined, 12); await waitFor(() => expect(getAllPersonas).toHaveBeenLastCalledWith( @@ -221,7 +221,7 @@ describe('PersonasLanding', () => { it('resumes from a cursor in the hash on mount', async () => { mockPagingParams({ page: 3, - pageSize: 25, + pageSize: 24, cursor: 'cursor-x', cursorType: 'before', }); @@ -230,7 +230,7 @@ describe('PersonasLanding', () => { await screen.findByTestId('persona-card-analyst'); expect(getAllPersonas).toHaveBeenCalledWith({ - limit: 25, + limit: 24, fields: 'users', after: undefined, before: 'cursor-x', @@ -243,7 +243,7 @@ describe('PersonasLanding', () => { await screen.findByTestId('persona-card-analyst'); - expect(mockSetPage).toHaveBeenCalledWith(1, undefined, undefined, 15); + expect(mockSetPage).toHaveBeenCalledWith(1, undefined, undefined, 12); expect(getAllPersonas).toHaveBeenCalledWith( expect.objectContaining({ after: undefined, before: undefined }) ); @@ -258,6 +258,6 @@ describe('PersonasLanding', () => { fireEvent.click(await screen.findByText('size')); - expect(mockSetPage).toHaveBeenCalledWith(1, undefined, undefined, 25); + expect(mockSetPage).toHaveBeenCalledWith(1, undefined, undefined, 24); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/personas/PersonasLanding.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/personas/PersonasLanding.tsx index 938cc3c2db7a..6b04f599981c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/personas/PersonasLanding.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/personas/PersonasLanding.tsx @@ -23,14 +23,11 @@ import { import { AxiosError } from 'axios'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { - PAGE_SIZE_BASE, - PAGE_SIZE_LARGE, - PAGE_SIZE_MEDIUM, -} from '../../../../../../constants/constants'; +import { GRID_PAGE_SIZE_OPTIONS } from '../../../../../../constants/constants'; import { TabSpecificField } from '../../../../../../enums/entity.enum'; import { Persona } from '../../../../../../generated/entity/teams/persona'; import { Paging } from '../../../../../../generated/type/paging'; +import { useSavedPageSize } from '../../../../../../hooks/paging/useSavedPageSize'; import { useHashPagingParams } from '../../../../../../hooks/useSettingsHash'; import { getAllPersonas } from '../../../../../../rest/PersonaAPI'; import { getEntityName } from '../../../../../../utils/EntityNameUtils'; @@ -52,8 +49,11 @@ const PersonasLanding = ({ onNavigate }: PersonasLandingProps) => { cursor: hashCursor, cursorType: hashCursorType, setPage: setHashPage, - } = useHashPagingParams(); - const pageSize = hashPageSize || PAGE_SIZE_BASE; + } = useHashPagingParams(GRID_PAGE_SIZE_OPTIONS); + const { savePageSize, resolvePageSize } = useSavedPageSize( + GRID_PAGE_SIZE_OPTIONS + ); + const pageSize = resolvePageSize(hashPageSize); const [personas, setPersonas] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -140,6 +140,7 @@ const PersonasLanding = ({ onNavigate }: PersonasLandingProps) => { }; const handlePageSizeChange = (size: number) => { + savePageSize(size); setHashPage(1, undefined, undefined, size); setCursorCache(new Map()); }; @@ -261,7 +262,7 @@ const PersonasLanding = ({ onNavigate }: PersonasLandingProps) => { (); + const view = selectedView ?? personaView; + const pageSizeOptions = getViewPageSizeOptions(view); + const { savedPageSize: pageSize, savePageSize } = + useSavedPageSize(pageSizeOptions); + const handleViewChange = useCallback((nextView: PageViewMode) => { + setSelectedView(nextView); + setCurrentPage(1); + }, []); const { resources, paging, isLoading, refetch } = useLearningResources({ searchText, @@ -141,10 +154,6 @@ const LearningResourcesSettings = ({ onFilterChange: setFilterState, }); - const personaView = usePersonaViewMode(VIEW_MODE_PAGE.LearningResources); - const [selectedView, setSelectedView] = useState(); - const view = selectedView ?? personaView; - const debouncedSearch = useMemo( () => debounce(setSearchText, SEARCH_DEBOUNCE_MS), [] @@ -279,11 +288,11 @@ const LearningResourcesSettings = ({ currentPage={currentPage} isLoading={isLoading} pageSize={pageSize} - pageSizeOptions={[PAGE_SIZE_BASE, PAGE_SIZE_MEDIUM, PAGE_SIZE_LARGE]} + pageSizeOptions={pageSizeOptions} paging={{ total: paging.total }} pagingHandler={({ currentPage: page }) => setCurrentPage(page)} onShowSizeChange={(size) => { - setPageSize(size); + savePageSize(size); setCurrentPage(1); }} /> @@ -310,7 +319,7 @@ const LearningResourcesSettings = ({ /> {quickFilters} - + {filterSelectionDisplay} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchPreviewPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchPreviewPanel.tsx index 63c315fd70c6..c0e5e4501154 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchPreviewPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/Profile/tabs/platform-settings/search/SearchPreviewPanel.tsx @@ -23,9 +23,9 @@ import { AxiosError } from 'axios'; import { debounce } from 'lodash'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { PAGE_SIZE_BASE } from '../../../../../../../constants/constants'; import { ENTITY_PATH_TO_SEARCH_INDEX } from '../../../../../../../constants/SearchSettings.constant'; import { SearchSettings } from '../../../../../../../generated/configuration/searchSettings'; +import { useSavedPageSize } from '../../../../../../../hooks/paging/useSavedPageSize'; import { searchPreview } from '../../../../../../../rest/searchAPI'; import { showErrorToast } from '../../../../../../../utils/ToastUtils'; import ExploreSearchCard from '../../../../../../ExploreV1/ExploreSearchCard/ExploreSearchCard'; @@ -51,7 +51,8 @@ const SearchPreviewPanel = ({ const [query, setQuery] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(PAGE_SIZE_BASE); + const { savedPageSize, savePageSize } = useSavedPageSize(); + const [pageSize, setPageSize] = useState(savedPageSize); const [showRankingDetails, setShowRankingDetails] = useState(false); const [hits, setHits] = useState([]); const [total, setTotal] = useState(0); @@ -200,6 +201,7 @@ const SearchPreviewPanel = ({ onPageChange={setPage} onPageSizeChange={(next) => { setPageSize(next); + savePageSize(next); setPage(1); }} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsListView.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsListView.test.tsx index 24ece142f390..d16fcc62781f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsListView.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsListView.test.tsx @@ -13,6 +13,10 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; +import { + GRID_PAGE_SIZE_OPTIONS, + LIST_PAGE_SIZE_OPTIONS, +} from '../../../constants/constants'; import { ALL_SERVICES_CATEGORY } from '../../../constants/Services.constant'; import { ServiceCategory } from '../../../enums/service.enum'; import { EXTENSION_POINTS } from '../../../utils/ExtensionPointTypes'; @@ -20,10 +24,6 @@ import ConnectionsListView, { NAME_COLUMN_WIDTH, SECONDARY_COLUMN_WIDTH, } from './ConnectionsListView'; -import { - GRID_PAGE_SIZE_OPTIONS, - LIST_PAGE_SIZE_OPTIONS, -} from './ConnectionsPage.constants'; import { ConnectionsCategory } from './useConnectionsData'; const mockNavigate = jest.fn(); @@ -449,7 +449,7 @@ const defaultData = { serviceTypeOptions: [{ count: 24, value: 'Mysql' }], serviceTypesInTab: ['Mysql'], page: 1, - pageSize: 10, + pageSize: LIST_PAGE_SIZE_OPTIONS[0], rows: [row], setPage: mockSetPage, setPageSize: mockSetPageSize, @@ -1271,7 +1271,7 @@ describe('ConnectionsListView', () => { ); }); - it('uses twelve cards per grid page and ten rows per list page', () => { + it('uses the grid scale for cards and the list scale for rows', () => { renderView('grid'); expect(mockUseConnectionsData).toHaveBeenLastCalledWith( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsListView.tsx b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsListView.tsx index daa9bdc6dc37..a4753e8f3015 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsListView.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsListView.tsx @@ -30,6 +30,10 @@ import React, { } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import { + GRID_PAGE_SIZE_OPTIONS, + LIST_PAGE_SIZE_OPTIONS, +} from '../../../constants/constants'; import { SERVICE_EMPTY_STATE_ICON_CLASS } from '../../../constants/ServiceEmptyState.constant'; import { ServiceHealth } from '../../../generated/api/services/servicesOverview'; import { TagSource } from '../../../generated/type/tagLabel'; @@ -53,9 +57,7 @@ import { DELETED_PARAM, ENTITY_TYPE_TO_CATEGORY, getConnectionsEmptyState, - GRID_PAGE_SIZE_OPTIONS, HEALTH_PARAM, - LIST_PAGE_SIZE_OPTIONS, SERVICE_TYPE_PARAM, } from './ConnectionsPage.constants'; import ConnectionsPageSkeleton from './ConnectionsPageSkeleton'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsPage.constants.ts b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsPage.constants.ts index 1dbc56a559f8..7dc4c3870d17 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsPage.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/ConnectionsPage.constants.ts @@ -107,9 +107,6 @@ export const CATEGORY_TO_ENTITY_TYPE = Object.fromEntries( // The mode is derived from the response's own `total`, never guessed. export const SERVICES_ESTATE_LIMIT = 500; -export const GRID_PAGE_SIZE_OPTIONS = [12, 24, 48]; -// Same steps as the grid so switching layout keeps the page you were on rather than resizing it. -export const LIST_PAGE_SIZE_OPTIONS = [12, 24, 48]; export const VIEW_MODE_PARAM = 'viewMode'; export const CATEGORY_PARAM = 'category'; // Health filter, set either by this page's own dropdown or by the landing Platform Health diff --git a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsData.test.ts b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsData.test.ts index 5a9a5802e8cc..740a5b220206 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsData.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsData.test.ts @@ -15,12 +15,12 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { act, renderHook, waitFor } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { ServiceHealth } from '../../../generated/api/services/servicesOverview'; -import { queryClient } from '../../../queryClient'; import { GRID_PAGE_SIZE_OPTIONS, LIST_PAGE_SIZE_OPTIONS, -} from './ConnectionsPage.constants'; +} from '../../../constants/constants'; +import { ServiceHealth } from '../../../generated/api/services/servicesOverview'; +import { queryClient } from '../../../queryClient'; import { ConnectionsCategory, useConnectionsData } from './useConnectionsData'; const mockGetServicesOverview = jest.fn(); @@ -50,7 +50,9 @@ jest.mock('../../../hooks/paging/usePaging', () => ({ .usePaging(defaultPageSize, pageSizeOptions); const { useCallback, useState } = jest.requireActual('react'); const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(defaultPageSize ?? 10); + const [pageSize, setPageSize] = useState( + defaultPageSize ?? pageSizeOptions?.[0] ?? 15 + ); const handlePageChange = useCallback( (page: number | ((page: number) => number)) => { setCurrentPage((current: number) => @@ -432,6 +434,19 @@ describe('useConnectionsData', () => { await waitFor(() => expect(result.current.page).toBe(1)); }); + it('returns to page 1 when the layout switches to the other page-size scale', async () => { + const { result, rerender } = renderConnections(); + await goToPageThree(result, rerender); + + const gridArgs = { + ...defaultArgs, + pageSizeOptions: GRID_PAGE_SIZE_OPTIONS, + }; + rerender(gridArgs); + + await waitFor(() => expect(result.current.page).toBe(1)); + }); + it('keeps the page when only the sort order changes', async () => { const { result, rerender } = renderConnections(); await goToPageThree(result, rerender); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsData.ts b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsData.ts index 88096a2ad257..a3024082e0d2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsData.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsData.ts @@ -16,8 +16,9 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query'; -import { debounce } from 'lodash'; +import { debounce, noop } from 'lodash'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { LIST_PAGE_SIZE_OPTIONS } from '../../../constants/constants'; import { ServiceHealth, ServiceSummary, @@ -30,7 +31,6 @@ import { ConnectionsServiceCategory, CONNECTIONS_ENTITY_TYPES, ENTITY_TYPE_TO_CATEGORY, - LIST_PAGE_SIZE_OPTIONS, SERVICES_ESTATE_LIMIT, } from './ConnectionsPage.constants'; import { @@ -180,7 +180,7 @@ export const useConnectionsData = ({ pageSize, handlePageChange, handlePageSizeChange, - } = usePaging(pageSizeOptions[0], pageSizeOptions); + } = usePaging(undefined, pageSizeOptions); const [sortOrder, setSortOrder] = useState('asc'); // Any change to what is being queried invalidates the current page number. usePaging restores @@ -189,6 +189,7 @@ export const useConnectionsData = ({ // non-zero count, and server mode asks for an offset the narrowed result set no longer has. // Sort order is deliberately absent — reordering does not change which rows match. const queryKey = [ + pageSizeOptions.join(','), category, String(showDeleted), searchTerm.trim(), @@ -385,9 +386,9 @@ export const useConnectionsData = ({ return; } - void queryClient.prefetchQuery( - getServicesOverviewQueryOptions({ ...ESTATE_PARAMS, include }) - ); + queryClient + .query(getServicesOverviewQueryOptions({ ...ESTATE_PARAMS, include })) + .catch(noop); }); const toggleSortOrder = () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsViewMode.test.ts b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsViewMode.test.ts index 0d0b633eea81..06bfda5388cc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsViewMode.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsViewMode.test.ts @@ -86,4 +86,25 @@ describe('useConnectionsViewMode', () => { connectionsViewMode: 'list', }); }); + + it('drops the paging params so the new view starts at its own saved size', () => { + const { result } = renderHook(() => useConnectionsViewMode()); + + act(() => result.current.setViewMode('grid')); + + const [updater] = mockSetSearchParams.mock.calls[0]; + const next = updater( + new URLSearchParams( + 'category=all&pageSize=50¤tPage=3&cursorType=after&cursorValue=abc' + ) + ); + + // A list link's 50 must not come back when the user returns to the list. + expect(next.get('pageSize')).toBeNull(); + expect(next.get('currentPage')).toBeNull(); + expect(next.get('cursorType')).toBeNull(); + expect(next.get('cursorValue')).toBeNull(); + expect(next.get('category')).toBe('all'); + expect(next.get('viewMode')).toBe('grid'); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsViewMode.ts b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsViewMode.ts index a4a474779062..264e2c54e430 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsViewMode.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionsPage/useConnectionsViewMode.ts @@ -22,6 +22,8 @@ const isConnectionsViewMode = ( value: string | null | undefined ): value is ConnectionsViewMode => value === 'grid' || value === 'list'; +const PAGING_PARAMS = ['currentPage', 'pageSize', 'cursorType', 'cursorValue']; + export const useConnectionsViewMode = () => { const [searchParams, setSearchParams] = useSearchParams(); const { @@ -43,6 +45,7 @@ export const useConnectionsViewMode = () => { (previous) => { const next = new URLSearchParams(previous); next.set(VIEW_MODE_PARAM, nextViewMode); + PAGING_PARAMS.forEach((param) => next.delete(param)); return next; }, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiRecentEventsTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiRecentEventsTab.tsx index bc5592b6e202..62bcafa3deb6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiRecentEventsTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/Alerts/AlertAiRecentEventsTab.tsx @@ -190,7 +190,7 @@ const AlertAiRecentEventsTab = ({ pageSize, paging, showPagination, - } = usePaging(PAGE_SIZE_BASE, PAGE_SIZE_OPTIONS); + } = usePaging(undefined, PAGE_SIZE_OPTIONS); const { id } = alertDetails; const isFiltered = filter !== AlertRecentEventFilters.ALL; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/IncidentGroupDetail.tsx b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/IncidentGroupDetail.tsx index 4d6106d5dff1..b946436ba082 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/IncidentGroupDetail.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/IncidentGroupDetail.tsx @@ -28,7 +28,6 @@ import { Transi18next } from '../../../../utils/i18next/LocalUtil'; import { computeTotalPages } from '../../../../utils/PaginationUtils'; import IncidentGroupRelatedBadge from './IncidentGroupRelatedBadge'; import { - INCIDENT_GROUPS_PAGE_SIZE, INCIDENT_GROUPS_PAGE_SIZE_OPTIONS, INCIDENT_GROUP_SEPARATOR, INCIDENT_TABLE_CARD_CLASS, @@ -73,7 +72,6 @@ const IncidentGroupDetail = ({ } = useIncidentGroupIncidents({ group, filters, - defaultPageSize: INCIDENT_GROUPS_PAGE_SIZE, }); const name = getIncidentGroupName( group, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/IncidentGroupDrawer.tsx b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/IncidentGroupDrawer.tsx index 02dc8a6e716c..96649effb371 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/IncidentGroupDrawer.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/IncidentGroupDrawer.tsx @@ -34,7 +34,6 @@ import { formatDate } from '../../../../utils/date-time/DateTimeUtils'; import { computeTotalPages } from '../../../../utils/PaginationUtils'; import IncidentGroupRelatedBadge from './IncidentGroupRelatedBadge'; import { - INCIDENT_GROUPS_PAGE_SIZE, INCIDENT_GROUPS_PAGE_SIZE_OPTIONS, INCIDENT_TABLE_CARD_CLASS, INCIDENT_TABLE_PAGER_CLASS, @@ -105,7 +104,6 @@ const IncidentGroupDrawer = ({ } = useIncidentGroupIncidents({ group, filters, - defaultPageSize: INCIDENT_GROUPS_PAGE_SIZE, }); return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/IncidentGroups.constants.ts b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/IncidentGroups.constants.ts index 5f6434cc4e76..c38b81df5222 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/IncidentGroups.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/IncidentGroups.constants.ts @@ -63,8 +63,6 @@ export const INCIDENT_GROUP_DETAIL_PARAM = 'group'; /** Dimension the page opens with when the URL does not carry a valid one. */ export const DEFAULT_INCIDENT_GROUP_BY = IncidentGroupBy.TestDefinition; -export const INCIDENT_GROUPS_PAGE_SIZE = PAGE_SIZE_BASE; - /** * The incident tables' card edge, as designed: a full border with no shadow. * Drawn as a border because the card's own outline is painted over by a diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentGroupIncidents.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentGroupIncidents.test.tsx index a046f2f61dee..bfe3e9440da2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentGroupIncidents.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentGroupIncidents.test.tsx @@ -32,6 +32,15 @@ jest.mock('../../../../rest/incidentManagerAPI', () => ({ getListTestCaseIncidentStatus: jest.fn(), })); +// Small pages keep the paging cases short; the real hook reads the user's saved list size. +jest.mock('../../../../hooks/paging/useSavedPageSize', () => ({ + useSavedPageSize: () => ({ + savedPageSize: 4, + savePageSize: jest.fn(), + resolvePageSize: jest.fn(), + }), +})); + jest.mock('../../../../utils/ToastUtils', () => ({ showErrorToast: jest.fn(), })); @@ -68,7 +77,6 @@ const renderIncidents = (group?: TestCaseIncidentGroup) => useIncidentGroupIncidents({ group: current, filters: FILTERS, - defaultPageSize: 4, }), { initialProps: { current: group }, wrapper: createQueryWrapper() } ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentGroupIncidents.ts b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentGroupIncidents.ts index e882f821b05c..d1ecba42d7d1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentGroupIncidents.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentGroupIncidents.ts @@ -32,7 +32,6 @@ interface UseIncidentGroupIncidentsProps { group?: TestCaseIncidentGroup; /** The filters the group was fetched with, so the list matches its count. */ filters: IncidentGroupFilters; - defaultPageSize: number; } const NO_INCIDENTS: TestCaseResolutionStatus[] = []; @@ -49,7 +48,6 @@ const NO_INCIDENTS: TestCaseResolutionStatus[] = []; export const useIncidentGroupIncidents = ({ group, filters, - defaultPageSize, }: UseIncidentGroupIncidentsProps) => { const { activeDomain } = useDomainStore(); const domain = @@ -58,8 +56,7 @@ export const useIncidentGroupIncidents = ({ ? `${group.groupBy}|${getIncidentGroupKey(group)}` : ''; const { currentPage, pageSize, setPageSize, goToPage } = useIncidentPaging( - `${groupKey}|${domain}|${JSON.stringify(filters)}`, - defaultPageSize + `${groupKey}|${domain}|${JSON.stringify(filters)}` ); // The group object changes with every re-read of the groups; the read is // keyed on its identity, and takes the latest object when it runs. diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentGroups.ts b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentGroups.ts index a3ba1e41d14c..94ed911f7c80 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentGroups.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentGroups.ts @@ -37,7 +37,6 @@ import { import { showErrorToast } from '../../../../utils/ToastUtils'; import { DEFAULT_INCIDENT_GROUP_SORT, - INCIDENT_GROUPS_PAGE_SIZE, INCIDENT_GROUP_BY_PARAM, INCIDENT_GROUP_DETAIL_PARAM, INCIDENT_GROUP_FILTER_KEYS, @@ -286,8 +285,7 @@ export const useIncidentGroups = () => { DEFAULT_INCIDENT_GROUP_SORT ); const { currentPage, pageSize, setPageSize, goToPage } = useIncidentPaging( - `${groupBy}|${sort.field}|${sort.type}|${domain}|${filtersSearch}`, - INCIDENT_GROUPS_PAGE_SIZE + `${groupBy}|${sort.field}|${sort.type}|${domain}|${filtersSearch}` ); const listParams = { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentPaging.test.ts b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentPaging.test.ts index 3f2d931f31f0..db26db0f7517 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentPaging.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentPaging.test.ts @@ -15,15 +15,16 @@ import { act, renderHook } from '@testing-library/react'; import { useIncidentPaging } from './useIncidentPaging'; describe('useIncidentPaging', () => { - it('should start on the first page in the default size', () => { - const { result } = renderHook(() => useIncidentPaging('query', 10)); + it('should start on the first page at the saved page size', () => { + const { result } = renderHook(() => useIncidentPaging('query')); + // No size saved yet, so the list scale's first step. expect(result.current.currentPage).toBe(1); - expect(result.current.pageSize).toBe(10); + expect(result.current.pageSize).toBe(15); }); it('should go straight to any page asked for', () => { - const { result } = renderHook(() => useIncidentPaging('query', 10)); + const { result } = renderHook(() => useIncidentPaging('query')); act(() => result.current.goToPage(7)); @@ -36,7 +37,7 @@ describe('useIncidentPaging', () => { it('should start over when the query changes', () => { const { result, rerender } = renderHook( - ({ queryKey }) => useIncidentPaging(queryKey, 10), + ({ queryKey }) => useIncidentPaging(queryKey), { initialProps: { queryKey: 'query' } } ); @@ -47,7 +48,7 @@ describe('useIncidentPaging', () => { }); it('should start over in the new size when the page size changes', () => { - const { result } = renderHook(() => useIncidentPaging('query', 10)); + const { result } = renderHook(() => useIncidentPaging('query')); act(() => result.current.goToPage(3)); act(() => result.current.setPageSize(25)); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentPaging.ts b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentPaging.ts index 4fdaaf5648d8..2a9f7dc4c7de 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentPaging.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/observability/IncidentManager/IncidentGroups/useIncidentPaging.ts @@ -12,6 +12,7 @@ */ import { useCallback, useState } from 'react'; +import { useSavedPageSize } from '../../../../hooks/paging/useSavedPageSize'; interface PageState { /** The query the page belongs to; a page of another query is page 1. */ @@ -25,11 +26,16 @@ interface PageState { * reset is derived rather than set from an effect, so the stale page is never * fetched on the way. */ -export const useIncidentPaging = ( - queryKey: string, - defaultPageSize: number -) => { - const [pageSize, setPageSize] = useState(defaultPageSize); +export const useIncidentPaging = (queryKey: string) => { + const { savedPageSize, savePageSize } = useSavedPageSize(); + const [pageSize, setPageSize] = useState(savedPageSize); + const changePageSize = useCallback( + (size: number) => { + setPageSize(size); + savePageSize(size); + }, + [savePageSize] + ); const pagedKey = `${queryKey}|${pageSize}`; const [page, setPage] = useState({ queryKey: pagedKey, @@ -43,5 +49,5 @@ export const useIncidentPaging = ( [pagedKey] ); - return { currentPage, pageSize, setPageSize, goToPage }; + return { currentPage, pageSize, setPageSize: changePageSize, goToPage }; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/ContextCenter.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/ContextCenter.constants.ts index 4f6824767377..1adc4ced87cc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/ContextCenter.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/ContextCenter.constants.ts @@ -73,7 +73,6 @@ export const ARTICLE_SORT_OPTIONS: ArticleSortOption[] = [ export const DEFAULT_ARTICLE_SORT_OPTION = ARTICLE_SORT_OPTIONS[0]; -export const ARCHIVE_PAGE_SIZE = 15; export const RECENT_DASHBOARD_ARTICLES_LIMIT = 3; export const RECENT_DASHBOARD_DOCUMENTS_LIMIT = 3; export const RECENT_DASHBOARD_MEMORIES_LIMIT = 3; @@ -133,7 +132,6 @@ export const VISIBILITY_OPTIONS = [ }, ]; -export const MEMORIES_PER_PAGE = 10; export const MEMORY_FIELDS = 'owners,tags,domains,primaryEntity,relatedEntities,derivedEntities,sourceEntity'; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/Learning.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/Learning.constants.ts index b83de92bc2a4..656c792c4466 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/Learning.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/Learning.constants.ts @@ -22,7 +22,6 @@ export { LearningResourceStatus, ResourceCategory, ResourceType }; export const MAX_VISIBLE_TAGS = 2; export const MAX_VISIBLE_CONTEXTS = 2; -export const DEFAULT_PAGE_SIZE = 10; export const MAX_CHAIN_PAGES = 25; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts index 32fc216fb602..8a6bf2a79ef3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts @@ -56,10 +56,17 @@ export const INITIAL_PAGING_VALUE = 1; export const JSON_TAB_SIZE = 2; export const KNOWLEDGE_LIST_LENGTH = 8; export const PAGE_SIZE = 10; -export const TABLE_CARD_PAGE_SIZE = 9; export const PAGE_SIZE_BASE = 15; export const PAGE_SIZE_MEDIUM = 25; export const PAGE_SIZE_LARGE = 50; +// ADR:2026-10-13-ui-page-sizes-lists-15-25-50-grids-12-24-48 +export const LIST_PAGE_SIZE_OPTIONS = [ + PAGE_SIZE_BASE, + PAGE_SIZE_MEDIUM, + PAGE_SIZE_LARGE, +]; +export const GRID_PAGE_SIZE_BASE = 12; +export const GRID_PAGE_SIZE_OPTIONS = [GRID_PAGE_SIZE_BASE, 24, 48]; export const PAGE_SIZE_EXTRA_LARGE = 100; export const AGGREGATE_PAGE_SIZE_LARGE = 1000; export const ES_MAX_PAGE_SIZE = 10000; diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts index efea69202fed..8a60be80c1ea 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts @@ -16,7 +16,7 @@ import { RecentlySearchedData, RecentlyViewedData } from 'Models'; import { useCallback, useMemo } from 'react'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; -import { PAGE_SIZE_BASE } from '../../constants/constants'; +import { GRID_PAGE_SIZE_BASE, PAGE_SIZE_BASE } from '../../constants/constants'; import { deleteUserPreference, putUserPreference } from '../../rest/userAPI'; import { showErrorToast } from '../../utils/ToastUtils'; import { useApplicationStore } from '../useApplicationStore'; @@ -30,6 +30,7 @@ export interface UserPreferences { isSidebarCollapsed: boolean; selectedEntityTableColumns: Record; globalPageSize: number; + globalGridPageSize: number; recentlyViewed: RecentlyViewedData[]; recentlySearched: RecentlySearchedData[]; recentlyViewedQuickLinks: RecentlyViewedData[]; @@ -64,6 +65,7 @@ const defaultPreferences: UserPreferences = { isSidebarCollapsed: false, selectedEntityTableColumns: {}, globalPageSize: PAGE_SIZE_BASE, + globalGridPageSize: GRID_PAGE_SIZE_BASE, recentlyViewed: [], recentlySearched: [], recentlyViewedQuickLinks: [], diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePageForSize.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePageForSize.test.ts new file mode 100644 index 000000000000..de76b0688ccd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePageForSize.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { act, renderHook } from '@testing-library/react'; +import { usePageForSize } from './usePageForSize'; + +describe('usePageForSize', () => { + it('keeps the page while the size stays the same', () => { + const { result, rerender } = renderHook( + ({ size }: { size: number }) => usePageForSize(size), + { initialProps: { size: 15 } } + ); + + act(() => result.current[1](3)); + rerender({ size: 15 }); + + expect(result.current[0]).toBe(3); + }); + + it('reads as page 1 in the same render the size changes', () => { + const { result, rerender } = renderHook( + ({ size }: { size: number }) => usePageForSize(size), + { initialProps: { size: 15 } } + ); + + act(() => result.current[1](3)); + rerender({ size: 50 }); + + expect(result.current[0]).toBe(1); + + act(() => result.current[1](2)); + + expect(result.current[0]).toBe(2); + }); + + it('keeps the setter from an earlier render working after the size changes', () => { + const { result, rerender } = renderHook( + ({ size }: { size: number }) => usePageForSize(size), + { initialProps: { size: 15 } } + ); + const [, setPageFromFirstRender] = result.current; + + rerender({ size: 12 }); + act(() => setPageFromFirstRender(2)); + + expect(result.current[0]).toBe(2); + expect(result.current[1]).toBe(setPageFromFirstRender); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePageForSize.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePageForSize.ts new file mode 100644 index 000000000000..122094c69ebc --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePageForSize.ts @@ -0,0 +1,36 @@ +/* + * 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 { useCallback, useRef, useState } from 'react'; +import { INITIAL_PAGING_VALUE } from '../../constants/constants'; + +export const usePageForSize = ( + pageSize: number +): [number, (page: number) => void] => { + const [pageAtSize, setPageAtSize] = useState({ + page: INITIAL_PAGING_VALUE, + size: pageSize, + }); + + const sizeRef = useRef(pageSize); + sizeRef.current = pageSize; + + const setPage = useCallback( + (page: number) => setPageAtSize({ page, size: sizeRef.current }), + [] + ); + + return [ + pageAtSize.size === pageSize ? pageAtSize.page : INITIAL_PAGING_VALUE, + setPage, + ]; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePaging.integration.test.tsx b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePaging.integration.test.tsx index c5a80cde8e54..6274f6e9fcfc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePaging.integration.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePaging.integration.test.tsx @@ -12,7 +12,8 @@ */ import { act, renderHook } from '@testing-library/react'; import { ReactNode } from 'react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { RouteVisibilityProvider } from '../../context/RouteVisibilityProvider/RouteVisibilityProvider'; import { usePersistentStorage } from '../currentUserStore/useCurrentUserStore'; import { usePaging } from './usePaging'; @@ -58,18 +59,33 @@ describe('usePaging (integration)', () => { expect(result.current.handlePageSizeChange).toBe(firstHandlePageSizeChange); }); - it('keeps a size off the app-wide scale out of the shared preference', () => { - const { result } = renderHook(() => usePaging(12, GRID_SIZES), { + it('saves a grid size to the grid preference, leaving the list one alone', () => { + const { result } = renderHook(() => usePaging(undefined, GRID_SIZES), { wrapper: wrapperAt('/connections'), }); act(() => result.current.handlePageSizeChange(24)); - // Persisting 24 would hand it to every page that reads the preference rather than declaring - // options — landing in a picker with no entry for it, and discarding the size chosen there. - expect( - usePersistentStorage.getState().preferences['paging-user'] - ).toBeUndefined(); + // Saving 24 as the list size would hand it to list pages, whose pickers have no 24. + const saved = usePersistentStorage.getState().preferences['paging-user']; + + expect(saved.globalGridPageSize).toBe(24); + expect(saved.globalPageSize).toBe(15); + }); + + it('remembers the size last picked on each scale', () => { + usePersistentStorage.getState().setUserPreference('paging-user', { + globalGridPageSize: 24, + globalPageSize: 25, + }); + + const grid = renderHook(() => usePaging(undefined, GRID_SIZES), { + wrapper, + }); + const list = renderHook(() => usePaging(), { wrapper }); + + expect(grid.result.current.pageSize).toBe(24); + expect(list.result.current.pageSize).toBe(25); }); it('starts from the size the caller asked for, whatever the preference holds', () => { @@ -77,11 +93,11 @@ describe('usePaging (integration)', () => { .getState() .setUserPreference('paging-user', { globalPageSize: 50 }); - const { result } = renderHook(() => usePaging(10), { wrapper }); + const { result } = renderHook(() => usePaging(25), { wrapper }); // A caller that names a default never consults globalPageSize, so what this page persists to // it can only ever reach other pages — never come back to this one. - expect(result.current.pageSize).toBe(10); + expect(result.current.pageSize).toBe(25); }); it('still records a size the app-wide scale offers', () => { @@ -125,11 +141,93 @@ describe('usePaging (integration)', () => { expect(result.current.pageSize).toBe(12); }); - it('takes the URL page size as-is when the caller declares no options', () => { - const { result } = renderHook(() => usePaging(12), { - wrapper: wrapperAt('/connections?pageSize=15'), + it("never hands out the old scale's size in the render where the options change", () => { + const seen: number[] = []; + const { rerender } = renderHook( + ({ options }: { options: number[] }) => { + const { pageSize } = usePaging(undefined, options); + seen.push(pageSize); + + return pageSize; + }, + { + initialProps: { options: [15, 25, 50] }, + wrapper: wrapperAt('/settings/services?pageSize=25'), + } + ); + seen.length = 0; + + rerender({ options: GRID_SIZES }); + + // A caller fetches with whatever it gets on each render, so a single 25 here is a request for + // 25 cards under a picker that says 12. + expect(seen.length).toBeGreaterThan(0); + expect(seen.every((size) => size === 12)).toBe(true); + }); + + it('keeps a list-scale URL page size when the caller declares no options', () => { + const { result } = renderHook(() => usePaging(), { + wrapper: wrapperAt('/settings/services?pageSize=25'), + }); + + expect(result.current.pageSize).toBe(25); + }); + + it('puts a caller that declares no options on the list scale, not a grid size in the URL', () => { + // The connections grid writes 48 to the shared `pageSize` param; a list page offering + // 15/25/50 must not adopt it. + const { result } = renderHook(() => usePaging(), { + wrapper: wrapperAt('/settings/services?pageSize=48'), }); expect(result.current.pageSize).toBe(15); }); + + it('ignores an off-scale globalPageSize an older build stored', () => { + usePersistentStorage + .getState() + .setUserPreference('paging-user', { globalPageSize: 48 }); + + const { result } = renderHook(() => usePaging(), { wrapper }); + + expect(result.current.pageSize).toBe(15); + }); + + describe('a page size from an old link', () => { + const renderAt = (entry: string, isVisible = true) => + renderHook(() => ({ paging: usePaging(), location: useLocation() }), { + wrapper: ({ children }: { children: ReactNode }) => ( + + + {children} + + + ), + }); + + it('opens on page 1 without its cursor and drops its paging from the URL', () => { + const { result } = renderAt( + '/schema?currentPage=3&pageSize=10&cursorType=after&cursorValue=abc' + ); + + expect(result.current.paging.currentPage).toBe(1); + expect(result.current.paging.pageSize).toBe(15); + expect(result.current.paging.pagingCursor.cursorValue).toBeUndefined(); + expect(result.current.location.search).toBe(''); + }); + + it('keeps the page of a link whose size is on the scale', () => { + const { result } = renderAt('/schema?currentPage=3&pageSize=25'); + + expect(result.current.paging.currentPage).toBe(3); + expect(result.current.paging.pageSize).toBe(25); + expect(result.current.location.search).toBe('?currentPage=3&pageSize=25'); + }); + + it('leaves the URL alone while its route is hidden', () => { + const { result } = renderAt('/schema?currentPage=3&pageSize=10', false); + + expect(result.current.location.search).toBe('?currentPage=3&pageSize=10'); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePaging.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePaging.test.ts index b10b297fd5b8..d692d5ded3f6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePaging.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePaging.test.ts @@ -61,6 +61,23 @@ describe('usePaging', () => { expect(result.current.showPagination).toBe(true); }); + it('hides pagination at a non-default page size only while there are no records', () => { + mockUrlParams.pageSize = '25'; + const { result } = renderHook(() => usePaging()); + + act(() => { + result.current.handlePagingChange({ total: 0 }); + }); + + expect(result.current.showPagination).toBe(false); + + act(() => { + result.current.handlePagingChange({ total: 3 }); + }); + + expect(result.current.showPagination).toBe(true); + }); + it('syncs pagination state when url paging params are updated externally', () => { const { result, rerender } = renderHook(() => usePaging()); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePaging.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePaging.ts index 178309677443..5eec74bf37df 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePaging.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/usePaging.ts @@ -20,15 +20,14 @@ import { } from 'react'; import { INITIAL_PAGING_VALUE, - PAGE_SIZE_BASE, - PAGE_SIZE_LARGE, - PAGE_SIZE_MEDIUM, + LIST_PAGE_SIZE_OPTIONS, pagingObject, } from '../../constants/constants'; +import { useIsRouteVisible } from '../../context/RouteVisibilityProvider/RouteVisibilityProvider'; import { CursorType } from '../../enums/pagination.enum'; import { Paging } from '../../generated/type/paging'; -import { useCurrentUserPreferences } from '../currentUserStore/useCurrentUserStore'; import { useTableFilters } from '../useTableFilters'; +import { useSavedPageSize } from './useSavedPageSize'; type FilterState = Record< string, @@ -63,39 +62,25 @@ export interface UsePagingInterface { } /** - * The sizes every page on the app-wide scale offers, and therefore the only ones `globalPageSize` - * may hold: it is read by pages that declare no options of their own, so a size from some page's - * bespoke scale would land in a picker with no matching entry. - */ -const GLOBAL_PAGE_SIZES = new Set([ - PAGE_SIZE_BASE, - PAGE_SIZE_MEDIUM, - PAGE_SIZE_LARGE, -]); - -/** - * @param defaultPageSize where to start when the URL carries no size; falls back to the app-wide - * `globalPageSize` preference. - * @param pageSizeOptions the sizes this page's picker offers. Pass them when the page is not on - * the app-wide scale: a size from the shared URL param or preference is reconciled to this set. + * @param defaultPageSize where to start when the URL carries no size; falls back to the size the + * user last picked on this page's scale (`useSavedPageSize`). + * @param pageSizeOptions the sizes this page's picker offers; defaults to the app-wide list scale. + * A size from the shared URL param or preference is reconciled to this set, so a grid's 48 left + * in the URL, or an off-scale size an older build stored in the preference, cannot reach a + * 15/25/50 picker. Pass the grid scale on grid pages. * Pass a stable reference (a module constant) — a fresh array each render re-runs the sync * effect below, which is wasted work rather than a bug, since its updates bail on equal values. */ export const usePaging = ( defaultPageSize?: number, - pageSizeOptions?: number[] + pageSizeOptions: number[] = LIST_PAGE_SIZE_OPTIONS ): UsePagingInterface => { - const { - preferences: { globalPageSize }, - setPreference, - } = useCurrentUserPreferences(); - - // A caller that declares no options accepts any size, so an absent list allows everything. - const fallbackPageSize = defaultPageSize ?? globalPageSize; - const processedPageSize = - pageSizeOptions && !pageSizeOptions.includes(fallbackPageSize) - ? pageSizeOptions[0] - : fallbackPageSize; + const { savedPageSize, savePageSize } = useSavedPageSize(pageSizeOptions); + + const fallbackPageSize = defaultPageSize ?? savedPageSize; + const processedPageSize = !pageSizeOptions.includes(fallbackPageSize) + ? pageSizeOptions[0] + : fallbackPageSize; const { filters: urlParams, setFilters: updateUrlParams } = useTableFilters({ cursorType: undefined, @@ -108,62 +93,62 @@ export const usePaging = ( // every visited route mounted against it) plus one app-wide preference — so either can hand a // page a size its picker has no option for, which renders as an unselectable placeholder. const urlPageSize = Number(urlParams.pageSize) || processedPageSize; - const resolvedPageSize = - pageSizeOptions && !pageSizeOptions.includes(urlPageSize) - ? processedPageSize - : urlPageSize; + const isOffScaleUrlSize = !pageSizeOptions.includes(urlPageSize); + const resolvedPageSize = isOffScaleUrlSize ? processedPageSize : urlPageSize; + const urlCurrentPage = isOffScaleUrlSize + ? INITIAL_PAGING_VALUE + : Number(urlParams.currentPage) || INITIAL_PAGING_VALUE; + const isRouteVisible = useIsRouteVisible(); - const initialCurrentPage = - Number(urlParams.currentPage) || INITIAL_PAGING_VALUE; + useEffect(() => { + if (isRouteVisible && isOffScaleUrlSize) { + updateUrlParams({ + currentPage: null, + cursorType: null, + cursorValue: null, + pageSize: null, + }); + } + }, [isRouteVisible, isOffScaleUrlSize, updateUrlParams]); const [paging, setPaging] = useState(pagingObject); - const [currentPage, setCurrentPage] = useState(initialCurrentPage); - const [pageSize, setPageSize] = useState(resolvedPageSize); + const [currentPage, setCurrentPage] = useState(urlCurrentPage); + const [storedPageSize, setStoredPageSize] = + useState(resolvedPageSize); + const pageSize = pageSizeOptions.includes(storedPageSize) + ? storedPageSize + : resolvedPageSize; // Keep pagination in sync when filters or other controls update paging params directly in the URL. useEffect(() => { - const nextCurrentPage = - Number(urlParams.currentPage) || INITIAL_PAGING_VALUE; - setCurrentPage((currentPage) => - currentPage === nextCurrentPage ? currentPage : nextCurrentPage + currentPage === urlCurrentPage ? currentPage : urlCurrentPage ); - setPageSize((pageSize) => + setStoredPageSize((pageSize) => pageSize === resolvedPageSize ? pageSize : resolvedPageSize ); - }, [resolvedPageSize, urlParams.currentPage]); + }, [resolvedPageSize, urlCurrentPage]); const pagingCursorUrlParams: PagingUrlParams = useMemo( () => ({ - cursorType: urlParams.cursorType, - cursorValue: urlParams.cursorValue, - currentPage: urlParams.currentPage, + cursorType: isOffScaleUrlSize ? undefined : urlParams.cursorType, + cursorValue: isOffScaleUrlSize ? undefined : urlParams.cursorValue, + currentPage: String(urlCurrentPage), pageSize: resolvedPageSize, }), [ + isOffScaleUrlSize, urlParams.cursorType, urlParams.cursorValue, - urlParams.currentPage, + urlCurrentPage, resolvedPageSize, ] ); - // A page on its own scale keeps its size to itself. Persisting a 24 here would hand it to every - // page that reads the preference instead of declaring options, where it has no matching entry — - // and would overwrite whatever size the user had chosen on those pages. - const persistGlobalPageSize = useCallback( - (size: number) => { - if (GLOBAL_PAGE_SIZES.has(size)) { - setPreference({ globalPageSize: size }); - } - }, - [setPreference] - ); - const handlePageSize = useCallback( (page: number) => { - setPageSize(page); - persistGlobalPageSize(page); + setStoredPageSize(page); + savePageSize(page); setCurrentPage(INITIAL_PAGING_VALUE); // Update URL params, removing cursor data since they're invalid with new page size @@ -174,18 +159,27 @@ export const usePaging = ( cursorValue: null, }); }, - [setPageSize, persistGlobalPageSize, setCurrentPage, updateUrlParams] + [setStoredPageSize, savePageSize, setCurrentPage, updateUrlParams] ); const paginationVisible = useMemo(() => { const hasCursorPagination = Boolean(paging.before || paging.after); + const isNonDefaultSize = + pageSize !== (defaultPageSize ?? pageSizeOptions[0]); return ( hasCursorPagination || paging.total > pageSize || - pageSize !== (defaultPageSize ?? PAGE_SIZE_BASE) + (paging.total > 0 && isNonDefaultSize) ); - }, [defaultPageSize, paging.after, paging.before, paging.total, pageSize]); + }, [ + defaultPageSize, + pageSizeOptions, + paging.after, + paging.before, + paging.total, + pageSize, + ]); const handlePageChange = useCallback( ( @@ -208,12 +202,12 @@ export const usePaging = ( if (pageSize) { urlUpdate.pageSize = pageSize; - persistGlobalPageSize(pageSize); + savePageSize(pageSize); } updateUrlParams(urlUpdate as FilterState); }, - [currentPage, setCurrentPage, persistGlobalPageSize, updateUrlParams] + [currentPage, setCurrentPage, savePageSize, updateUrlParams] ); return { diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/paging/useSavedPageSize.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/useSavedPageSize.test.ts new file mode 100644 index 000000000000..66924c4c853e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/useSavedPageSize.test.ts @@ -0,0 +1,92 @@ +/* + * 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 { act, renderHook } from '@testing-library/react'; +import { + GRID_PAGE_SIZE_OPTIONS, + LIST_PAGE_SIZE_OPTIONS, +} from '../../constants/constants'; +import { usePersistentStorage } from '../currentUserStore/useCurrentUserStore'; +import { useSavedPageSize } from './useSavedPageSize'; + +// Exercises the real preferences store; only the current user is stubbed. +jest.mock('../useApplicationStore', () => ({ + useApplicationStore: jest.fn((selector) => + selector({ currentUser: { name: 'saved-size-user' } }) + ), +})); + +const savePreference = (preference: { + globalPageSize?: number; + globalGridPageSize?: number; +}) => + usePersistentStorage + .getState() + .setUserPreference('saved-size-user', preference); + +describe('useSavedPageSize', () => { + beforeEach(() => + usePersistentStorage.getState().clearUserPreference('saved-size-user') + ); + + it('starts each scale at its first step when nothing is saved', () => { + const list = renderHook(() => useSavedPageSize()); + const grid = renderHook(() => useSavedPageSize(GRID_PAGE_SIZE_OPTIONS)); + + expect(list.result.current.savedPageSize).toBe(15); + expect(grid.result.current.savedPageSize).toBe(12); + }); + + it('reads each scale from its own preference', () => { + savePreference({ globalPageSize: 50, globalGridPageSize: 24 }); + + const list = renderHook(() => useSavedPageSize(LIST_PAGE_SIZE_OPTIONS)); + const grid = renderHook(() => useSavedPageSize(GRID_PAGE_SIZE_OPTIONS)); + + expect(list.result.current.savedPageSize).toBe(50); + expect(grid.result.current.savedPageSize).toBe(24); + }); + + it('falls back to the first step when an older build saved an off-scale size', () => { + savePreference({ globalPageSize: 10 }); + + const { result } = renderHook(() => useSavedPageSize()); + + expect(result.current.savedPageSize).toBe(15); + }); + + it('saves a pick to the preference of the scale it belongs to', () => { + const { result } = renderHook(() => useSavedPageSize()); + + act(() => result.current.savePageSize(24)); + act(() => result.current.savePageSize(50)); + + const saved = + usePersistentStorage.getState().preferences['saved-size-user']; + + expect(saved.globalGridPageSize).toBe(24); + expect(saved.globalPageSize).toBe(50); + }); + + it('lets a size from the URL or hash win only when the picker offers it', () => { + savePreference({ globalPageSize: 25 }); + + const { result } = renderHook(() => useSavedPageSize()); + + // A shared link's size is honoured... + expect(result.current.resolvePageSize(50)).toBe(50); + // ...but an old link's 10, a grid's 24, or no size at all use the saved size. + expect(result.current.resolvePageSize(10)).toBe(25); + expect(result.current.resolvePageSize(24)).toBe(25); + expect(result.current.resolvePageSize(undefined)).toBe(25); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/paging/useSavedPageSize.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/useSavedPageSize.ts new file mode 100644 index 000000000000..812e5f6daef9 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/paging/useSavedPageSize.ts @@ -0,0 +1,57 @@ +/* + * 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 { useCallback } from 'react'; +import { + GRID_PAGE_SIZE_OPTIONS, + LIST_PAGE_SIZE_OPTIONS, +} from '../../constants/constants'; +import { useCurrentUserPreferences } from '../currentUserStore/useCurrentUserStore'; + +// ADR:2026-10-13-ui-page-sizes-lists-15-25-50-grids-12-24-48 +export const useSavedPageSize = ( + pageSizeOptions: number[] = LIST_PAGE_SIZE_OPTIONS +) => { + const { + preferences: { globalPageSize, globalGridPageSize }, + setPreference, + } = useCurrentUserPreferences(); + + const saved = GRID_PAGE_SIZE_OPTIONS.includes(pageSizeOptions[0]) + ? globalGridPageSize + : globalPageSize; + const savedPageSize = pageSizeOptions.includes(saved) + ? saved + : pageSizeOptions[0]; + + const savePageSize = useCallback( + (size: number) => { + if (GRID_PAGE_SIZE_OPTIONS.includes(size)) { + setPreference({ globalGridPageSize: size }); + } else if (LIST_PAGE_SIZE_OPTIONS.includes(size)) { + setPreference({ globalPageSize: size }); + } + }, + [setPreference] + ); + + const resolvePageSize = useCallback( + (requested?: number) => + requested && pageSizeOptions.includes(requested) + ? requested + : savedPageSize, + [pageSizeOptions, savedPageSize] + ); + + return { savedPageSize, savePageSize, resolvePageSize }; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCursorPagedList.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCursorPagedList.test.ts index 97068b3cf967..5acc2baba85d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCursorPagedList.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCursorPagedList.test.ts @@ -38,6 +38,18 @@ describe('useCursorPagedList', () => { expect(result.current.showPagination).toBe(true); }); + it('starts at the first step of the scale its caller passes', async () => { + const { result } = renderHook(() => + useCursorPagedList(fetchPage, [12, 24, 48]) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(fetchPage).toHaveBeenCalledWith({ limit: 12 }); + expect(result.current.pageSize).toBe(12); + expect(result.current.totalPages).toBe(4); + }); + it('follows the after cursor forward and the before cursor back', async () => { const { result } = renderHook(() => useCursorPagedList(fetchPage)); await waitFor(() => expect(result.current.isLoading).toBe(false)); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCursorPagedList.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCursorPagedList.ts index 4b64bdc9227a..88f4adc5cd36 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCursorPagedList.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCursorPagedList.ts @@ -13,9 +13,10 @@ import { AxiosError } from 'axios'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { PAGE_SIZE_BASE } from '../constants/constants'; +import { LIST_PAGE_SIZE_OPTIONS } from '../constants/constants'; import { Paging } from '../generated/type/paging'; import { showErrorToast } from '../utils/ToastUtils'; +import { useSavedPageSize } from './paging/useSavedPageSize'; export interface CursorPageParams { after?: string; @@ -33,12 +34,14 @@ export const useCursorPagedList = ( fetchPage: (params: CursorPageParams) => Promise<{ data: T[]; paging: Paging; - }> + }>, + pageSizeOptions: number[] = LIST_PAGE_SIZE_OPTIONS ) => { + const { savedPageSize, savePageSize } = useSavedPageSize(pageSizeOptions); const [items, setItems] = useState([]); const [paging, setPaging] = useState({ total: 0 }); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(PAGE_SIZE_BASE); + const [pageSize, setPageSize] = useState(savedPageSize); const [isLoading, setIsLoading] = useState(true); // Only the latest request may write state: a slow earlier response (e.g. the // list before a filter flipped) must not overwrite the newer one. @@ -72,6 +75,14 @@ export const useCursorPagedList = ( void load(); }, [load]); + const onPageSizeChange = useCallback( + (size: number) => { + setPageSize(size); + savePageSize(size); + }, + [savePageSize] + ); + const onPageChange = (nextPage: number) => { const cursorType = nextPage > page ? 'after' : 'before'; setPage(nextPage); @@ -85,8 +96,9 @@ export const useCursorPagedList = ( pageSize, totalPages: Math.ceil((paging.total ?? 0) / pageSize), showPagination: - Boolean(paging.after || paging.before) || paging.total > PAGE_SIZE_BASE, + Boolean(paging.after || paging.before) || + paging.total > pageSizeOptions[0], onPageChange, - onPageSizeChange: setPageSize, + onPageSizeChange, }; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useSettingsHash.test.tsx b/openmetadata-ui/src/main/resources/ui/src/hooks/useSettingsHash.test.tsx index 630e841ea7d4..43eab5954d77 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useSettingsHash.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useSettingsHash.test.tsx @@ -13,7 +13,8 @@ import { act, renderHook } from '@testing-library/react'; import { ReactNode } from 'react'; import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'; -import { useSettingsHash } from './useSettingsHash'; +import { LIST_PAGE_SIZE_OPTIONS } from '../constants/constants'; +import { useHashPagingParams, useSettingsHash } from './useSettingsHash'; // Real MemoryRouter (no react-router mock): the hook drives navigation through // react-router and reads the live URL hash from `globalThis.location`, so tests @@ -206,3 +207,55 @@ describe('useSettingsHash', () => { act(() => second.result.current.clearHash()); }); }); + +describe('useHashPagingParams', () => { + beforeEach(() => { + globalThis.location.hash = ''; + const { result, unmount } = renderHook(() => useSettingsHash(), { + wrapper, + }); + act(() => result.current.clearHash()); + unmount(); + }); + + const renderPaging = (options?: number[]) => + renderHook( + () => ({ paging: useHashPagingParams(options), hash: useSettingsHash() }), + { wrapper } + ); + + it('keeps the page, cursor and size of a link on the picker scale', () => { + globalThis.location.hash = + '#notification/events?page=3&pageSize=25&cursorType=after&cursor=abc'; + + const { result } = renderPaging(LIST_PAGE_SIZE_OPTIONS); + + expect(result.current.paging.page).toBe(3); + expect(result.current.paging.pageSize).toBe(25); + expect(result.current.paging.cursor).toBe('abc'); + }); + + it('opens an off-scale link on page 1 and rewrites the hash to match', () => { + // An older link: its page 3 was counted at 10 per page, so at the saved 15 it would request an + // offset past the data and show an empty page with no pager to page back with. + globalThis.location.hash = + '#notification/events?page=3&pageSize=10&cursorType=after&cursor=abc'; + + const { result } = renderPaging(LIST_PAGE_SIZE_OPTIONS); + + expect(result.current.paging.page).toBe(1); + expect(result.current.paging.pageSize).toBeUndefined(); + expect(result.current.paging.cursor).toBeUndefined(); + expect(result.current.paging.cursorType).toBeUndefined(); + expect(result.current.hash.state.params).toEqual({}); + }); + + it('accepts any size when the caller declares no options', () => { + globalThis.location.hash = '#notification/events?page=2&pageSize=10'; + + const { result } = renderPaging(); + + expect(result.current.paging.page).toBe(2); + expect(result.current.paging.pageSize).toBe(10); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useSettingsHash.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useSettingsHash.ts index a93cefaf9819..2454c3dd58df 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useSettingsHash.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useSettingsHash.ts @@ -276,17 +276,57 @@ export const useSettingsHash = () => { return { state, setHash, clearHash, updateParams, goTo }; }; +interface HashPaging { + page: number; + cursorType?: string; + cursor?: string; + pageSize?: number; + isPageSizeRejected: boolean; +} + +function readHashPaging( + params: Record, + pageSizeOptions?: number[] +): HashPaging { + const requestedPageSize = Number(params.pageSize) || undefined; + const isPageSizeRejected = Boolean( + requestedPageSize && + pageSizeOptions && + !pageSizeOptions.includes(requestedPageSize) + ); + + if (isPageSizeRejected) { + return { page: 1, isPageSizeRejected }; + } + + return { + page: Number(params.page) || 1, + cursorType: params.cursorType || undefined, + cursor: params.cursor || undefined, + pageSize: requestedPageSize, + isPageSizeRejected, + }; +} + /** * Hook to read/write pagination params from the hash. * Drop-in replacement for usePaging when inside the settings modal. */ -export const useHashPagingParams = () => { +export const useHashPagingParams = (pageSizeOptions?: number[]) => { const { state, updateParams } = useSettingsHash(); + const { page, cursorType, cursor, pageSize, isPageSizeRejected } = + readHashPaging(state.params, pageSizeOptions); - const page = Number(state.params.page) || 1; - const cursorType = state.params.cursorType || undefined; - const cursor = state.params.cursor || undefined; - const pageSize = Number(state.params.pageSize) || undefined; + useEffect(() => { + if (isPageSizeRejected) { + updateParams({ + page: undefined, + cursorType: undefined, + cursor: undefined, + pageSize: undefined, + }); + } + }, [isPageSizeRejected, updateParams]); const setPage = useCallback( ( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/Application/ApplicationPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/Application/ApplicationPage.tsx index 0e7dfecb5351..539590752eb3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/Application/ApplicationPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/Application/ApplicationPage.tsx @@ -32,7 +32,7 @@ import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/Ti import PageHeader from '../../components/PageHeader/PageHeader.component'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import ApplicationCard from '../../components/Settings/Applications/ApplicationCard/ApplicationCard.component'; -import { ROUTES } from '../../constants/constants'; +import { GRID_PAGE_SIZE_OPTIONS, ROUTES } from '../../constants/constants'; import { GlobalSettingsMenuCategory } from '../../constants/GlobalSettings.constants'; import { LEARNING_PAGE_IDS } from '../../constants/Learning.constants'; import { PAGE_HEADERS } from '../../constants/PageHeaders.constant'; @@ -62,7 +62,7 @@ const ApplicationPage = () => { handlePageSizeChange, showPagination, pagingCursor, - } = usePaging(); + } = usePaging(undefined, GRID_PAGE_SIZE_OPTIONS); const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(true); const [applicationData, setApplicationData] = useState(); @@ -262,6 +262,7 @@ const ApplicationPage = () => { currentPage={currentPage} isLoading={isLoading} pageSize={pageSize} + pageSizeOptions={GRID_PAGE_SIZE_OPTIONS} paging={paging} pagingHandler={handleApplicationPageChange} onShowSizeChange={handlePageSizeChange} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AuditLogsPage/AuditLogsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AuditLogsPage/AuditLogsPage.test.tsx index 78e853ec5cb5..46f5ee21d1d2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AuditLogsPage/AuditLogsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AuditLogsPage/AuditLogsPage.test.tsx @@ -22,6 +22,9 @@ import { import { AxiosError } from 'axios'; import { MemoryRouter } from 'react-router-dom'; import { SOCKET_EVENTS } from '../../constants/constants'; +import { User } from '../../generated/entity/teams/user'; +import { usePersistentStorage } from '../../hooks/currentUserStore/useCurrentUserStore'; +import { useApplicationStore } from '../../hooks/useApplicationStore'; import { exportAuditLogs, getAuditLogExportJob, @@ -338,6 +341,32 @@ describe('AuditLogsPage', () => { await waitFor(() => expect(mockGetAuditLogs).toHaveBeenCalledTimes(1)); }); + it('starts from the page size the user last picked', async () => { + // Real stores: a signed-in user whose saved list size is 50, not the page's old fixed 25. + const previousUser = useApplicationStore.getState().currentUser; + useApplicationStore.setState({ + currentUser: { ...previousUser, name: 'audit-user' } as User, + }); + usePersistentStorage + .getState() + .setUserPreference('audit-user', { globalPageSize: 50 }); + + render( + + + + ); + + await waitFor(() => + expect(mockGetAuditLogs).toHaveBeenCalledWith( + expect.objectContaining({ limit: 50 }) + ) + ); + + usePersistentStorage.getState().clearUserPreference('audit-user'); + useApplicationStore.setState({ currentUser: previousUser }); + }); + it('displays loading state initially', async () => { mockGetAuditLogs.mockImplementation( () => diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AuditLogsPage/AuditLogsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AuditLogsPage/AuditLogsPage.tsx index f126dea84999..e538458715a3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AuditLogsPage/AuditLogsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AuditLogsPage/AuditLogsPage.tsx @@ -50,6 +50,7 @@ import { PAGE_HEADERS } from '../../constants/PageHeaders.constant'; import { useWebSocketConnector } from '../../context/WebSocketProvider/WebSocketProvider'; import { CursorType } from '../../enums/pagination.enum'; import { Paging } from '../../generated/type/paging'; +import { useSavedPageSize } from '../../hooks/paging/useSavedPageSize'; import { useStagedPickerValue } from '../../hooks/useStagedPickerValue'; import { exportAuditLogs, @@ -111,7 +112,8 @@ const AuditLogsPage = () => { {} ); const filterParamsRef = useRef>({}); - const [pageSize, setPageSize] = useState(PAGE_SIZE_MEDIUM); + const { savedPageSize, savePageSize } = useSavedPageSize(); + const [pageSize, setPageSize] = useState(savedPageSize); const [isExportModalOpen, setIsExportModalOpen] = useState(false); const [exportDateRange, setExportDateRange] = @@ -171,10 +173,14 @@ const AuditLogsPage = () => { [fetchAuditLogs, paging] ); - const handlePageSizeChange = useCallback((size: number) => { - setPageSize(size); - setCurrentPage(1); - }, []); + const handlePageSizeChange = useCallback( + (size: number) => { + setPageSize(size); + savePageSize(size); + setCurrentPage(1); + }, + [savePageSize] + ); const handleFiltersChange = useCallback( (filters: AuditLogActiveFilter[], params: Partial) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnGrid/hooks/useColumnGridListingData.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnGrid/hooks/useColumnGridListingData.tsx index 689f956608bd..4591a17ad5fb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnGrid/hooks/useColumnGridListingData.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnGrid/hooks/useColumnGridListingData.tsx @@ -23,10 +23,10 @@ import { ListingData, } from '../../../../components/common/atoms/shared/types'; import { ExploreQuickFilterField } from '../../../../components/Explore/ExplorePage.interface'; -import { PAGE_SIZE_MEDIUM } from '../../../../constants/constants'; import { EntityFields } from '../../../../enums/AdvancedSearch.enum'; import { ColumnGridItem } from '../../../../generated/api/data/columnGridResponse'; import { TagLabel } from '../../../../generated/type/tagLabel'; +import { useSavedPageSize } from '../../../../hooks/paging/useSavedPageSize'; import { getColumnGrid } from '../../../../rest/columnAPI'; import { showErrorToast } from '../../../../utils/ToastUtils'; import { ColumnGridFilters, ColumnGridRowData } from '../ColumnGrid.interface'; @@ -38,8 +38,6 @@ import { MAX_REFETCH_CHAIN_PAGES, } from '../constants/ColumnGrid.constants'; -const PAGE_SIZE = PAGE_SIZE_MEDIUM; - interface UseColumnGridListingDataProps { externalFilters?: ColumnGridFilters; transformGridItemsToRows: ( @@ -96,7 +94,8 @@ export const useColumnGridListingData = ( // Local pagination state (cursor-based, not in URL) const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(PAGE_SIZE); + const { savedPageSize, savePageSize } = useSavedPageSize(); + const [pageSize, setPageSize] = useState(savedPageSize); // URL state management (search and filters only, pagination is local) const urlState = useMemo(() => { @@ -412,14 +411,18 @@ export const useColumnGridListingData = ( setCurrentPage(page); }, []); - const handlePageSizeChange = useCallback((size: number) => { - setPageSize(size); - setCurrentPage(1); - cursorsByPageRef.current = new Map(); - itemsByPageRef.current = new Map(); - totalUniqueColumnsRef.current = 0; - totalOccurrencesRef.current = 0; - }, []); + const handlePageSizeChange = useCallback( + (size: number) => { + setPageSize(size); + savePageSize(size); + setCurrentPage(1); + cursorsByPageRef.current = new Map(); + itemsByPageRef.current = new Map(); + totalUniqueColumnsRef.current = 0; + totalOccurrencesRef.current = 0; + }, + [savePageSize] + ); /** * Refetch after bulk update (WebSocket job complete). diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.tsx index bcdbb9f4b245..75456df9edf3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterArchivePage/ContextCenterArchivePage.tsx @@ -22,7 +22,6 @@ import ArchiveView from '../../../components/ContextCenter/ArchiveView/ArchiveVi import { ArchiveItem } from '../../../components/ContextCenter/ArchiveView/ArchiveView.interface'; import ContextCenterHeader from '../../../components/ContextCenter/ContextCenterHeader/ContextCenterHeader.component'; import { useContextCenterPageLayout } from '../../../components/ContextCenter/ContextCenterLayout/useContextCenterPageLayout'; -import { ARCHIVE_PAGE_SIZE } from '../../../constants/ContextCenter.constants'; import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; import { OperationPermission } from '../../../context/PermissionProvider/PermissionProvider.interface'; import { ResourceEntity } from '../../../enums/permissions.enum'; @@ -55,7 +54,7 @@ const ContextCenterArchivePage: FC = () => { const pageLayoutClassNames = useContextCenterPageLayout(); const { currentUser } = useApplicationStore(); const { getResourcePermission } = usePermissionProvider(); - const { paging, pageSize, handlePagingChange } = usePaging(ARCHIVE_PAGE_SIZE); + const { paging, pageSize, handlePagingChange } = usePaging(); const [items, setItems] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isLoadingMore, setIsLoadingMore] = useState(false); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterMemoriesPage/ContextCenterMemoriesPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterMemoriesPage/ContextCenterMemoriesPage.test.tsx index 1a32606d3bf8..6c41fa809e09 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterMemoriesPage/ContextCenterMemoriesPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterMemoriesPage/ContextCenterMemoriesPage.test.tsx @@ -21,6 +21,7 @@ import { import { Link, MemoryRouter, useLocation } from 'react-router-dom'; import { CreateMemoryModalProps } from '../../../components/ContextCenter/CreateMemoryModal/CreateMemoryModal.interface'; import { ContextMemory } from '../../../generated/entity/context/contextMemory'; +import { usePersistentStorage } from '../../../hooks/currentUserStore/useCurrentUserStore'; import { getContextMemoryById, getContextMemoryByName, @@ -41,11 +42,23 @@ jest.mock('../../../context/PermissionProvider/PermissionProvider', () => ({ }), })); -jest.mock('../../../hooks/useApplicationStore', () => ({ - useApplicationStore: jest.fn().mockReturnValue({ +jest.mock('../../../hooks/useApplicationStore', () => { + const state = { currentUser: { id: 'user-1', name: 'test.user', isAdmin: false }, - }), -})); + userProfilePics: {}, + updateUserProfilePics: jest.fn(), + }; + const useApplicationStore = jest.fn( + (selector?: (current: typeof state) => unknown) => + selector ? selector(state) : state + ); + + return { + useApplicationStore: Object.assign(useApplicationStore, { + getState: () => state, + }), + }; +}); jest.mock('../../../rest/contextMemoryAPI', () => ({ getListContextMemories: jest.fn().mockResolvedValue({ @@ -624,3 +637,58 @@ describe('ContextCenterMemoriesPage — needs review', () => { expect(screen.queryByTestId('memory-search')).not.toBeInTheDocument(); }); }); + +describe('ContextCenterMemoriesPage — page size', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetResourcePermission.mockResolvedValue({ EditAll: true }); + usePersistentStorage + .getState() + .setUserPreference('test.user', { globalPageSize: 15 }); + (getListContextMemories as jest.Mock).mockResolvedValue({ + data: [{ id: 'memory-1', title: 'Test Memory' }], + paging: { total: 40 }, + }); + }); + + afterEach(() => { + usePersistentStorage.getState().clearUserPreference('test.user'); + (getListContextMemories as jest.Mock).mockResolvedValue({ + data: [{ id: 'memory-1', title: 'Test Memory' }], + paging: { total: 1 }, + }); + }); + + it('returns to page 1 when the saved page size changes, without requesting the old page', async () => { + renderPage(); + + await screen.findByText('Page 1 of 3'); + fireEvent.click(screen.getAllByRole('button', { name: 'Next' })[0]); + await screen.findByText('Page 2 of 3'); + fireEvent.click(screen.getAllByRole('button', { name: 'Next' })[0]); + + await waitFor(() => { + expect(getListContextMemories).toHaveBeenLastCalledWith( + expect.objectContaining({ limit: 15, offset: 30 }) + ); + }); + + (getListContextMemories as jest.Mock).mockClear(); + + await act(async () => { + usePersistentStorage + .getState() + .setUserPreference('test.user', { globalPageSize: 50 }); + }); + + await waitFor(() => { + expect(getListContextMemories).toHaveBeenLastCalledWith( + expect.objectContaining({ limit: 50, offset: 0 }) + ); + }); + + expect(getListContextMemories).not.toHaveBeenCalledWith( + expect.objectContaining({ offset: 100 }) + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterMemoriesPage/ContextCenterMemoriesPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterMemoriesPage/ContextCenterMemoriesPage.tsx index 3350388c7a14..a2fcd3898b55 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterMemoriesPage/ContextCenterMemoriesPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContextCenterPage/ContextCenterMemoriesPage/ContextCenterMemoriesPage.tsx @@ -57,7 +57,6 @@ import MemoryConflictReviews from '../../../components/discovery/context-center/ import { INBOX_OPEN_TASK_COUNT_QUERY_KEY } from '../../../components/discovery/personal-space/inbox.constants'; import { DEFAULT_MEMORY_LIST_STATUSES, - MEMORIES_PER_PAGE, MEMORY_FIELDS, MEMORY_STATUS_LABEL_KEYS, } from '../../../constants/ContextCenter.constants'; @@ -69,6 +68,8 @@ import { ContextMemoryStatus, } from '../../../generated/entity/context/contextMemory'; import { useMemoryConflictReviews } from '../../../hooks/discovery/context-center/useMemoryConflictReviews'; +import { usePageForSize } from '../../../hooks/paging/usePageForSize'; +import { useSavedPageSize } from '../../../hooks/paging/useSavedPageSize'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { queryClient } from '../../../queryClient'; import { @@ -334,7 +335,8 @@ const ContextCenterMemoriesPage: FC = () => { const [selectedStatuses, setSelectedStatuses] = useState< ContextMemoryStatus[] >(DEFAULT_MEMORY_LIST_STATUSES); - const [currentPage, setCurrentPage] = useState(1); + const { savedPageSize: memoriesPerPage } = useSavedPageSize(); + const [currentPage, setCurrentPage] = usePageForSize(memoriesPerPage); const [selectedAsset, setSelectedAsset] = useState(); const [selectedAuthor, setSelectedAuthor] = useState(); const [authorOptions, setAuthorOptions] = useState([]); @@ -433,8 +435,8 @@ const ContextCenterMemoriesPage: FC = () => { ? currentUser?.id ?? currentUser?.name : selectedAuthor?.id; const response = await getListContextMemories({ - limit: MEMORIES_PER_PAGE, - offset: (currentPage - 1) * MEMORIES_PER_PAGE, + limit: memoriesPerPage, + offset: (currentPage - 1) * memoriesPerPage, fields: MEMORY_FIELDS, q: debouncedSearch.trim() || undefined, assets: selectedAsset?.id, @@ -459,6 +461,7 @@ const ContextCenterMemoriesPage: FC = () => { activeFilter, applyMemoryListResponse, currentPage, + memoriesPerPage, currentUser?.id, currentUser?.name, debouncedSearch, @@ -600,7 +603,7 @@ const ContextCenterMemoriesPage: FC = () => { void fetchMemoryCounts(); }, [fetchMemoryCounts]); - const totalPages = Math.max(1, Math.ceil(totalMemories / MEMORIES_PER_PAGE)); + const totalPages = Math.max(1, Math.ceil(totalMemories / memoriesPerPage)); const { hasActiveFilters, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.test.tsx index 53f5a539ad2b..cbd12d94327d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.test.tsx @@ -16,6 +16,7 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { act } from 'react-test-renderer'; import { mockUserData } from '../../components/Settings/Users/mocks/User.mocks'; +import { getListKPIs } from '../../rest/KpiAPI'; import KPIList from './KPIList'; import { KPI_DATA } from './mocks/KPIList'; @@ -90,6 +91,29 @@ jest.mock('../../context/PermissionProvider/PermissionProvider', () => ({ })); describe('KPI list component', () => { + it('asks for the saved page size and shows the pager when the server has more KPIs', async () => { + (getListKPIs as jest.Mock).mockResolvedValueOnce({ + data: KPI_DATA, + paging: { after: 'next-page', total: 20 }, + }); + + render(, { wrapper: MemoryRouter }); + + // The fetched page never holds more rows than the page size, so only the cursor can say a + // second page exists. + expect(await screen.findByTestId('next')).toBeInTheDocument(); + expect(getListKPIs).toHaveBeenCalledWith( + expect.objectContaining({ limit: 15 }) + ); + }); + + it('hides the pager when every KPI fits on one page', async () => { + render(, { wrapper: MemoryRouter }); + + expect(await screen.findByText('Description KPI')).toBeInTheDocument(); + expect(screen.queryByTestId('next')).not.toBeInTheDocument(); + }); + it('Should render the kpi list', async () => { render(, { wrapper: MemoryRouter }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.tsx index 53693af655ed..16035186fc5c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.tsx @@ -29,11 +29,7 @@ import { PagingHandlerParams } from '../../components/common/NextPrevious/NextPr import { ColumnsType } from '../../components/common/Table/Table.interface'; import Table from '../../components/common/Table/TableV2'; import { EmptyGraphPlaceholder } from '../../components/DataInsight/EmptyGraphPlaceholder'; -import { - INITIAL_PAGING_VALUE, - PAGE_SIZE_MEDIUM, - pagingObject, -} from '../../constants/constants'; +import { INITIAL_PAGING_VALUE, pagingObject } from '../../constants/constants'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum'; import { EntityType, TabSpecificField } from '../../enums/entity.enum'; @@ -41,6 +37,7 @@ import { ResourceEntity } from '../../enums/permissions.enum'; import { Kpi, KpiTargetType } from '../../generated/dataInsight/kpi/kpi'; import { Operation } from '../../generated/entity/policies/policy'; import { Paging } from '../../generated/type/paging'; +import { useSavedPageSize } from '../../hooks/paging/useSavedPageSize'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { getListKPIs } from '../../rest/KpiAPI'; import { formatDateTime } from '../../utils/date-time/DateTimeUtils'; @@ -54,6 +51,7 @@ const KPIList = () => { const navigate = useNavigate(); const { currentUser } = useApplicationStore(); const isAdminUser = currentUser?.isAdmin ?? false; + const { savedPageSize: kpiPageSize } = useSavedPageSize(); const { t } = useTranslation(); const { permissions } = usePermissionProvider(); const viewKPIPermission = useMemo( @@ -67,30 +65,33 @@ const KPIList = () => { const [selectedKpi, setSelectedKpi] = useState(); const [isDeleting, setIsDeleting] = useState(false); - const fetchKpiList = async (param?: Record) => { - try { - setIsLoading(true); - const response = await getListKPIs({ - fields: [ - TabSpecificField.START_DATE, - TabSpecificField.END_DATE, - TabSpecificField.TARGET_VALUE, - TabSpecificField.DATA_INSIGHT_CHART, - TabSpecificField.METRIC_TYPE, - ], - limit: PAGE_SIZE_MEDIUM, - before: param && param.before, - after: param && param.after, - }); - setKpiList(response.data); - setKpiPaging(response.paging); - } catch { - setKpiList([]); - setKpiPaging(pagingObject); - } finally { - setIsLoading(false); - } - }; + const fetchKpiList = useCallback( + async (param?: Record) => { + try { + setIsLoading(true); + const response = await getListKPIs({ + fields: [ + TabSpecificField.START_DATE, + TabSpecificField.END_DATE, + TabSpecificField.TARGET_VALUE, + TabSpecificField.DATA_INSIGHT_CHART, + TabSpecificField.METRIC_TYPE, + ], + limit: kpiPageSize, + before: param && param.before, + after: param && param.after, + }); + setKpiList(response.data); + setKpiPaging(response.paging); + } catch { + setKpiList([]); + setKpiPaging(pagingObject); + } finally { + setIsLoading(false); + } + }, + [kpiPageSize] + ); const columns = useMemo(() => { const col: ColumnsType = [ @@ -209,8 +210,9 @@ const KPIList = () => { }; useEffect(() => { + setKpiPage(INITIAL_PAGING_VALUE); fetchKpiList(); - }, []); + }, [fetchKpiList]); const handleAfterDeleteAction = useCallback(() => { fetchKpiList(); @@ -254,8 +256,8 @@ const KPIList = () => { customPaginationProps={{ currentPage: kpiPage, isLoading, - showPagination: kpiList.length > PAGE_SIZE_MEDIUM, - pageSize: PAGE_SIZE_MEDIUM, + showPagination: Boolean(kpiPaging.after || kpiPaging.before), + pageSize: kpiPageSize, paging: kpiPaging, pagingHandler: kpiPagingHandler, }} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/GlossaryTermRelationSettings/GlossaryTermRelationSettings.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/GlossaryTermRelationSettings/GlossaryTermRelationSettings.tsx index c7ffbf4db6ca..bc9964e8be95 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/GlossaryTermRelationSettings/GlossaryTermRelationSettings.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/GlossaryTermRelationSettings/GlossaryTermRelationSettings.tsx @@ -36,6 +36,7 @@ import { import { GlobalSettingsMenuCategory } from '../../constants/GlobalSettings.constants'; import { RelationshipType } from '../../generated/entity/data/relationshipType'; import { useAuth } from '../../hooks/authHooks'; +import { useSavedPageSize } from '../../hooks/paging/useSavedPageSize'; import { getRelationTypeUsageCounts } from '../../rest/glossaryAPI'; import { createRelationshipType, @@ -132,7 +133,8 @@ const GlossaryTermRelationSettingsPage = () => { ); const [formErrors, setFormErrors] = useState>({}); const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(PAGE_SIZE_BASE); + const { savedPageSize, savePageSize } = useSavedPageSize(); + const [pageSize, setPageSize] = useState(savedPageSize); const breadcrumbs: TitleBreadcrumbProps['titleLinks'] = useMemo( () => @@ -348,6 +350,7 @@ const GlossaryTermRelationSettingsPage = () => { onPageChange={setCurrentPage} onPageSizeChange={(nextPageSize) => { setPageSize(nextPageSize); + savePageSize(nextPageSize); setCurrentPage(1); }} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/LearningResourcesPage/LearningResourcesPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/LearningResourcesPage/LearningResourcesPage.tsx index 0c007930c292..bd70897e84b5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/LearningResourcesPage/LearningResourcesPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/LearningResourcesPage/LearningResourcesPage.tsx @@ -41,11 +41,6 @@ import { LearningResourceCard } from '../../components/Learning/LearningResource import { ResourcePlayerModal } from '../../components/Learning/ResourcePlayer/ResourcePlayerModal.component'; import { ResourceTypeIcon } from '../../components/Learning/ResourceTypeIcon/ResourceTypeIcon'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; -import { - PAGE_SIZE_BASE, - PAGE_SIZE_LARGE, - PAGE_SIZE_MEDIUM, -} from '../../constants/constants'; import { GlobalSettingsMenuCategory } from '../../constants/GlobalSettings.constants'; import { MAX_VISIBLE_CONTEXTS, @@ -54,8 +49,11 @@ import { } from '../../constants/Learning.constants'; import { VIEW_MODE_PAGE } from '../../constants/platform/personaAppLayout.constants'; import { PageViewMode } from '../../generated/type/personaPreferences'; +import { usePageForSize } from '../../hooks/paging/usePageForSize'; +import { useSavedPageSize } from '../../hooks/paging/useSavedPageSize'; import { usePersonaViewMode } from '../../hooks/platform/usePersonaViewMode'; import { LearningResource } from '../../rest/learningResourceAPI'; +import { getViewPageSizeOptions } from '../../utils/PaginationUtils'; import { getSettingPath } from '../../utils/RouterUtils'; import { useLearningResourceActions } from './hooks/useLearningResourceActions'; import { @@ -84,8 +82,14 @@ export const LearningResourcesPage: React.FC = () => { const [filterState, setFilterState] = useState( {} ); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(PAGE_SIZE_BASE); + + const personaView = usePersonaViewMode(VIEW_MODE_PAGE.LearningResources); + const [selectedView, setView] = useState(); + const view = selectedView ?? personaView; + const pageSizeOptions = getViewPageSizeOptions(view); + const { savedPageSize: pageSize, savePageSize } = + useSavedPageSize(pageSizeOptions); + const [currentPage, setCurrentPage] = usePageForSize(pageSize); const { resources, paging, isLoading, refetch } = useLearningResources({ searchText, @@ -112,10 +116,6 @@ export const LearningResourcesPage: React.FC = () => { handlePlayerClose, } = useLearningResourceActions({ onRefetch: refetch }); - const personaView = usePersonaViewMode(VIEW_MODE_PAGE.LearningResources); - const [selectedView, setView] = useState(); - const view = selectedView ?? personaView; - const [searchInputValue, setSearchInputValue] = useState(searchText); const debouncedSetSearchText = useMemo( @@ -138,8 +138,16 @@ export const LearningResourcesPage: React.FC = () => { setCurrentPage(1); }, [searchText, filterState]); - const handlePageSizeChange = useCallback((size: number) => { - setPageSize(size); + const handlePageSizeChange = useCallback( + (size: number) => { + savePageSize(size); + setCurrentPage(1); + }, + [savePageSize] + ); + + const handleViewChange = useCallback((nextView: PageViewMode) => { + setView(nextView); setCurrentPage(1); }, []); @@ -175,10 +183,17 @@ export const LearningResourcesPage: React.FC = () => { currentPage, isNumberBased: true, isLoading, - pageSizeOptions: [PAGE_SIZE_BASE, PAGE_SIZE_MEDIUM, PAGE_SIZE_LARGE], + pageSizeOptions, onShowSizeChange: handlePageSizeChange, }), - [paging.total, pageSize, currentPage, isLoading, handlePageSizeChange] + [ + paging.total, + pageSize, + pageSizeOptions, + currentPage, + isLoading, + handlePageSizeChange, + ] ); const renderCategoriesCell = (record: LearningResource) => { @@ -354,7 +369,7 @@ export const LearningResourcesPage: React.FC = () => { /> {quickFilters} - + {filterSelectionDisplay} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MarketPlacePage/MarketPlacePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MarketPlacePage/MarketPlacePage.tsx index 8651d895437d..78c5f00f2073 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MarketPlacePage/MarketPlacePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MarketPlacePage/MarketPlacePage.tsx @@ -26,6 +26,7 @@ import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadc import PageHeader from '../../components/PageHeader/PageHeader.component'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import ApplicationCard from '../../components/Settings/Applications/ApplicationCard/ApplicationCard.component'; +import { GRID_PAGE_SIZE_OPTIONS } from '../../constants/constants'; import { GlobalSettingOptions } from '../../constants/GlobalSettings.constants'; import { LEARNING_PAGE_IDS } from '../../constants/Learning.constants'; import { PAGE_HEADERS } from '../../constants/PageHeaders.constant'; @@ -54,7 +55,7 @@ const MarketPlacePage = () => { handlePageSizeChange, showPagination, pagingCursor, - } = usePaging(); + } = usePaging(undefined, GRID_PAGE_SIZE_OPTIONS); const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(true); const [applicationData, setApplicationData] = @@ -208,6 +209,7 @@ const MarketPlacePage = () => { currentPage={currentPage} isLoading={isLoading} pageSize={pageSize} + pageSizeOptions={GRID_PAGE_SIZE_OPTIONS} paging={paging} pagingHandler={handleMarketPlacePageChange} onShowSizeChange={handlePageSizeChange} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricListPage/MetricListPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricListPage/MetricListPage.test.tsx index 44c852d665f1..1495acbff77a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricListPage/MetricListPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricListPage/MetricListPage.test.tsx @@ -19,6 +19,7 @@ import { within, } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { PAGE_SIZE_BASE } from '../../../constants/constants'; import { EntityType } from '../../../enums/entity.enum'; import { EntityStatus, @@ -544,7 +545,7 @@ const setHierarchy = ({ mockUseMetricHierarchy.mockImplementation(() => ({ topLevelNodes, buildRows: () => rows, - paging: { total, offset: 0, limit: 20 }, + paging: { total, offset: 0, limit: PAGE_SIZE_BASE }, isPending, isFetching: isPending, error, @@ -1012,7 +1013,7 @@ describe('MetricListPage', () => { ).not.toBeInTheDocument(); }); - it('expands group banners once by default without expanding metric variants or overriding collapse', async () => { + it('expands group banners once per page scope without expanding metric variants', async () => { const groupRow = { id: 'group:group-1', isGroupRow: true, @@ -1060,12 +1061,12 @@ describe('MetricListPage', () => { { row: groupRow, groupId: 'group-1', members: [groupedMetric] }, ], }); + // Card view pages at a different size, and useMetricHierarchy clears expansion when the size + // changes, so the default expansion is applied again for the new scope. fireEvent.click(cardViewButton); - expect(hierarchyActions.expandAll).toHaveBeenCalledTimes(1); - expect(screen.getByTestId('metric-group-profitability')).toHaveAttribute( - 'aria-expanded', - 'false' + await waitFor(() => + expect(hierarchyActions.expandAll).toHaveBeenCalledTimes(2) ); }); @@ -1131,7 +1132,11 @@ describe('MetricListPage', () => { await waitFor(() => expect(mockUseMetricHierarchy).toHaveBeenLastCalledWith( - expect.objectContaining({ page: 2, pageSize: 20, query: '' }) + expect.objectContaining({ + page: 2, + pageSize: PAGE_SIZE_BASE, + query: '', + }) ) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricListPage/MetricListPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricListPage/MetricListPage.tsx index 35102391b566..d7d19fee6df3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricListPage/MetricListPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricListPage/MetricListPage.tsx @@ -93,6 +93,8 @@ import type { TagLabel } from '../../../generated/type/tagLabel'; import { TagSource } from '../../../generated/type/tagLabel'; import LimitWrapper from '../../../hoc/LimitWrapper'; import { useCurrentUserPreferences } from '../../../hooks/currentUserStore/useCurrentUserStore'; +import { usePageForSize } from '../../../hooks/paging/usePageForSize'; +import { useSavedPageSize } from '../../../hooks/paging/useSavedPageSize'; import { useIsAiMode } from '../../../hooks/useAppMode'; import { useMetricHierarchy } from '../../../hooks/useMetricHierarchy'; import { @@ -123,6 +125,7 @@ import { MetricTreeNode, VisibleMetricTableRow, } from '../../../utils/MetricEntityUtils/MetricHierarchyUtils'; +import { getViewPageSizeOptions } from '../../../utils/PaginationUtils'; import { getDerivedPermissionFlags } from '../../../utils/PermissionDerivation'; import { DEFAULT_ENTITY_PERMISSION } from '../../../utils/PermissionsUtils'; import { getEntityDetailsPath } from '../../../utils/RouterUtils'; @@ -152,7 +155,6 @@ const METRIC_TABLE_PREFERENCE_KEY = 'metricList'; const METRIC_STATIC_COLUMNS = ['name']; const METRIC_VIEW_STORAGE_KEY = 'metricsList.viewMode.v1'; const METRIC_SEARCH_DEBOUNCE_MS = 500; -const METRIC_PAGE_SIZE = 20; const DEFAULT_VISIBLE_METRIC_COLUMNS: MetricColumnId[] = [ 'description', @@ -255,7 +257,6 @@ const MetricListPage = () => { const [searchText, setSearchText] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(); - const [page, setPage] = useState(1); const [selectedMetricIds, setSelectedMetricIds] = useState([]); const { preferences: { selectedEntityTableColumns }, @@ -263,6 +264,10 @@ const MetricListPage = () => { // Owned by TableV2's Customize menu; card view only reads it. const visibleColumns = getVisibleMetricColumns(selectedEntityTableColumns); const [viewMode, setViewMode] = useState(getInitialViewMode); + const { savedPageSize: metricPageSize } = useSavedPageSize( + getViewPageSizeOptions(viewMode) + ); + const [page, setPage] = usePageForSize(metricPageSize); const [isExporting, setIsExporting] = useState(false); const [isMetricActionsOpen, setIsMetricActionsOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); @@ -306,14 +311,14 @@ const MetricListPage = () => { search: debouncedSearch, status: statusFilter, page, - size: METRIC_PAGE_SIZE, + size: metricPageSize, }, ], queryFn: () => searchQuery({ query: debouncedSearch, pageNumber: page, - pageSize: METRIC_PAGE_SIZE, + pageSize: metricPageSize, searchIndex: SearchIndex.METRIC, trackTotalHits: true, queryFilter: statusFilter @@ -346,7 +351,7 @@ const MetricListPage = () => { } = useMetricHierarchy({ enabled: [hasViewPermission, isTreeMode].every(Boolean), page, - pageSize: METRIC_PAGE_SIZE, + pageSize: metricPageSize, query: debouncedSearch, }); @@ -409,7 +414,7 @@ const MetricListPage = () => { }); }, [selectableMetricIds]); - const hierarchyExpansionScope = `${page}:${debouncedSearch}`; + const hierarchyExpansionScope = `${page}:${metricPageSize}:${debouncedSearch}`; useEffect(() => { const hasExpandableGroup = topLevelNodes.some(({ groupId }) => groupId); @@ -452,7 +457,7 @@ const MetricListPage = () => { const totalMetrics = isTreeMode ? treePaging.total : searchResponse?.hits.total.value ?? 0; - const totalPages = Math.max(1, Math.ceil(totalMetrics / METRIC_PAGE_SIZE)); + const totalPages = Math.max(1, Math.ceil(totalMetrics / metricPageSize)); const listingError = permissionError ?? (isTreeMode ? treeError : searchError); const listingErrorMessage = permissionError @@ -530,10 +535,14 @@ const MetricListPage = () => { [setCurrentPage] ); - const handleViewModeChange = useCallback((nextMode: MetricViewMode) => { - setViewMode(nextMode); - localStorage.setItem(METRIC_VIEW_STORAGE_KEY, nextMode); - }, []); + const handleViewModeChange = useCallback( + (nextMode: MetricViewMode) => { + setViewMode(nextMode); + setCurrentPage(1); + localStorage.setItem(METRIC_VIEW_STORAGE_KEY, nextMode); + }, + [setCurrentPage] + ); const handleImport = useCallback(() => { setIsMetricActionsOpen(false); @@ -1050,7 +1059,7 @@ const MetricListPage = () => { ); const renderPagination = () => - totalMetrics > METRIC_PAGE_SIZE ? ( + totalMetrics > metricPageSize ? ( { const { isAdminUser } = useAuth(); const { t } = useTranslation(); @@ -60,7 +59,7 @@ export const PersonaPage = ({ pageTitle }: { pageTitle: string }) => { paging, pagingCursor, showPagination, - } = usePaging(PERSONA_PAGE_SIZE); + } = usePaging(undefined, GRID_PAGE_SIZE_OPTIONS); const breadcrumbs: TitleBreadcrumbProps['titleLinks'] = useMemo( () => getSettingPageEntityBreadCrumb(GlobalSettingsMenuCategory.PERSONA), diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.interface.ts b/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.interface.ts index 7e0d9b08b12f..8234a0cdcdde 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.interface.ts @@ -24,6 +24,7 @@ export interface ServiceVersionMainTabContentProps { data: ServicePageData[]; isServiceLoading: boolean; paging: Paging; + pageSize: number; currentPage: number; pagingHandler: NextPreviousProps['pagingHandler']; entityType: EntityType; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.test.tsx index f98716fa2ecc..02b549ce2e92 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.test.tsx @@ -166,6 +166,7 @@ const props: ServiceVersionMainTabContentProps = { serviceName: 'sample_data', data: mockData, isServiceLoading: false, + pageSize: 15, paging: { total: 1 }, pagingHandler: mockPagingHandler, currentPage: 1, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.tsx index a14743213089..b38a90809e02 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.tsx @@ -24,7 +24,6 @@ import { ColumnsType } from '../../components/common/Table/Table.interface'; import Table from '../../components/common/Table/TableV2'; import TagsContainerV2 from '../../components/Tag/TagsContainerV2/TagsContainerV2'; import { DisplayType } from '../../components/Tag/TagsViewer/TagsViewer.interface'; -import { PAGE_SIZE } from '../../constants/constants'; import { TABLE_SCROLL_VALUE } from '../../constants/Table.constants'; import { TagSource } from '../../generated/type/tagLabel'; import { useFqn } from '../../hooks/useFqn'; @@ -40,6 +39,7 @@ function ServiceVersionMainTabContent({ data, isServiceLoading, paging, + pageSize, pagingHandler, currentPage, serviceDetails, @@ -106,7 +106,7 @@ function ServiceVersionMainTabContent({ showPagination: Boolean(!isNil(paging.after) || !isNil(paging.before)) && !isEmpty(data), - pageSize: PAGE_SIZE, + pageSize, paging, pagingHandler, }} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionPage.tsx index bfa1631a9a39..bd0a5859c942 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionPage.tsx @@ -334,7 +334,8 @@ function ServiceVersionPage() { ); const getOtherDetails = useCallback( - async (paging?: PagingWithoutTotal) => { + async (cursor?: PagingWithoutTotal) => { + const paging = { ...cursor, limit: pageSize }; try { setIsOtherDataLoading(true); switch (serviceCategory) { @@ -394,6 +395,7 @@ function ServiceVersionPage() { }, [ serviceCategory, + pageSize, fetchDatabases, fetchTopics, fetchDashboards, @@ -463,7 +465,7 @@ function ServiceVersionPage() { ); } }, - [paging, getOtherDetails, handlePageChange] + [paging, pageSize, getOtherDetails, handlePageChange] ); const tabs: DetailsTabItem[] = useMemo(() => { @@ -482,6 +484,7 @@ function ServiceVersionPage() { data={data} entityType={entityType} isServiceLoading={isOtherDataLoading} + pageSize={pageSize} paging={paging} pagingHandler={pagingHandler} serviceDetails={currentVersionData} @@ -500,6 +503,7 @@ function ServiceVersionPage() { currentVersionData, serviceCategory, paging, + pageSize, data, isOtherDataLoading, getOtherDetails, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/WorkflowDefinitions/WorkflowsPage/WorkflowsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/WorkflowDefinitions/WorkflowsPage/WorkflowsPage.test.tsx index 361673bd5ea0..2cc156fbda15 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/WorkflowDefinitions/WorkflowsPage/WorkflowsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/WorkflowDefinitions/WorkflowsPage/WorkflowsPage.test.tsx @@ -14,6 +14,8 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; +import NextPrevious from '../../../components/common/NextPrevious/NextPrevious'; +import { GRID_PAGE_SIZE_OPTIONS } from '../../../constants/constants'; import { getWorkflowDefinitions } from '../../../rest/workflowDefinitionsAPI'; import workflowClassBase from '../../../utils/WorkflowClassBase'; import WorkflowsPage from './WorkflowsPage'; @@ -201,4 +203,31 @@ describe('WorkflowsPage on OSS', () => { screen.queryByTestId('create-workflow-button') ).not.toBeInTheDocument(); }); + + it('pages the workflow cards on the grid scale', async () => { + mockGetWorkflowDefinitions.mockResolvedValue({ + data: [ + { + id: 'w1', + name: 'pw-grid-workflow', + fullyQualifiedName: 'pw-grid-workflow', + }, + ], + paging: { total: 1 }, + }); + + renderPage(); + + expect(await screen.findByTestId('pw-grid-workflow')).toBeInTheDocument(); + expect(mockGetWorkflowDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ limit: 12 }) + ); + expect(NextPrevious).toHaveBeenLastCalledWith( + expect.objectContaining({ + pageSize: 12, + pageSizeOptions: GRID_PAGE_SIZE_OPTIONS, + }), + expect.anything() + ); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/WorkflowDefinitions/WorkflowsPage/WorkflowsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/WorkflowDefinitions/WorkflowsPage/WorkflowsPage.tsx index ee01dcfac675..ce726b919ab6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/WorkflowDefinitions/WorkflowsPage/WorkflowsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/WorkflowDefinitions/WorkflowsPage/WorkflowsPage.tsx @@ -42,15 +42,14 @@ import { LearningIcon } from '../../../components/Learning/LearningIcon/Learning import PageHeader from '../../../components/PageHeader/PageHeader.component'; import PageLayoutV1 from '../../../components/PageLayoutV1/PageLayoutV1'; import { + GRID_PAGE_SIZE_OPTIONS, INITIAL_PAGING_VALUE, - PAGE_SIZE_BASE, - PAGE_SIZE_LARGE, - PAGE_SIZE_MEDIUM, } from '../../../constants/constants'; import { LEARNING_PAGE_IDS } from '../../../constants/Learning.constants'; import { CursorType } from '../../../enums/pagination.enum'; import { WorkflowDefinition } from '../../../generated/governance/workflows/workflowDefinition'; import { Paging } from '../../../generated/type/paging'; +import { useSavedPageSize } from '../../../hooks/paging/useSavedPageSize'; import { useIsAiMode } from '../../../hooks/useAppMode'; import { createWorkflowDefinition, @@ -80,7 +79,9 @@ const WorkflowsPage = () => { total: 0, }); const [currentPage, setCurrentPage] = useState(INITIAL_PAGING_VALUE); - const [pageSize, setPageSize] = useState(PAGE_SIZE_MEDIUM); + const { savedPageSize: pageSize, savePageSize } = useSavedPageSize( + GRID_PAGE_SIZE_OPTIONS + ); const [isModalOpen, setIsModalOpen] = useState(false); const [workflowName, setWorkflowName] = useState(''); const [description, setDescription] = useState(''); @@ -384,14 +385,10 @@ const WorkflowsPage = () => { currentPage={currentPage} isLoading={loading} pageSize={pageSize} - pageSizeOptions={[ - PAGE_SIZE_BASE, - PAGE_SIZE_MEDIUM, - PAGE_SIZE_LARGE, - ]} + pageSizeOptions={GRID_PAGE_SIZE_OPTIONS} paging={paging} pagingHandler={handlePageChange} - onShowSizeChange={setPageSize} + onShowSizeChange={savePageSize} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/PaginationUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/PaginationUtils.test.ts index 0552cc68d0ce..5bdd7c13ee32 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/PaginationUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/PaginationUtils.test.ts @@ -1,4 +1,9 @@ -import { computeTotalPages } from './PaginationUtils'; +import { + GRID_PAGE_SIZE_OPTIONS, + LIST_PAGE_SIZE_OPTIONS, +} from '../constants/constants'; +import { PageViewMode } from '../generated/type/personaPreferences'; +import { computeTotalPages, getViewPageSizeOptions } from './PaginationUtils'; /* * Copyright 2024 Collate. @@ -41,3 +46,25 @@ describe('computeTotalPages', () => { expect(computeTotalPages(20, 10)).toBe(1); }); }); + +// ADR:2026-10-13-ui-page-sizes-lists-15-25-50-grids-12-24-48 +describe('view page sizes', () => { + it('pages lists at 15/25/50 and card grids at 12/24/48', () => { + expect(LIST_PAGE_SIZE_OPTIONS).toEqual([15, 25, 50]); + expect(GRID_PAGE_SIZE_OPTIONS).toEqual([12, 24, 48]); + }); + + it('uses the grid scale only for the card view', () => { + expect(getViewPageSizeOptions(PageViewMode.Card)).toBe( + GRID_PAGE_SIZE_OPTIONS + ); + expect(getViewPageSizeOptions('card')).toBe(GRID_PAGE_SIZE_OPTIONS); + expect(getViewPageSizeOptions(PageViewMode.Table)).toBe( + LIST_PAGE_SIZE_OPTIONS + ); + expect(getViewPageSizeOptions(PageViewMode.Tree)).toBe( + LIST_PAGE_SIZE_OPTIONS + ); + expect(getViewPageSizeOptions(undefined)).toBe(LIST_PAGE_SIZE_OPTIONS); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/PaginationUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/PaginationUtils.ts index 084f32a8a2b4..a577dc9dde86 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/PaginationUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/PaginationUtils.ts @@ -11,6 +11,17 @@ * limitations under the License. */ +import { + GRID_PAGE_SIZE_OPTIONS, + LIST_PAGE_SIZE_OPTIONS, +} from '../constants/constants'; +import { PageViewMode } from '../generated/type/personaPreferences'; + export const computeTotalPages = (pSize: number, total: number) => { return Math.ceil(total / pSize); }; + +type ViewMode = PageViewMode | `${PageViewMode}`; + +export const getViewPageSizeOptions = (view?: ViewMode) => + view === PageViewMode.Card ? GRID_PAGE_SIZE_OPTIONS : LIST_PAGE_SIZE_OPTIONS;