diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index faef0eb9b5..161ab60f06 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.64.0", + "version": "6.64.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.64.0", + "version": "6.64.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 25ccecc6ef..5a994324eb 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.64.0", + "version": "6.64.1", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 84972f70bd..2df555e130 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,10 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 6.64.1 +*Released*: 9 October 2025 +- Issue 53997: Establish a maximum size for query selections + ### version 6.64.0 *Released*: 9 October 2025 - AssayDefinitionModel: add plateEnabled flag diff --git a/packages/components/src/internal/components/pagination/PageMenu.test.tsx b/packages/components/src/internal/components/pagination/PageMenu.test.tsx index 0576955a78..7f47a3de6b 100644 --- a/packages/components/src/internal/components/pagination/PageMenu.test.tsx +++ b/packages/components/src/internal/components/pagination/PageMenu.test.tsx @@ -54,7 +54,6 @@ describe('PageMenu', () => { } }; - // eslint-disable-next-line jest/expect-expect -- this test does use expect, via expectPageMenuItems test('render', () => { const { rerender } = render(); expectPageMenuItems(false, false, false, '2', '34 Total Pages'); diff --git a/packages/components/src/internal/components/pagination/PageMenu.tsx b/packages/components/src/internal/components/pagination/PageMenu.tsx index 0ecec706fe..9a656d4819 100644 --- a/packages/components/src/internal/components/pagination/PageMenu.tsx +++ b/packages/components/src/internal/components/pagination/PageMenu.tsx @@ -32,7 +32,7 @@ export const PageMenu: FC = props => { pageSizes, setPageSize, } = props; - const totalPagesText = disabled ? '...' : `${pageCount} Total Pages`; + const totalPagesText = disabled ? '...' : `${pageCount.toLocaleString()} Total Pages`; // We have to manually wire up a Tooltip here because we're rendering PageMenu within a btn-group so any extra // wrapping elements cause it to render incorrectly. const { onMouseEnter, onMouseLeave, portalEl, show, targetRef } = useOverlayTriggerState( @@ -51,11 +51,11 @@ export const PageMenu: FC = props => { @@ -68,8 +68,8 @@ export const PageMenu: FC = props => { {pageSizes?.map(size => ( - setPageSize(size)}> - {size} + setPageSize(size)}> + {size.toLocaleString()} ))} {show && createPortal(tooltip, portalEl)} diff --git a/packages/components/src/public/QueryModel/GridPanel.tsx b/packages/components/src/public/QueryModel/GridPanel.tsx index 65ea302a82..ac80e6f2a1 100644 --- a/packages/components/src/public/QueryModel/GridPanel.tsx +++ b/packages/components/src/public/QueryModel/GridPanel.tsx @@ -18,11 +18,11 @@ import { HeaderCellDropdown, HeaderSelectionCell, isFilterColumnNameMatch } from import { getGridView, + incrementClientSideMetricCount, revertViewEdit, - saveGridView, saveAsSessionView, + saveGridView, saveSessionView, - incrementClientSideMetricCount, } from '../../internal/actions'; import { hasServerContext, useServerContext } from '../../internal/components/base/ServerContext'; @@ -77,15 +77,15 @@ import { Actions, InjectedQueryModels, RequiresModelAndActions, withQueryModels import { ChartPanel } from './ChartPanel'; export interface GridPanelProps { - ButtonsComponent?: ComponentType; - ButtonsComponentRight?: ComponentType; - advancedExportOptions?: { [key: string]: any }; + advancedExportOptions?: Record; allowFiltering?: boolean; allowSelections?: boolean; allowSorting?: boolean; allowViewCustomization?: boolean; asPanel?: boolean; + ButtonsComponent?: ComponentType; buttonsComponentProps?: ButtonsComponentProps; + ButtonsComponentRight?: ComponentType; emptyText?: string; getEmptyText?: (model: QueryModel) => string; getFilterDisplayValue?: (columnName: string, rawValue: string) => string; @@ -93,13 +93,13 @@ export interface GridPanelProps { hideEmptyViewMenu?: boolean; highlightLastSelectedRow?: boolean; loadOnMount?: boolean; - onExport?: { [key: string]: (modelId?: string) => any }; + onExport?: Record any>; pageSizes?: number[]; showButtonBar?: boolean; showChartMenu?: boolean; showExport?: boolean; - showFilterStatus?: boolean; showFiltersButton?: boolean; + showFilterStatus?: boolean; showHeader?: boolean; showPagination?: boolean; showSearchInput?: boolean; @@ -174,9 +174,10 @@ class ButtonBar extends PureComponent> { supportedExportTypes, } = this.props; - const { hasData, queryInfo, queryInfoError, rowCount, rowsError, selectionsError } = model; - const hasError = queryInfoError !== undefined || rowsError !== undefined || selectionsError !== undefined; - const paginate = showPagination && rowCount > 0 && !hasError; + const { hasData, queryInfo, rowCount, rowsError, selectionsError } = model; + const hasLoadErrors = model.hasLoadErrors; + const hasError = hasLoadErrors || selectionsError !== undefined; + const paginate = showPagination && rowCount > 0 && !hasLoadErrors; const canExport = showExport && !hasError; // Don't disable view selection when there is an error because it's possible the error may be caused by the view const canSelectView = showViewMenu && queryInfo !== undefined; @@ -190,10 +191,10 @@ class ButtonBar extends PureComponent> { const paginationComp = ( @@ -205,7 +206,7 @@ class ButtonBar extends PureComponent> {
{showButtonsComponent && ( - + )}
@@ -221,26 +222,26 @@ class ButtonBar extends PureComponent> { {canExport && ( )} {showChartMenu && } {canSelectView && ( )} {ButtonsComponentRight !== undefined && ( - + )}
@@ -259,7 +260,7 @@ class ButtonBar extends PureComponent> {
- {showFiltersButton && } + {showFiltersButton && } {showSearchInput && }
@@ -362,7 +363,7 @@ export const GridTitle: FC = memo(props => { )} {showSave && canSaveCurrent && ( - + Save as... @@ -974,7 +975,7 @@ export class GridPanel extends PureComponent, State> { checked={selected === true} className="grid-panel__row-checkbox" disabled={isLoading || isLoadingSelections} - onChange={this.selectRow.bind(this, row)} // eslint-disable-line + onChange={this.selectRow.bind(this, row)} type="checkbox" /> ); @@ -1117,16 +1118,16 @@ export class GridPanel extends PureComponent, State> { <>
extends PureComponent, State> { )} @@ -1156,7 +1157,7 @@ export class GridPanel extends PureComponent, State> {
)} - {allowSelections && } + {allowSelections && } {showFilterStatus && ( extends PureComponent, State> { {hasData && ( )}
@@ -1200,17 +1201,17 @@ export class GridPanel extends PureComponent, State> { {showFilterModalFieldKey && ( )} {showSaveViewModal && ( @@ -1225,10 +1226,10 @@ export class GridPanel extends PureComponent, State> { )} {showManageViewsModal && ( )} diff --git a/packages/components/src/public/QueryModel/SelectionStatus.test.tsx b/packages/components/src/public/QueryModel/SelectionStatus.test.tsx index cd2957d337..61334cc24e 100644 --- a/packages/components/src/public/QueryModel/SelectionStatus.test.tsx +++ b/packages/components/src/public/QueryModel/SelectionStatus.test.tsx @@ -26,6 +26,10 @@ describe('SelectionStatus', () => { }); const ACTIONS = makeTestActions(); + beforeEach(() => { + LABKEY.moduleContext.query = { maxQuerySelection: 100_000 }; + }); + test('loading', () => { render(); expect(document.querySelectorAll('.selection-status')).toHaveLength(0); @@ -50,7 +54,7 @@ describe('SelectionStatus', () => { expect(document.querySelectorAll('.selection-status__count')).toHaveLength(0); expect(document.querySelectorAll('.selection-status__clear-all')).toHaveLength(0); expect(document.querySelectorAll('.selection-status__select-all')).toHaveLength(1); - expect(document.querySelector('.selection-status__select-all').textContent).toBe('Select all 21'); + expect(document.querySelector('.selection-status__select-all')).toHaveTextContent('Select all 21'); }); test('no selections, rowCount greater than maxRows but isLoadingTotalCount', () => { @@ -63,17 +67,16 @@ describe('SelectionStatus', () => { expect(document.querySelectorAll('.selection-status')).toHaveLength(1); expect(document.querySelectorAll('.selection-status__count')).toHaveLength(0); expect(document.querySelectorAll('.selection-status__clear-all')).toHaveLength(0); - expect(document.querySelectorAll('.selection-status__select-all')).toHaveLength(1); - expect(document.querySelector('.selection-status__select-all').textContent).toBe('Select all '); + expect(document.querySelectorAll('.selection-status__select-all')).toHaveLength(0); }); test('has selection, rowCount less than maxRows', () => { render(); expect(document.querySelectorAll('.selection-status')).toHaveLength(1); expect(document.querySelectorAll('.selection-status__count')).toHaveLength(1); - expect(document.querySelector('.selection-status__count').textContent).toBe('1 of 1 selected'); + expect(document.querySelector('.selection-status__count')).toHaveTextContent('1 of 1 selected'); expect(document.querySelectorAll('.selection-status__clear-all')).toHaveLength(1); - expect(document.querySelector('.selection-status__clear-all').textContent).toBe('Clear'); + expect(document.querySelector('.selection-status__clear-all')).toHaveTextContent('Clear'); expect(document.querySelectorAll('.selection-status__select-all')).toHaveLength(0); }); @@ -82,25 +85,27 @@ describe('SelectionStatus', () => { render(); expect(document.querySelectorAll('.selection-status')).toHaveLength(1); expect(document.querySelectorAll('.selection-status__count')).toHaveLength(1); - expect(document.querySelector('.selection-status__count').textContent).toBe('1 of 21 selected'); + expect(document.querySelector('.selection-status__count')).toHaveTextContent('1 of 21 selected'); expect(document.querySelectorAll('.selection-status__clear-all')).toHaveLength(1); - expect(document.querySelector('.selection-status__clear-all').textContent).toBe('Clear'); + expect(document.querySelector('.selection-status__clear-all')).toHaveTextContent('Clear'); expect(document.querySelectorAll('.selection-status__select-all')).toHaveLength(1); - expect(document.querySelector('.selection-status__select-all').textContent).toBe('Select all 21'); + expect(document.querySelector('.selection-status__select-all')).toHaveTextContent('Select all 21'); }); test('has selections, rowCount greater than large maxRows', () => { const selectionSet = []; - for (let i = 0; i < 1031; i++) selectionSet.push(i.toString()); + for (let i = 0; i < 1031; i++) { + selectionSet.push(i.toString()); + } const model = MODEL_LOADED.mutate({ rowCount: 41321, selections: new Set(selectionSet) }); render(); expect(document.querySelectorAll('.selection-status')).toHaveLength(1); expect(document.querySelectorAll('.selection-status__count')).toHaveLength(1); - expect(document.querySelector('.selection-status__count').textContent).toBe('1,031 of 41,321 selected'); + expect(document.querySelector('.selection-status__count')).toHaveTextContent('1,031 of 41,321 selected'); expect(document.querySelectorAll('.selection-status__clear-all')).toHaveLength(1); - expect(document.querySelector('.selection-status__clear-all').textContent).toBe('Clear all'); + expect(document.querySelector('.selection-status__clear-all')).toHaveTextContent('Clear all'); expect(document.querySelectorAll('.selection-status__select-all')).toHaveLength(1); - expect(document.querySelector('.selection-status__select-all').textContent).toBe('Select all 41,321'); + expect(document.querySelector('.selection-status__select-all')).toHaveTextContent('Select all 41,321'); }); test('has selections, rowCount greater than maxRows but isLoadingTotalCount', () => { @@ -108,10 +113,16 @@ describe('SelectionStatus', () => { render(); expect(document.querySelectorAll('.selection-status')).toHaveLength(1); expect(document.querySelectorAll('.selection-status__count')).toHaveLength(1); - expect(document.querySelector('.selection-status__count').textContent).toBe('1 of selected'); + expect(document.querySelector('.selection-status__count')).toHaveTextContent('1 of selected'); expect(document.querySelectorAll('.selection-status__clear-all')).toHaveLength(1); - expect(document.querySelector('.selection-status__clear-all').textContent).toBe('Clear'); + expect(document.querySelector('.selection-status__clear-all')).toHaveTextContent('Clear'); + expect(document.querySelectorAll('.selection-status__select-all')).toHaveLength(0); + }); + + test('selection loaded, rowCount greater than maxRows and maxSelectionSize', () => { + const model = MODEL_LOADED.mutate({ rowCount: 1_115_000 }); + render(); expect(document.querySelectorAll('.selection-status__select-all')).toHaveLength(1); - expect(document.querySelector('.selection-status__select-all').textContent).toBe('Select all '); + expect(document.querySelector('.selection-status__select-all')).toHaveTextContent('Select first 100,000'); }); }); diff --git a/packages/components/src/public/QueryModel/SelectionStatus.tsx b/packages/components/src/public/QueryModel/SelectionStatus.tsx index b3e2088c39..9d6baf980b 100644 --- a/packages/components/src/public/QueryModel/SelectionStatus.tsx +++ b/packages/components/src/public/QueryModel/SelectionStatus.tsx @@ -1,5 +1,7 @@ import React, { FC, memo, useCallback, useMemo } from 'react'; +import { getServerContext } from '@labkey/api'; + import { LoadingSpinner } from '../../internal/components/base/LoadingSpinner'; import { RequiresModelAndActions } from './withQueryModels'; @@ -7,6 +9,7 @@ import { RequiresModelAndActions } from './withQueryModels'; export const SelectionStatus: FC = memo(({ actions, model }) => { const { isLoading, isLoadingSelections, isLoadingTotalCount, maxRows, rowCount, selections } = model; const selectionSize = selections?.size; + const maxSelectionSize = useMemo(() => getServerContext().moduleContext?.query?.maxQuerySelection, []); const clearSelections = useCallback((): void => { actions.clearSelections(model.id); @@ -53,11 +56,19 @@ export const SelectionStatus: FC = memo(({ actions, mod ); } - if (rowCount > maxRows && selectionSize !== rowCount && rowCount > 0) { + if ( + rowCount > maxRows && + selectionSize !== rowCount && + rowCount > 0 && + !isLoadingTotalCount && + selectionSize < maxSelectionSize + ) { + const tooManyRows = rowCount > maxSelectionSize; selectAllButton = ( ); diff --git a/packages/components/src/public/QueryModel/withQueryModels.tsx b/packages/components/src/public/QueryModel/withQueryModels.tsx index b80edcb985..5dde0e0a45 100644 --- a/packages/components/src/public/QueryModel/withQueryModels.tsx +++ b/packages/components/src/public/QueryModel/withQueryModels.tsx @@ -482,6 +482,7 @@ export function withQueryModels( console.error(`Error setting selections for model ${id}:`, selectionsError); model.selectionsError = selectionsError; + model.selectionsLoadingState = LoadingState.LOADED; removeSettingsFromLocalStorage(this.state.queryModels[id]); }) ); @@ -708,7 +709,9 @@ export function withQueryModels( this.setState( produce((draft: WritableDraft) => { - draft.queryModels[id].rowsLoadingState = LoadingState.LOADING; + const model = draft.queryModels[id]; + model.rowsLoadingState = LoadingState.LOADING; + model.selectionsError = undefined; }) );