From 24169a7b6800e236cf443b10e6789feecf94cec7 Mon Sep 17 00:00:00 2001 From: XingY Date: Tue, 8 Sep 2026 18:59:52 -0700 Subject: [PATCH 1/8] GitHub Issue 1534: Cap row counts for React grid pagination --- packages/components/package-lock.json | 12 +-- packages/components/package.json | 4 +- .../components/src/internal/app/constants.ts | 1 + .../components/src/internal/app/utils.test.ts | 13 ++++ packages/components/src/internal/app/utils.ts | 5 ++ .../components/gridbar/ExportModal.tsx | 5 +- .../components/pagination/PageMenu.test.tsx | 8 ++ .../components/pagination/PageMenu.tsx | 12 ++- .../components/pagination/Pagination.tsx | 4 + .../pagination/PaginationInfo.test.tsx | 14 ++++ .../components/pagination/PaginationInfo.tsx | 8 +- .../src/internal/query/selectRows.ts | 3 + .../src/public/QueryModel/QueryModel.test.ts | 72 +++++++++++++++++ .../src/public/QueryModel/QueryModel.ts | 39 +++++++++- .../QueryModel/SelectionStatus.test.tsx | 35 +++++++++ .../src/public/QueryModel/SelectionStatus.tsx | 15 +++- .../QueryModel/withQueryModels.test.tsx | 77 +++++++++++++++++++ .../src/public/QueryModel/withQueryModels.tsx | 15 +++- 18 files changed, 315 insertions(+), 27 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index b598634bd1..715147b6a9 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,16 +1,16 @@ { "name": "@labkey/components", - "version": "7.62.3", + "version": "7.62.4-fb-limitMaxCount.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.62.3", + "version": "7.62.4-fb-limitMaxCount.4", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.52.4", + "@labkey/api": "1.52.5-fb-limitMaxCount.1", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~7.0.1", "@testing-library/react": "~16.3.2", @@ -2925,9 +2925,9 @@ } }, "node_modules/@labkey/api": { - "version": "1.52.4", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.52.4.tgz", - "integrity": "sha512-a6kLuI7Z33nbkH1MF+0vfn7secPrGyh9Qig3QH1esmsOGVE0EfggI+hZ8RgVBXxcbq5UKP/ti2ypHD3cvNE+zQ==", + "version": "1.52.5-fb-limitMaxCount.1", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.52.5-fb-limitMaxCount.1.tgz", + "integrity": "sha512-X320LbNjGkpStkwWVudCWPS5pK0DfkRYeMBScWL9QBtIaLKkRyiOMHem6JomXM5IBC64X5qTo7W7uy+zge8Wag==", "license": "Apache-2.0" }, "node_modules/@labkey/build": { diff --git a/packages/components/package.json b/packages/components/package.json index 369d870bea..9a12e14d1f 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.62.3", + "version": "7.62.4-fb-limitMaxCount.4", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ @@ -48,7 +48,7 @@ "homepage": "https://github.com/LabKey/labkey-ui-components#readme", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.52.4", + "@labkey/api": "1.52.5-fb-limitMaxCount.1", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~7.0.1", "@testing-library/react": "~16.3.2", diff --git a/packages/components/src/internal/app/constants.ts b/packages/components/src/internal/app/constants.ts index 6994e15ab4..7850e5c49a 100644 --- a/packages/components/src/internal/app/constants.ts +++ b/packages/components/src/internal/app/constants.ts @@ -108,6 +108,7 @@ export const NOTIFICATION_TIMEOUT = 1000; export const SERVER_NOTIFICATION_MAX_ROWS = 8; +export const EXPERIMENTAL_USE_LEGACY_EXACT_ROW_COUNTS = 'queryUseLegacyExactRowCounts'; export const EXPERIMENTAL_PRODUCT_ALL_FOLDER_LOOKUPS = 'queryProductAllFolderLookups'; export const EXPERIMENTAL_PRODUCT_FOLDER_DATA_LISTING_SCOPED = 'queryProductProjectDataListingScoped'; export const EXPERIMENTAL_REQUESTS_MENU = 'experimental-biologics-requests-menu'; diff --git a/packages/components/src/internal/app/utils.test.ts b/packages/components/src/internal/app/utils.test.ts index f6797a7fd4..367fd61e09 100644 --- a/packages/components/src/internal/app/utils.test.ts +++ b/packages/components/src/internal/app/utils.test.ts @@ -55,6 +55,7 @@ import { isAssayQCEnabled, isAssayRequestsEnabled, isCalculatedFieldsEnabled, + isCappedGridCountEnabled, isCommunityDistribution, isConditionalFormattingEnabled, isELNEnabled, @@ -1370,3 +1371,15 @@ describe('isSharedContainer', () => { expect(isSharedContainer('/Shared')).toBe(true); }); }); + +describe('isCappedGridCountEnabled', () => { + test('enabled by default', () => { + expect(isCappedGridCountEnabled({})).toBe(true); + expect(isCappedGridCountEnabled({ query: {} })).toBe(true); + }); + + test('disabled only when the legacy-exact-counts flag is explicitly true', () => { + expect(isCappedGridCountEnabled({ query: { queryUseLegacyExactRowCounts: true } })).toBe(false); + expect(isCappedGridCountEnabled({ query: { queryUseLegacyExactRowCounts: false } })).toBe(true); + }); +}); diff --git a/packages/components/src/internal/app/utils.ts b/packages/components/src/internal/app/utils.ts index 2c3c07b1b5..0312c85708 100644 --- a/packages/components/src/internal/app/utils.ts +++ b/packages/components/src/internal/app/utils.ts @@ -24,6 +24,7 @@ import { ARCHIVED_FOLDERS, ASSAYS_KEY, BIOLOGICS_APP_PROPERTIES, + EXPERIMENTAL_USE_LEGACY_EXACT_ROW_COUNTS, EXPERIMENTAL_PRODUCT_ALL_FOLDER_LOOKUPS, EXPERIMENTAL_PRODUCT_FOLDER_DATA_LISTING_SCOPED, EXPERIMENTAL_REQUESTS_MENU, @@ -267,6 +268,10 @@ export function isAllProductFoldersFilteringEnabled(moduleContext?: ModuleContex return resolveModuleContext(moduleContext)?.query?.[EXPERIMENTAL_PRODUCT_ALL_FOLDER_LOOKUPS] === true; } +export function isCappedGridCountEnabled(moduleContext?: ModuleContext): boolean { + return resolveModuleContext(moduleContext)?.query?.[EXPERIMENTAL_USE_LEGACY_EXACT_ROW_COUNTS] !== true; +} + export function isProductFoldersDataListingScopedToFolder(moduleContext?: ModuleContext): boolean { return resolveModuleContext(moduleContext)?.query?.[EXPERIMENTAL_PRODUCT_FOLDER_DATA_LISTING_SCOPED] === true; } diff --git a/packages/components/src/internal/components/gridbar/ExportModal.tsx b/packages/components/src/internal/components/gridbar/ExportModal.tsx index 2ccc70e87c..f78ec3b9eb 100644 --- a/packages/components/src/internal/components/gridbar/ExportModal.tsx +++ b/packages/components/src/internal/components/gridbar/ExportModal.tsx @@ -84,7 +84,10 @@ export const ExportModal: FC = memo(props => { {model.title} - {rowCountDisplay} + + {rowCountDisplay} + {model.rowCountCapped ? '+' : ''} + {!model.viewName || model.viewName.startsWith('~~') ? 'Default' : model.viewName}{' '} {model.currentView?.session && (edited)} diff --git a/packages/components/src/internal/components/pagination/PageMenu.test.tsx b/packages/components/src/internal/components/pagination/PageMenu.test.tsx index 0d91a6b503..762ba69b3d 100644 --- a/packages/components/src/internal/components/pagination/PageMenu.test.tsx +++ b/packages/components/src/internal/components/pagination/PageMenu.test.tsx @@ -72,6 +72,14 @@ describe('PageMenu', () => { expectPageMenuItems(false, false, true, '34', '34 Total Pages'); }); + test('capped rowCount hides Last Page and total pages', () => { + render(); + expect(screen.getByText('First Page')).toBeInTheDocument(); + // last-page navigation and the total-pages footer are meaningless when the count is only a floor + expect(screen.queryByText('Last Page')).not.toBeInTheDocument(); + expect(screen.queryByText('34 Total Pages')).not.toBeInTheDocument(); + }); + test('interactions', async () => { render(); diff --git a/packages/components/src/internal/components/pagination/PageMenu.tsx b/packages/components/src/internal/components/pagination/PageMenu.tsx index 1bb0bc27d1..77e331ff25 100644 --- a/packages/components/src/internal/components/pagination/PageMenu.tsx +++ b/packages/components/src/internal/components/pagination/PageMenu.tsx @@ -20,6 +20,7 @@ interface Props { pageCount: number; pageSize: number; pageSizes: number[]; + rowCountCapped?: boolean; setPageSize: (size: number) => void; } @@ -34,6 +35,7 @@ export const PageMenu: FC = props => { pageCount, pageSize, pageSizes, + rowCountCapped, setPageSize, } = props; const totalPagesText = disabled ? '...' : `${pageCount.toLocaleString()} Total Pages`; @@ -65,10 +67,12 @@ export const PageMenu: FC = props => { First Page - - Last Page - - + {!rowCountCapped && ( + + Last Page + + )} + {!rowCountCapped && } {pageSizes?.map(size => ( diff --git a/packages/components/src/internal/components/pagination/Pagination.tsx b/packages/components/src/internal/components/pagination/Pagination.tsx index 189efbe537..84696cb0bb 100644 --- a/packages/components/src/internal/components/pagination/Pagination.tsx +++ b/packages/components/src/internal/components/pagination/Pagination.tsx @@ -21,6 +21,7 @@ export interface PaginationData { pageCount: number; pageSize: number; rowCount: number; + rowCountCapped?: boolean; totalCountLoadingState?: LoadingState; } @@ -52,6 +53,7 @@ export const Pagination: FC = memo(props => { pageSize, pageSizes = DEFAULT_PAGE_SIZES, rowCount, + rowCountCapped, setPageSize, totalCountLoadingState, } = props; @@ -97,6 +99,7 @@ export const Pagination: FC = memo(props => { offset={offset} pageSize={pageSize} rowCount={rowCount} + rowCountCapped={rowCountCapped} totalCountLoadingState={totalCountLoadingState} /> @@ -116,6 +119,7 @@ export const Pagination: FC = memo(props => { isFirstPage={isFirstPage} isLastPage={isLastPage} pageCount={pageCount} + rowCountCapped={rowCountCapped} loadFirstPage={onLoadFirstPage} loadLastPage={onLoadLastPage} pageSize={pageSize} diff --git a/packages/components/src/internal/components/pagination/PaginationInfo.test.tsx b/packages/components/src/internal/components/pagination/PaginationInfo.test.tsx index 8c3fba9615..7cff7c4617 100644 --- a/packages/components/src/internal/components/pagination/PaginationInfo.test.tsx +++ b/packages/components/src/internal/components/pagination/PaginationInfo.test.tsx @@ -46,4 +46,18 @@ describe('PaginationInfo', () => { expect(document.querySelector('.pagination-info')).toBeInTheDocument(); expect(document.querySelector('.pagination-info').textContent).toBe('1 - 3,412 of 22,341'); }); + + test('capped rowCount shows a "+" suffix', () => { + render(); + expect(document.querySelector('.pagination-info').textContent).toBe('1 - 20 of 100,000+'); + }); + + test('capped rowCount shows total even on the boundary page where rowCount === max', () => { + // rowCount (20) === max (20): without the cap the total is hidden, with the cap it shows "20+" + const { container, rerender } = render(); + expect(container.querySelector('.pagination-info').textContent).toBe('1 - 20'); + + rerender(); + expect(container.querySelector('.pagination-info').textContent).toBe('1 - 20 of 20+'); + }); }); diff --git a/packages/components/src/internal/components/pagination/PaginationInfo.tsx b/packages/components/src/internal/components/pagination/PaginationInfo.tsx index 2d90e313e4..c605135534 100644 --- a/packages/components/src/internal/components/pagination/PaginationInfo.tsx +++ b/packages/components/src/internal/components/pagination/PaginationInfo.tsx @@ -11,24 +11,26 @@ export interface PaginationInfoProps { offset: number; pageSize: number; rowCount: number; + rowCountCapped?: boolean; totalCountLoadingState?: LoadingState; } export const PaginationInfo: FC = memo(props => { - const { offset, pageSize, rowCount, totalCountLoadingState } = props; + const { offset, pageSize, rowCount, rowCountCapped, totalCountLoadingState } = props; const loading = isLoading(totalCountLoadingState); const outOfBounds = rowCount <= offset; const min = offset !== rowCount ? offset + 1 : offset; const max = offset + pageSize; const text = outOfBounds ? '' : `${min.toLocaleString()} - `; const showRowCount = !loading && !outOfBounds; - const showTotalCount = !loading && rowCount > max; + // When capped, rowCount equals the cap, so show it with a "+" even on the boundary page where rowCount === max. + const showTotalCount = !loading && !outOfBounds && (rowCount > max || rowCountCapped); return ( {text} {loading && } {showRowCount && {max > rowCount ? rowCount.toLocaleString() : max.toLocaleString()}} - {showTotalCount && {` of ${rowCount.toLocaleString()}`}} + {showTotalCount && {` of ${rowCount.toLocaleString()}${rowCountCapped ? '+' : ''}`}} ); }); diff --git a/packages/components/src/internal/query/selectRows.ts b/packages/components/src/internal/query/selectRows.ts index 1872df5a73..ce4bac28aa 100644 --- a/packages/components/src/internal/query/selectRows.ts +++ b/packages/components/src/internal/query/selectRows.ts @@ -40,6 +40,8 @@ export interface SelectRowsResponse { metaData: Query.ResponseMetadata | undefined; queryInfo: QueryInfo; rowCount: number; + /** True when rowCount was capped at maxCount rather than counted exactly. */ + rowCountCapped?: boolean; rows: Row[]; schemaQuery: SchemaQuery; } @@ -99,6 +101,7 @@ export async function selectRows(options: SelectRowsOptions): Promise { expect(model.isLastPage).toEqual(true); }); + test('maxCount', () => { + // defaults to the cap, and rowCountCapped starts false + let model = new QueryModel({ schemaQuery: SCHEMA_QUERY }); + expect(model.maxCount).toEqual(DEFAULT_MAX_COUNT); + expect(model.rowCountCapped).toEqual(false); + + // explicit config values pass through, including 0 to request an exact count + expect(new QueryModel({ schemaQuery: SCHEMA_QUERY, maxCount: 500 }).maxCount).toEqual(500); + expect(new QueryModel({ schemaQuery: SCHEMA_QUERY, maxCount: 0 }).maxCount).toEqual(0); + }); + + test('isLastPage with capped rowCount', () => { + const model = new QueryModel({ schemaQuery: SCHEMA_QUERY }).mutate({ + maxRows: 20, + offset: 660, + rowCount: 661, + rows: {}, + }); + // on the true last page normally + expect(model.isLastPage).toEqual(true); + // a capped rowCount is only a floor, so paging forward stays available + expect(model.mutate({ rowCountCapped: true }).isLastPage).toEqual(false); + }); + + test('selectedState with capped rowCount', () => { + // selectedOnPage (2) === rowCount (2) but not every visible row is selected + const model = new QueryModel({ schemaQuery: SCHEMA_QUERY }).mutate({ + rows: { '1': { test: 1 }, '2': { test: 2 }, '3': { test: 3 } }, + orderedRows: ['1', '2', '3'], + selections: new Set(['1', '2']), + rowCount: 2, + maxRows: 20, + queryInfoLoadingState: LoadingState.LOADED, + rowsLoadingState: LoadingState.LOADED, + }); + // an exact rowCount matching the selection count reads as ALL + expect(model.selectedState).toBe(GRID_CHECKBOX_OPTIONS.ALL); + // a capped rowCount is only a floor, so it can't establish ALL: 2 of 3 visible selected is SOME + expect(model.mutate({ rowCountCapped: true }).selectedState).toBe(GRID_CHECKBOX_OPTIONS.SOME); + }); + + test('paginationData includes rowCountCapped', () => { + const model = new QueryModel({ schemaQuery: SCHEMA_QUERY }).mutate({ rowCountCapped: true }); + expect(model.paginationData.rowCountCapped).toEqual(true); + }); + test('Data', () => { const model = new QueryModel({ schemaQuery: SCHEMA_QUERY }).mutate({ orderedRows: ORDERED_ROWS, @@ -415,6 +462,7 @@ describe('locationHasQueryParamSettings', () => { expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.sort': '1' }))).toBe(true); expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.p': '1' }))).toBe(true); expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.pageSize': '1' }))).toBe(true); + expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.maxCount': '1' }))).toBe(true); expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.col~eq=': '1' }))).toBe(true); }); @@ -425,6 +473,7 @@ describe('locationHasQueryParamSettings', () => { expect(locationHasQueryParamSettings('bogus', new URLSearchParams({ 'test.sort': '1' }))).toBe(false); expect(locationHasQueryParamSettings('bogus', new URLSearchParams({ 'test.p': '1' }))).toBe(false); expect(locationHasQueryParamSettings('bogus', new URLSearchParams({ 'test.pageSize': '1' }))).toBe(false); + expect(locationHasQueryParamSettings('bogus', new URLSearchParams({ 'test.maxCount': '1' }))).toBe(false); expect(locationHasQueryParamSettings('bogus', new URLSearchParams({ 'test.col~eq=': '1' }))).toBe(false); }); @@ -442,6 +491,7 @@ describe('attributesForURLQueryParams', () => { test('without useExistingValues', () => { const defaultExpected = { filterArray: [], + maxCount: DEFAULT_MAX_COUNT, maxRows: DEFAULT_MAX_ROWS, offset: DEFAULT_OFFSET, schemaQuery: SCHEMA_QUERY, @@ -482,6 +532,20 @@ describe('attributesForURLQueryParams', () => { offset: 200, }); + // maxCount should be honored, including 0 (request an exact count) + searchParams = new URLSearchParams({ 'query.maxCount': '2' }); + values = model.attributesForURLQueryParams(searchParams); + expect(values).toEqual({ ...defaultExpected, maxCount: 2 }); + + searchParams = new URLSearchParams({ 'query.maxCount': '0' }); + values = model.attributesForURLQueryParams(searchParams); + expect(values).toEqual({ ...defaultExpected, maxCount: 0 }); + + // a non-numeric maxCount falls back to the model's configured cap + searchParams = new URLSearchParams({ 'query.maxCount': 'bogus' }); + values = model.attributesForURLQueryParams(searchParams); + expect(values).toEqual(defaultExpected); + // reportId should be honored searchParams = new URLSearchParams({ 'query.selectedReportIds': 'db:99', @@ -541,6 +605,7 @@ describe('attributesForURLQueryParams', () => { values = model.attributesForURLQueryParams(searchParams); expect(values).toEqual({ filterArray: expectedFilters, + maxCount: DEFAULT_MAX_COUNT, maxRows: 100, offset: 200, schemaQuery: new SchemaQuery(SCHEMA_QUERY.schemaName, SCHEMA_QUERY.queryName, 'custom view'), @@ -552,6 +617,7 @@ describe('attributesForURLQueryParams', () => { test('with useExistingValues', () => { const defaultExpected = { filterArray: [Filter.create('existingCol', 25)], + maxCount: DEFAULT_MAX_COUNT, maxRows: 10, offset: 60, schemaQuery: new SchemaQuery(SCHEMA_QUERY.schemaName, SCHEMA_QUERY.queryName, 'existing custom view'), @@ -654,11 +720,17 @@ describe('attributesForURLQueryParams', () => { values = model.attributesForURLQueryParams(searchParams, true); expect(values).toEqual({ filterArray: expectedFilters, + maxCount: DEFAULT_MAX_COUNT, maxRows: 100, offset: 200, schemaQuery: new SchemaQuery(SCHEMA_QUERY.schemaName, SCHEMA_QUERY.queryName, 'custom view'), selectedReportIds: ['db:99'], sorts: expectedSorts, }); + + // an explicit URL maxCount overrides the model's configured cap + searchParams = new URLSearchParams({ 'query.maxCount': '2' }); + values = model.attributesForURLQueryParams(searchParams, true); + expect(values).toEqual({ ...defaultExpected, maxCount: 2 }); }); }); diff --git a/packages/components/src/public/QueryModel/QueryModel.ts b/packages/components/src/public/QueryModel/QueryModel.ts index a942435e5f..7836edb05c 100644 --- a/packages/components/src/public/QueryModel/QueryModel.ts +++ b/packages/components/src/public/QueryModel/QueryModel.ts @@ -20,6 +20,7 @@ import { caseInsensitive } from '../../internal/util/utils'; import { naturalSortByProperty } from '../sort'; import { PaginationData } from '../../internal/components/pagination/Pagination'; import { SelectRowsMessage, SelectRowsOptions } from '../../internal/query/selectRows'; +import { isCappedGridCountEnabled } from '../../internal/app/utils'; export function flattenValuesFromRow( row: any, @@ -91,6 +92,8 @@ export function locationHasQueryParamSettings(prefix: string, searchParams?: URL if (searchParams.get(`${prefix}.sort`) !== null) return true; // Page offset if (searchParams.get(`${prefix}.p`) !== null) return true; + // Row-count cap + if (searchParams.get(`${prefix}.maxCount`) !== null) return true; // Page size return searchParams.get(`${prefix}.pageSize`) !== null; } @@ -169,6 +172,11 @@ export interface QueryConfig { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any keyValue?: any; + /** + * Cap the pagination row count at this many rows (defaults to DEFAULT_MAX_COUNT). Above the cap the grid shows + * "N+" and hides the last-page control. Set to 0 to count exactly. + */ + maxCount?: number; /** * The maximum number of rows to return from the server (defaults to 100000). * If you want to return all possible rows, set this config property to -1. @@ -235,6 +243,8 @@ export interface QueryConfig { export const DEFAULT_OFFSET = 0; export const DEFAULT_MAX_ROWS = 20; +// Cap the pagination count so a large grid's COUNT(*) is fixed-cost; above this the grid shows "100,000+". 0 counts exactly. +export const DEFAULT_MAX_COUNT = 100000; /** * An object that describes the current selection pivot row for shift-select behavior. When a single row is selected @@ -315,6 +325,10 @@ export class QueryModel { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly keyValue?: any; + /** + * Cap the pagination row count at this many rows (defaults to DEFAULT_MAX_COUNT). 0 counts exactly. + */ + readonly maxCount: number; /** * The maximum number of rows to return from the server (defaults to 20). * If you want to return all possible rows, set this config property to -1. @@ -411,6 +425,10 @@ export class QueryModel { * be the total count of rows for the query parameters. */ readonly rowCount?: number; + /** + * True when rowCount was capped at maxCount rather than counted exactly, i.e. there are more than rowCount rows. + */ + readonly rowCountCapped: boolean; /** * Error message from API call to load the data rows. */ @@ -500,6 +518,8 @@ export class QueryModel { this.includeUpdateColumn = queryConfig.includeUpdateColumn ?? false; this.includeTotalCount = queryConfig.includeTotalCount ?? false; this.keyValue = queryConfig.keyValue; + // Respect the opt-out experimental flag: when disabled, count exactly (0) instead of applying the default cap. + this.maxCount = queryConfig.maxCount ?? (isCappedGridCountEnabled() ? DEFAULT_MAX_COUNT : 0); this.maxRows = queryConfig.maxRows ?? DEFAULT_MAX_ROWS; this.offset = queryConfig.offset ?? DEFAULT_OFFSET; this.omittedColumns = queryConfig.omittedColumns ?? []; @@ -516,6 +536,7 @@ export class QueryModel { this.orderedRows = undefined; this.rows = undefined; this.rowCount = undefined; + this.rowCountCapped = false; this.rowsLoadingState = LoadingState.INITIALIZED; this.selectedReportIds = []; this.selectionPivot = undefined; @@ -1032,12 +1053,14 @@ export class QueryModel { * Get the row selection state (ALL, SOME, or NONE) for the QueryModel. */ get selectedState(): GRID_CHECKBOX_OPTIONS { - const { hasData, isLoading, maxRows, orderedRows, selections, rowCount } = this; + const { hasData, isLoading, maxRows, orderedRows, selections, rowCount, rowCountCapped } = this; if (!isLoading && hasData && selections) { const selectedOnPage = orderedRows.filter(rowId => selections.has(rowId)).length; - if ((selectedOnPage === rowCount || selectedOnPage === orderedRows.length) && rowCount > 0) { + // A capped rowCount is a floor, not the true total, so it can't establish that everything is selected. + const allByCount = !rowCountCapped && selectedOnPage === rowCount; + if ((allByCount || selectedOnPage === orderedRows.length) && rowCount > 0) { return GRID_CHECKBOX_OPTIONS.ALL; } else if (selectedOnPage > 0) { // if model has any selected on the page show checkbox as indeterminate @@ -1100,6 +1123,8 @@ export class QueryModel { * True if the current page is the last page for the given QueryModel rows. */ get isLastPage(): boolean { + // When the count is capped we don't know the real last page, so keep paging forward available. + if (this.rowCountCapped) return false; return this.currentPage === this.pageCount; } @@ -1123,6 +1148,7 @@ export class QueryModel { pageCount: this.pageCount, pageSize: this.maxRows, rowCount: this.rowCount, + rowCountCapped: this.rowCountCapped, totalCountLoadingState: this.totalCountLoadingState, }; } @@ -1157,6 +1183,7 @@ export class QueryModel { id: this.id, includeDetailsColumn: this.includeDetailsColumn, keyValue: this.keyValue, + maxCount: this.maxCount, maxRows: this.maxRows, offset: this.offset, omittedColumns: Array.from(this.omittedColumns), @@ -1203,6 +1230,10 @@ export class QueryModel { let filterArray = columnFilters.concat(searchFilters); let maxRows = parseInt(searchParams.get(`${prefix}.pageSize`), 10); if (isNaN(maxRows)) maxRows = DEFAULT_MAX_ROWS; + // Used for selenium test + // Absent maxCount keeps the configured cap; 0 (exact count) is a valid override, so guard on isNaN not falsiness + let maxCount = parseInt(searchParams.get(`${prefix}.maxCount`), 10); + if (isNaN(maxCount)) maxCount = this.maxCount; let offset = offsetFromString(maxRows, searchParams.get(`${prefix}.p`)) ?? DEFAULT_OFFSET; let schemaQuery = new SchemaQuery(this.schemaName, this.queryName, viewName); let selectedReportIds = searchParams.get(`${prefix}.selectedReportIds`)?.split(';') ?? []; @@ -1236,7 +1267,7 @@ export class QueryModel { } } - return { filterArray, maxRows, offset, schemaQuery, selectedReportIds, sorts }; + return { filterArray, maxCount, maxRows, offset, schemaQuery, selectedReportIds, sorts }; } /** @@ -1253,7 +1284,7 @@ export class QueryModel { type QueryModelURLState = Pick< QueryModel, - 'filterArray' | 'maxRows' | 'offset' | 'schemaQuery' | 'selectedReportIds' | 'sorts' + 'filterArray' | 'maxCount' | 'maxRows' | 'offset' | 'schemaQuery' | 'selectedReportIds' | 'sorts' >; type QueryModelSettings = Partial>; const LOCAL_STORAGE_PREFIX = 'QUERY_MODEL_SETTINGS'; diff --git a/packages/components/src/public/QueryModel/SelectionStatus.test.tsx b/packages/components/src/public/QueryModel/SelectionStatus.test.tsx index aca9846d58..f0b83b19bf 100644 --- a/packages/components/src/public/QueryModel/SelectionStatus.test.tsx +++ b/packages/components/src/public/QueryModel/SelectionStatus.test.tsx @@ -137,4 +137,39 @@ describe('SelectionStatus', () => { expect(document.querySelectorAll('.selection-status__select-all')).toHaveLength(1); expect(document.querySelector('.selection-status__select-all')).toHaveTextContent('Select first 100,000'); }); + + test('capped rowCount offers "Select first" even when rowCount does not exceed maxSelectionSize', () => { + // rowCount equals the cap and is not > maxSelectionSize, so only rowCountCapped forces the "first N" label + const model = MODEL_LOADED.mutate({ rowCount: 100_000, rowCountCapped: true }); + renderWithAppContext(, APP_CONTEXT); + expect(document.querySelectorAll('.selection-status__select-all')).toHaveLength(1); + expect(document.querySelector('.selection-status__select-all')).toHaveTextContent('Select first 100,000'); + // a capped total renders with a trailing "+" + expect(document.querySelector('.selection-status__count')).toHaveTextContent('1 of 100,000+ selected'); + }); + + test('capped rowCount still offers select-all when selectionSize equals rowCount', () => { + // selectionSize === rowCount would normally read as "all selected", but with a cap more rows exist beyond it + const selectionSet = []; + for (let i = 0; i < 25; i++) selectionSet.push(i.toString()); + const model = MODEL_LOADED.mutate({ rowCount: 25, rowCountCapped: true, selections: new Set(selectionSet) }); + renderWithAppContext(, APP_CONTEXT); + expect(document.querySelectorAll('.selection-status__select-all')).toHaveLength(1); + expect(document.querySelector('.selection-status__select-all')).toHaveTextContent('Select first 100,000'); + }); + + test('exact rowCount exceeding maxSelectionSize shows no "+" in the count', () => { + // Once the exact total is known (rowCountCapped false), the total is precise even though it exceeds the + // selection cap, so the count must not render a trailing "+". + const smallLimitContext = { serverContext: { moduleContext: { query: { maxQuerySelection: 3 } } } }; + const model = MODEL_LOADED.mutate({ + rowCount: 5, + rowCountCapped: false, + selections: new Set(['1', '2', '3']), + }); + renderWithAppContext(, smallLimitContext); + expect(document.querySelector('.selection-status__count')).toHaveTextContent('3 of 5 selected'); + // the whole selectable set is already selected, so no select-all button + expect(document.querySelectorAll('.selection-status__select-all')).toHaveLength(0); + }); }); diff --git a/packages/components/src/public/QueryModel/SelectionStatus.tsx b/packages/components/src/public/QueryModel/SelectionStatus.tsx index be673337c5..478962d835 100644 --- a/packages/components/src/public/QueryModel/SelectionStatus.tsx +++ b/packages/components/src/public/QueryModel/SelectionStatus.tsx @@ -9,7 +9,7 @@ import { RequiresModelAndActions } from './withQueryModels'; import { useServerContext } from '../../internal/components/base/ServerContext'; export const SelectionStatus: FC = memo(({ actions, model }) => { - const { isLoading, isLoadingSelections, isLoadingTotalCount, maxRows, rowCount, selections } = model; + const { isLoading, isLoadingSelections, isLoadingTotalCount, maxRows, rowCount, rowCountCapped, selections } = model; const selectionSize = selections?.size; const { moduleContext } = useServerContext(); const maxSelectionSize = moduleContext?.query?.maxQuerySelection; @@ -42,11 +42,19 @@ export const SelectionStatus: FC = memo(({ actions, mod let clearAllButton; let selectAllButton; + const tooManyRows = rowCountCapped || rowCount > maxSelectionSize; + if (selectionSize > 0) { selectionCount = ( {selectionSize.toLocaleString()} of{' '} - {isLoadingTotalCount ? : rowCount?.toLocaleString()} selected + {isLoadingTotalCount ? ( + + ) : ( + // "+" means the total is a capped floor; an exact count keeps no "+" even when it exceeds maxSelectionSize + `${rowCount?.toLocaleString() ?? ''}${rowCountCapped ? '+' : ''}` + )}{' '} + selected ); @@ -61,12 +69,11 @@ export const SelectionStatus: FC = memo(({ actions, mod if ( rowCount > maxRows && - selectionSize !== rowCount && + (rowCountCapped || selectionSize !== rowCount) && rowCount > 0 && !isLoadingTotalCount && selectionSize < maxSelectionSize ) { - const tooManyRows = rowCount > maxSelectionSize; selectAllButton = (