diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index b598634bd1..dc0789f535 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.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.62.3", + "version": "7.62.4-fb-limitMaxCount.9", "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..9235c76c55 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.9", "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/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..cdf8095406 100644 --- a/packages/components/src/internal/components/pagination/PageMenu.test.tsx +++ b/packages/components/src/internal/components/pagination/PageMenu.test.tsx @@ -72,6 +72,45 @@ describe('PageMenu', () => { expectPageMenuItems(false, false, true, '34', '34 Total Pages'); }); + test('capped rowCount keeps "Last Page" enabled and drops the total-pages footer', () => { + render(); + expect(screen.getByText('First Page')).toBeInTheDocument(); + // Last Page stays enabled while capped (even with isLastPage true) so there's always a way to reach the last row + const last = screen.getByText('Last Page').parentElement; + expect(last).not.toHaveClass('disabled'); + // no fake "Page N" jump, and the total-pages footer is hidden because the true total is unknown + expect(screen.queryByText('Page 34')).not.toBeInTheDocument(); + expect(screen.queryByText('34 Total Pages')).not.toBeInTheDocument(); + }); + + test('capped rowCount shows "Count All Rows" only when a handler is provided', () => { + const { rerender } = render(); + expect(screen.queryByText('Count All Rows')).not.toBeInTheDocument(); + + const onShowTotalRowCount = jest.fn(); + rerender(); + expect(screen.getByText('Count All Rows')).toBeInTheDocument(); + + // not shown when the count is exact + rerender(); + expect(screen.queryByText('Count All Rows')).not.toBeInTheDocument(); + }); + + test('Count All Rows shows a spinner and is disabled while counting', () => { + render(); + // the spinner replaces the label, and the item is present but disabled + expect(screen.queryByText('Count All Rows')).not.toBeInTheDocument(); + expect(screen.getByText('Counting…')).toBeInTheDocument(); + expect(screen.getByText('Counting…').closest('li')).toHaveClass('disabled'); + }); + + test('Count All Rows invokes the handler when clicked', async () => { + const onShowTotalRowCount = jest.fn(); + render(); + await userEvent.click(screen.getByText('Count All Rows')); + expect(onShowTotalRowCount).toHaveBeenCalled(); + }); + 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..febf91c8f6 100644 --- a/packages/components/src/internal/components/pagination/PageMenu.tsx +++ b/packages/components/src/internal/components/pagination/PageMenu.tsx @@ -9,6 +9,7 @@ import { createPortal } from 'react-dom'; import { DropdownButton, MenuDivider, MenuHeader, MenuItem } from '../../dropdowns'; import { useOverlayTriggerState } from '../../OverlayTrigger'; import { Tooltip } from '../../Tooltip'; +import { LoadingSpinner } from '../base/LoadingSpinner'; interface Props { currentPage: number; @@ -17,9 +18,12 @@ interface Props { isLastPage: boolean; loadFirstPage: () => void; loadLastPage: () => void; + loadingTotalCount?: boolean; + onShowTotalRowCount?: () => void; pageCount: number; pageSize: number; pageSizes: number[]; + rowCountCapped?: boolean; setPageSize: (size: number) => void; } @@ -31,9 +35,12 @@ export const PageMenu: FC = props => { isLastPage, loadFirstPage, loadLastPage, + loadingTotalCount, + onShowTotalRowCount, pageCount, pageSize, pageSizes, + rowCountCapped, setPageSize, } = props; const totalPagesText = disabled ? '...' : `${pageCount.toLocaleString()} Total Pages`; @@ -68,7 +75,12 @@ export const PageMenu: FC = props => { Last Page - + {rowCountCapped && onShowTotalRowCount && ( + + {loadingTotalCount ? : 'Count All Rows'} + + )} + {!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..8454233f0a 100644 --- a/packages/components/src/internal/components/pagination/Pagination.tsx +++ b/packages/components/src/internal/components/pagination/Pagination.tsx @@ -4,7 +4,7 @@ */ import React, { FC, memo, useCallback } from 'react'; -import { LoadingState } from '../../../public/LoadingState'; +import { isLoading, LoadingState } from '../../../public/LoadingState'; import { incrementClientSideMetricCount } from '../../actions'; @@ -21,6 +21,7 @@ export interface PaginationData { pageCount: number; pageSize: number; rowCount: number; + rowCountCapped?: boolean; totalCountLoadingState?: LoadingState; } @@ -32,6 +33,7 @@ export interface PaginationProps extends PaginationData { // pageSizes is expected to be sorted (ascending) pageSizes?: number[]; setPageSize: (pageSize) => void; + showTotalRowCount?: () => void; } const PAGINATION_METRIC_AREA = 'pagination'; @@ -52,7 +54,9 @@ export const Pagination: FC = memo(props => { pageSize, pageSizes = DEFAULT_PAGE_SIZES, rowCount, + rowCountCapped, setPageSize, + showTotalRowCount, totalCountLoadingState, } = props; const hasPages = rowCount > pageSizes[0]; @@ -90,6 +94,11 @@ export const Pagination: FC = memo(props => { [setPageSize] ); + const onShowTotalRowCount = useCallback(() => { + incrementClientSideMetricCount(PAGINATION_METRIC_AREA, 'showTotalRowCount'); + showTotalRowCount?.(); + }, [showTotalRowCount]); + // Use lk-pagination so we don't conflict with bootstrap pagination class. return (
@@ -97,6 +106,7 @@ export const Pagination: FC = memo(props => { offset={offset} pageSize={pageSize} rowCount={rowCount} + rowCountCapped={rowCountCapped} totalCountLoadingState={totalCountLoadingState} /> @@ -116,8 +126,11 @@ export const Pagination: FC = memo(props => { isFirstPage={isFirstPage} isLastPage={isLastPage} pageCount={pageCount} + rowCountCapped={rowCountCapped} loadFirstPage={onLoadFirstPage} loadLastPage={onLoadLastPage} + loadingTotalCount={isLoading(totalCountLoadingState)} + onShowTotalRowCount={showTotalRowCount ? onShowTotalRowCount : undefined} pageSize={pageSize} pageSizes={pageSizes} setPageSize={onSetPageSize} 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 extends PureComponent> { actions.setMaxRows(model.id, pageSize); }; + showTotalRowCount = (): void => { + const { model, actions } = this.props; + actions.loadTotalCount(model.id, true, true); + }; + render(): ReactNode { const { searchActionValues, @@ -204,6 +209,7 @@ class ButtonBar extends PureComponent> { loadPreviousPage={this.loadPreviousPage} pageSizes={pageSizes} setPageSize={this.setPageSize} + showTotalRowCount={this.showTotalRowCount} /> ); diff --git a/packages/components/src/public/QueryModel/QueryModel.test.ts b/packages/components/src/public/QueryModel/QueryModel.test.ts index bfb8c2ff8f..9cccd07386 100644 --- a/packages/components/src/public/QueryModel/QueryModel.test.ts +++ b/packages/components/src/public/QueryModel/QueryModel.test.ts @@ -20,6 +20,7 @@ import { getQueryParams } from '../../internal/util/URL'; import { createQueryModelId, + DEFAULT_MAX_COUNT, DEFAULT_MAX_ROWS, DEFAULT_OFFSET, flattenValuesFromRow, @@ -117,6 +118,52 @@ describe('QueryModel', () => { 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, diff --git a/packages/components/src/public/QueryModel/QueryModel.ts b/packages/components/src/public/QueryModel/QueryModel.ts index a942435e5f..e21fabdaa5 100644 --- a/packages/components/src/public/QueryModel/QueryModel.ts +++ b/packages/components/src/public/QueryModel/QueryModel.ts @@ -169,6 +169,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). + * When the grid has more that this number of rows it will show the max as N+. 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 +240,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 +322,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 +422,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 +515,7 @@ export class QueryModel { this.includeUpdateColumn = queryConfig.includeUpdateColumn ?? false; this.includeTotalCount = queryConfig.includeTotalCount ?? false; this.keyValue = queryConfig.keyValue; + this.maxCount = queryConfig.maxCount ?? DEFAULT_MAX_COUNT; this.maxRows = queryConfig.maxRows ?? DEFAULT_MAX_ROWS; this.offset = queryConfig.offset ?? DEFAULT_OFFSET; this.omittedColumns = queryConfig.omittedColumns ?? []; @@ -516,6 +532,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 +1049,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 +1119,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 +1144,7 @@ export class QueryModel { pageCount: this.pageCount, pageSize: this.maxRows, rowCount: this.rowCount, + rowCountCapped: this.rowCountCapped, totalCountLoadingState: this.totalCountLoadingState, }; } @@ -1157,6 +1179,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), 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 = (
+ ); + }; + const ModelStateWithQueryModels = withQueryModels(ModelState); + + function textOf(selector: string): string { + return document.querySelector(selector).textContent; + } + + beforeEach(() => { + jest.mocked(selectRows).mockReset(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('re-fires an exact count (maxCount=0) when the user pages to the edge of a capped count', async () => { + // First count comes back capped at 100; the exact re-fire finds the true total of 151 + jest.mocked(selectRows) + .mockResolvedValueOnce({ rowCount: 100, rowCountCapped: true } as any) + .mockResolvedValue({ rowCount: 151, rowCountCapped: false } as any); + + render( + + ); + + await waitFor(() => expect(textOf('.row-count-capped')).toBe('true')); + expect(textOf('.row-count')).toBe('100'); + // The initial count request used the configured cap + expect(jest.mocked(selectRows).mock.calls[0][0]).toEqual(expect.objectContaining({ maxCount: 100 })); + + await userEvent.click(document.querySelector('.to-cap-edge')); + + // Paging to the cap edge re-fires with an exact count and the true total replaces the capped floor + await waitFor(() => expect(textOf('.row-count-capped')).toBe('false')); + expect(textOf('.row-count')).toBe('151'); + expect(jest.mocked(selectRows).mock.calls[1][0]).toEqual(expect.objectContaining({ maxCount: 0 })); + }); + + it('Last Page resolves the exact count then jumps to the true last page when capped', async () => { + // Capped at 100; the exact count reveals 151 rows → last page offset is 140 (maxRows 20) + jest.mocked(selectRows) + .mockResolvedValueOnce({ rowCount: 100, rowCountCapped: true } as any) + .mockResolvedValue({ rowCount: 151, rowCountCapped: false } as any); + + render( + + ); + + await waitFor(() => expect(textOf('.row-count-capped')).toBe('true')); + expect(textOf('.offset')).toBe('0'); + + await userEvent.click(document.querySelector('.to-last-page')); + + // The exact count is fetched (maxCount=0) and the offset lands on the real last page (140, not the capped 80) + await waitFor(() => expect(textOf('.row-count-capped')).toBe('false')); + expect(textOf('.row-count')).toBe('151'); + expect(textOf('.offset')).toBe('140'); + expect(jest.mocked(selectRows).mock.calls[1][0]).toEqual(expect.objectContaining({ maxCount: 0 })); + }); + + it('Count All Rows fetches the exact count without navigating', async () => { + jest.mocked(selectRows) + .mockResolvedValueOnce({ rowCount: 100, rowCountCapped: true } as any) + .mockResolvedValue({ rowCount: 151, rowCountCapped: false } as any); + + render( + + ); + + await waitFor(() => expect(textOf('.row-count-capped')).toBe('true')); + + await userEvent.click(document.querySelector('.count-all')); + + // Exact count replaces the capped floor, but the offset does not move + await waitFor(() => expect(textOf('.row-count-capped')).toBe('false')); + expect(textOf('.row-count')).toBe('151'); + expect(textOf('.offset')).toBe('0'); + expect(jest.mocked(selectRows).mock.calls[1][0]).toEqual(expect.objectContaining({ maxCount: 0 })); + }); +}); diff --git a/packages/components/src/public/QueryModel/withQueryModels.tsx b/packages/components/src/public/QueryModel/withQueryModels.tsx index 9fa60c3f9b..ca5a075c4d 100644 --- a/packages/components/src/public/QueryModel/withQueryModels.tsx +++ b/packages/components/src/public/QueryModel/withQueryModels.tsx @@ -124,6 +124,7 @@ export interface Actions { loadNextPage: (id: string) => void; loadPreviousPage: (id: string) => void; loadRows: (id: string) => void; + loadTotalCount: (id: string, reloadTotalCount?: boolean, forceExact?: boolean) => void; onModelChange: (id: string, modelChange: ModelChange) => void; replaceSelections: (id: string, selections: string[]) => void; resetTotalCountState: () => void; @@ -182,6 +183,7 @@ const resetQueryInfoState = (model: Draft): void => { */ const resetTotalCountState = (model: Draft): void => { model.rowCount = undefined; + model.rowCountCapped = false; model.totalCountError = undefined; model.totalCountLoadingState = LoadingState.INITIALIZED; }; @@ -428,6 +430,7 @@ export function withQueryModels( loadFirstPage: this.loadFirstPage, loadLastPage: this.loadLastPage, loadCharts: this.loadCharts, + loadTotalCount: this.loadTotalCount, onModelChange: this.onModelChange, replaceSelections: this.replaceSelections, resetTotalCountState: this.resetTotalCountState, @@ -923,14 +926,21 @@ export function withQueryModels( } }; - loadTotalCount = async (id: string, reloadTotalCount = false): Promise => { + loadTotalCount = async (id: string, reloadTotalCount = false, forceExact = false): Promise => { // Issue 53192 if (!this.state.queryModels[id].isQueryInfoLoaded) { return; } - // if we've already loaded the totalCount, no need to load it again - if (!reloadTotalCount && this.state.queryModels[id].totalCountLoadingState === LoadingState.LOADED) { + const model = this.state.queryModels[id]; + + const haveExactCount = model.rowCount !== undefined && !model.rowCountCapped; + const needsExactCount = + forceExact || + (!haveExactCount && model.maxCount > 0 && model.offset + model.maxRows >= model.maxCount); + + // if we've already loaded the totalCount, no need to load it again (unless we now need an exact count) + if (!reloadTotalCount && !needsExactCount && model.totalCountLoadingState === LoadingState.LOADED) { return; } @@ -961,13 +971,14 @@ export function withQueryModels( queryInfo?.getPkCols() ); - const { rowCount } = await selectRows({ + const { rowCount, rowCountCapped } = await selectRows({ ...loadRowsConfig, columns, includeDetailsColumn: false, // includeMetadata: false, // TODO don't require metadata in selectRows response processing includeTotalCount: true, includeUpdateColumn: false, + maxCount: needsExactCount ? 0 : model.maxCount, maxRows: 1, offset: 0, sort: undefined, @@ -978,6 +989,7 @@ export function withQueryModels( produce((draft: WritableDraft) => { const model = draft.queryModels[id]; model.rowCount = rowCount; + model.rowCountCapped = rowCountCapped ?? false; model.totalCountLoadingState = LoadingState.LOADED; model.totalCountError = undefined; }) @@ -1142,7 +1154,11 @@ export function withQueryModels( ); }; - loadLastPage = (id: string): void => { + loadLastPage = async (id: string): Promise => { + if (this.state.queryModels[id].rowCountCapped) { + await this.loadTotalCount(id, true, true); + } + let shouldLoad = false; this.setState( produce((draft: WritableDraft) => {