From 3b240ca5878a13146c33a894a49d36792c4b4e55 Mon Sep 17 00:00:00 2001 From: alanv Date: Sat, 18 Apr 2026 10:43:36 -0500 Subject: [PATCH 01/23] withQueryModels: Move types to QueryModel and utils to utils.ts --- packages/components/src/index.ts | 23 +- packages/components/src/internal/actions.ts | 2 +- .../components/chart/ChartBuilderMenuItem.tsx | 2 +- .../components/chart/ChartBuilderModal.tsx | 3 +- .../domainproperties/DesignerDetailPanel.tsx | 2 +- .../components/gridbar/ExportModal.tsx | 2 +- .../labelPrinting/PrintLabelsModal.tsx | 4 +- .../lineage/node/LineageNodeDetail.tsx | 4 +- .../components/samples/SampleStatusLegend.tsx | 3 +- .../internal/components/user/APIKeysPanel.tsx | 4 +- .../components/user/UsersGridPanel.tsx | 4 +- .../src/public/QueryModel/ChartMenu.tsx | 2 +- .../src/public/QueryModel/ChartPanel.tsx | 2 +- .../src/public/QueryModel/DetailPanel.tsx | 4 +- .../public/QueryModel/EditableDetailPanel.tsx | 4 +- .../src/public/QueryModel/ExportMenu.tsx | 3 +- .../src/public/QueryModel/GridPanel.tsx | 13 +- .../src/public/QueryModel/QueryModel.ts | 85 +++++ .../src/public/QueryModel/SelectionStatus.tsx | 2 +- .../src/public/QueryModel/TabbedGridPanel.tsx | 3 +- .../src/public/QueryModel/testUtils.ts | 3 +- .../components/src/public/QueryModel/utils.ts | 212 +++++++++++- .../src/public/QueryModel/withQueryModels.tsx | 311 ++---------------- 23 files changed, 367 insertions(+), 330 deletions(-) diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 48919be9ef..dca08f2221 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -624,9 +624,9 @@ import { wrapDraggable, } from './internal/test/testHelpers'; import { renderWithAppContext } from './internal/test/reactTestLibraryHelpers'; -import { flattenValuesFromRow, QueryModel, SavedSettings } from './public/QueryModel/QueryModel'; +import { ChangeType, flattenValuesFromRow, QueryModel, SavedSettings } from './public/QueryModel/QueryModel'; import { getExpandQueryInfo, includedColumnsForCustomizationFilter } from './public/QueryModel/CustomizeGridViewModal'; -import { ChangeType, withQueryModels } from './public/QueryModel/withQueryModels'; +import { withQueryModels } from './public/QueryModel/withQueryModels'; import { GridPanel, GridPanelWithModel } from './public/QueryModel/GridPanel'; import { TabbedGridPanel } from './public/QueryModel/TabbedGridPanel'; import { DetailPanel, DetailPanelWithModel } from './public/QueryModel/DetailPanel'; @@ -1804,6 +1804,11 @@ export { wrapDraggable, }; +// Due to babel-loader & typescript babel plugins we need to export/import types separately. The babel plugins require +// the typescript compiler option "isolatedModules", which do not export types from modules, so types must be exported +// separately. +// https://github.com/babel/babel-loader/issues/603 + export type { ComponentsAPIWrapper } from './internal/APIWrapper'; export type { AppReducerState, ProductMenuState, ServerNotificationState } from './internal/app/reducers'; export type { @@ -1932,18 +1937,14 @@ export type { ImportTemplate } from './public/QueryInfo'; export type { EditableDetailPanelProps } from './public/QueryModel/EditableDetailPanel'; export type { Action, ActionValue } from './public/QueryModel/grid/actions/Action'; export type { QueryConfig } from './public/QueryModel/QueryModel'; -export type { QueryModelLoader } from './public/QueryModel/QueryModelLoader'; -export type { TabbedGridPanelProps } from './public/QueryModel/TabbedGridPanel'; - -// Due to babel-loader & typescript babel plugins we need to export/import types separately. The babel plugins require -// the typescript compiler option "isolatedModules", which do not export types from modules, so types must be exported -// separately. -// https://github.com/babel/babel-loader/issues/603 export type { Actions, InjectedQueryModels, - MakeQueryModels, QueryConfigMap, QueryModelMap, RequiresModelAndActions, -} from './public/QueryModel/withQueryModels'; +} from './public/QueryModel/QueryModel'; +export type { QueryModelLoader } from './public/QueryModel/QueryModelLoader'; + +export type { TabbedGridPanelProps } from './public/QueryModel/TabbedGridPanel'; +export type { MakeQueryModels } from './public/QueryModel/withQueryModels'; diff --git a/packages/components/src/internal/actions.ts b/packages/components/src/internal/actions.ts index 77a10200dd..bf3ee1ffcc 100644 --- a/packages/components/src/internal/actions.ts +++ b/packages/components/src/internal/actions.ts @@ -18,7 +18,7 @@ import { ActionURL, Ajax, Filter, getServerContext, Query, Utils } from '@labkey import { SchemaQuery } from '../public/SchemaQuery'; -import { Actions } from '../public/QueryModel/withQueryModels'; +import { Actions } from '../public/QueryModel/QueryModel'; import { GridResponse } from './components/editable/models'; diff --git a/packages/components/src/internal/components/chart/ChartBuilderMenuItem.tsx b/packages/components/src/internal/components/chart/ChartBuilderMenuItem.tsx index c86e7d7ef1..782aaa74d4 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderMenuItem.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderMenuItem.tsx @@ -1,6 +1,6 @@ import React, { FC, memo, useCallback, useState } from 'react'; -import { RequiresModelAndActions } from '../../../public/QueryModel/withQueryModels'; +import { RequiresModelAndActions } from '../../../public/QueryModel/QueryModel'; import { useNotificationsContext } from '../notifications/NotificationsContext'; diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx index f7d77e3711..572dcc5a62 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx @@ -8,8 +8,7 @@ import { Modal } from '../../Modal'; import { LoadingSpinner } from '../base/LoadingSpinner'; -import { QueryModel } from '../../../public/QueryModel/QueryModel'; -import { RequiresModelAndActions } from '../../../public/QueryModel/withQueryModels'; +import { QueryModel, RequiresModelAndActions } from '../../../public/QueryModel/QueryModel'; import { useServerContext } from '../base/ServerContext'; import { hasPermissions } from '../base/models/User'; diff --git a/packages/components/src/internal/components/domainproperties/DesignerDetailPanel.tsx b/packages/components/src/internal/components/domainproperties/DesignerDetailPanel.tsx index 14553ed5b9..ecfdca3bbd 100644 --- a/packages/components/src/internal/components/domainproperties/DesignerDetailPanel.tsx +++ b/packages/components/src/internal/components/domainproperties/DesignerDetailPanel.tsx @@ -4,7 +4,7 @@ import { useAppContext } from '../../AppContext'; import { DetailDisplaySharedProps } from '../forms/detail/DetailDisplay'; -import { RequiresModelAndActions } from '../../../public/QueryModel/withQueryModels'; +import { RequiresModelAndActions } from '../../../public/QueryModel/QueryModel'; import { SchemaQuery } from '../../../public/SchemaQuery'; import { DetailPanel } from '../../../public/QueryModel/DetailPanel'; import { QueryColumn } from '../../../public/QueryColumn'; diff --git a/packages/components/src/internal/components/gridbar/ExportModal.tsx b/packages/components/src/internal/components/gridbar/ExportModal.tsx index 958559467a..3bfa6f3c23 100644 --- a/packages/components/src/internal/components/gridbar/ExportModal.tsx +++ b/packages/components/src/internal/components/gridbar/ExportModal.tsx @@ -2,7 +2,7 @@ import React, { FC, memo, useCallback, useState } from 'react'; import { Modal } from '../../Modal'; -import { QueryModelMap } from '../../../public/QueryModel/withQueryModels'; +import { QueryModelMap } from '../../../public/QueryModel/QueryModel'; import { CheckboxLK } from '../../Checkbox'; interface ExportModalProperties { diff --git a/packages/components/src/internal/components/labelPrinting/PrintLabelsModal.tsx b/packages/components/src/internal/components/labelPrinting/PrintLabelsModal.tsx index 6461e5cd98..d1ea88b621 100644 --- a/packages/components/src/internal/components/labelPrinting/PrintLabelsModal.tsx +++ b/packages/components/src/internal/components/labelPrinting/PrintLabelsModal.tsx @@ -8,8 +8,8 @@ import { QuerySelect } from '../forms/QuerySelect'; import { Alert } from '../base/Alert'; import { LoadingSpinner } from '../base/LoadingSpinner'; -import { InjectedQueryModels, withQueryModels } from '../../../public/QueryModel/withQueryModels'; -import { QueryModel } from '../../../public/QueryModel/QueryModel'; +import { withQueryModels } from '../../../public/QueryModel/withQueryModels'; +import { InjectedQueryModels, QueryModel } from '../../../public/QueryModel/QueryModel'; import { FormButtons } from '../../FormButtons'; diff --git a/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx b/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx index 9151396cfb..4503938fe2 100644 --- a/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx +++ b/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx @@ -25,11 +25,11 @@ import { hasModule } from '../../../app/utils'; import { LineageDetail } from './LineageDetail'; import { DetailHeader, NodeDetailHeader } from './NodeDetailHeader'; import { DetailsListLineageIO, DetailsListNodes, DetailsListSteps } from './DetailsList'; -import { InjectedQueryModels, QueryConfigMap, withQueryModels } from '../../../../public/QueryModel/withQueryModels'; +import { withQueryModels } from '../../../../public/QueryModel/withQueryModels'; import { Filter } from '@labkey/api'; import { SchemaQuery } from '../../../../public/SchemaQuery'; import { ViewInfo } from '../../../ViewInfo'; -import { QueryModel } from '../../../../public/QueryModel/QueryModel'; +import { InjectedQueryModels, QueryConfigMap, QueryModel } from '../../../../public/QueryModel/QueryModel'; import { LINEAGE_DETAIL_REQUIRED_COLS } from '../constants'; diff --git a/packages/components/src/internal/components/samples/SampleStatusLegend.tsx b/packages/components/src/internal/components/samples/SampleStatusLegend.tsx index ffb180aad6..166a029f04 100644 --- a/packages/components/src/internal/components/samples/SampleStatusLegend.tsx +++ b/packages/components/src/internal/components/samples/SampleStatusLegend.tsx @@ -7,7 +7,8 @@ import { LoadingSpinner } from '../base/LoadingSpinner'; import { caseInsensitive } from '../../util/utils'; import { SCHEMAS } from '../../schemas'; -import { InjectedQueryModels, withQueryModels } from '../../../public/QueryModel/withQueryModels'; +import { withQueryModels } from '../../../public/QueryModel/withQueryModels'; +import { InjectedQueryModels } from '../../../public/QueryModel/QueryModel'; import { SampleStatusTag } from './SampleStatusTag'; import { getSampleStatus, getSampleStatusContainerFilter } from './utils'; diff --git a/packages/components/src/internal/components/user/APIKeysPanel.tsx b/packages/components/src/internal/components/user/APIKeysPanel.tsx index effb34d29b..7aabaf308f 100644 --- a/packages/components/src/internal/components/user/APIKeysPanel.tsx +++ b/packages/components/src/internal/components/user/APIKeysPanel.tsx @@ -24,8 +24,8 @@ import { InjectedQueryModels, QueryConfigMap, RequiresModelAndActions, - withQueryModels, -} from '../../../public/QueryModel/withQueryModels'; +} from '../../../public/QueryModel/QueryModel'; +import { withQueryModels } from '../../../public/QueryModel/withQueryModels'; import { SCHEMAS } from '../../schemas'; import { GridPanel } from '../../../public/QueryModel/GridPanel'; import { Modal } from '../../Modal'; diff --git a/packages/components/src/internal/components/user/UsersGridPanel.tsx b/packages/components/src/internal/components/user/UsersGridPanel.tsx index 6c21dda19b..f145758189 100644 --- a/packages/components/src/internal/components/user/UsersGridPanel.tsx +++ b/packages/components/src/internal/components/user/UsersGridPanel.tsx @@ -10,7 +10,7 @@ import { SetURLSearchParams, useSearchParams } from 'react-router-dom'; import { getSelected } from '../../actions'; -import { QueryModel, SavedSettings } from '../../../public/QueryModel/QueryModel'; +import { ChangeType, InjectedQueryModels, QueryModel, SavedSettings } from '../../../public/QueryModel/QueryModel'; import { removeParameters } from '../../util/URL'; import { UserLimitSettings } from '../permissions/actions'; @@ -26,7 +26,7 @@ import { GridPanel } from '../../../public/QueryModel/GridPanel'; import { LoadingSpinner } from '../base/LoadingSpinner'; import { capitalizeFirstChar } from '../../util/utils'; -import { ChangeType, InjectedQueryModels, withQueryModels } from '../../../public/QueryModel/withQueryModels'; +import { withQueryModels } from '../../../public/QueryModel/withQueryModels'; import { MenuDivider, MenuItem } from '../../dropdowns'; diff --git a/packages/components/src/public/QueryModel/ChartMenu.tsx b/packages/components/src/public/QueryModel/ChartMenu.tsx index 10cea491a9..918dc40345 100644 --- a/packages/components/src/public/QueryModel/ChartMenu.tsx +++ b/packages/components/src/public/QueryModel/ChartMenu.tsx @@ -13,7 +13,7 @@ import { isChartBuilderEnabled } from '../../internal/app/utils'; import { ChartBuilderMenuItem } from '../../internal/components/chart/ChartBuilderMenuItem'; import { hasPermissions } from '../../internal/components/base/models/User'; -import { RequiresModelAndActions } from './withQueryModels'; +import { RequiresModelAndActions } from './QueryModel'; import { DisableableMenuItem } from '../../internal/components/samples/DisableableMenuItem'; const MAX_CHARTS = 5; diff --git a/packages/components/src/public/QueryModel/ChartPanel.tsx b/packages/components/src/public/QueryModel/ChartPanel.tsx index c02650e335..91d4ab9eff 100644 --- a/packages/components/src/public/QueryModel/ChartPanel.tsx +++ b/packages/components/src/public/QueryModel/ChartPanel.tsx @@ -13,7 +13,7 @@ import { useServerContext } from '../../internal/components/base/ServerContext'; import { DropdownButton, MenuHeader, MenuItem } from '../../internal/dropdowns'; -import { RequiresModelAndActions } from './withQueryModels'; +import { RequiresModelAndActions } from './QueryModel'; interface Props extends RequiresModelAndActions { api?: ChartAPIWrapper; diff --git a/packages/components/src/public/QueryModel/DetailPanel.tsx b/packages/components/src/public/QueryModel/DetailPanel.tsx index 6edd0ff6f1..31e32c5b3a 100644 --- a/packages/components/src/public/QueryModel/DetailPanel.tsx +++ b/packages/components/src/public/QueryModel/DetailPanel.tsx @@ -22,8 +22,8 @@ import { QueryColumn } from '../QueryColumn'; import { Alert } from '../../internal/components/base/Alert'; import { LoadingSpinner } from '../../internal/components/base/LoadingSpinner'; -import { InjectedQueryModels, RequiresModelAndActions, withQueryModels } from './withQueryModels'; -import { QueryConfig } from './QueryModel'; +import { InjectedQueryModels, QueryConfig, RequiresModelAndActions } from './QueryModel'; +import { withQueryModels } from './withQueryModels'; interface DetailPanelProps extends DetailDisplaySharedProps { editColumns?: QueryColumn[]; diff --git a/packages/components/src/public/QueryModel/EditableDetailPanel.tsx b/packages/components/src/public/QueryModel/EditableDetailPanel.tsx index 80e3dbc32c..ea5fb02b21 100644 --- a/packages/components/src/public/QueryModel/EditableDetailPanel.tsx +++ b/packages/components/src/public/QueryModel/EditableDetailPanel.tsx @@ -18,10 +18,10 @@ import { CommentTextArea } from '../../internal/components/forms/input/CommentTe import { useAppContext } from '../../internal/AppContext'; -import { QueryModel } from './QueryModel'; +import { InjectedQueryModels, QueryModel } from './QueryModel'; import { DetailPanel } from './DetailPanel'; -import { InjectedQueryModels, withQueryModels } from './withQueryModels'; +import { withQueryModels } from './withQueryModels'; import { EDIT_METHOD } from '../../internal/constants'; import { useRouteLeave } from '../../internal/util/RouteLeave'; diff --git a/packages/components/src/public/QueryModel/ExportMenu.tsx b/packages/components/src/public/QueryModel/ExportMenu.tsx index d3e00a97d1..35fe03dd72 100644 --- a/packages/components/src/public/QueryModel/ExportMenu.tsx +++ b/packages/components/src/public/QueryModel/ExportMenu.tsx @@ -8,9 +8,8 @@ import { Tip } from '../../internal/components/base/Tip'; import { DropdownButton, MenuDivider, MenuHeader, MenuItem } from '../../internal/dropdowns'; -import { QueryModel } from './QueryModel'; +import { Actions, QueryModel } from './QueryModel'; import { getQueryModelExportParams } from './utils'; -import { Actions } from './withQueryModels'; import { SelectionMenuItem } from '../../internal/components/menus/SelectionMenuItem'; interface ExportMenuProps { diff --git a/packages/components/src/public/QueryModel/GridPanel.tsx b/packages/components/src/public/QueryModel/GridPanel.tsx index b72ec7ef1b..93c1991c0d 100644 --- a/packages/components/src/public/QueryModel/GridPanel.tsx +++ b/packages/components/src/public/QueryModel/GridPanel.tsx @@ -60,7 +60,14 @@ import { ViewAction } from './grid/actions/View'; import { getSearchValueAction } from './grid/utils'; import { Change, ChangeType } from './grid/model'; -import { createQueryModelId, QueryConfig, QueryModel } from './QueryModel'; +import { + Actions, + createQueryModelId, + InjectedQueryModels, + QueryConfig, + QueryModel, + RequiresModelAndActions, +} from './QueryModel'; import { ViewMenu } from './ViewMenu'; import { ExportMenu } from './ExportMenu'; import { SelectionStatus } from './SelectionStatus'; @@ -73,8 +80,8 @@ import { FilterStatus } from './FilterStatus'; import { SaveViewModal } from './SaveViewModal'; import { CustomizeGridViewModal } from './CustomizeGridViewModal'; import { ManageViewsModal } from './ManageViewsModal'; -import { Actions, InjectedQueryModels, RequiresModelAndActions, withQueryModels } from './withQueryModels'; -import { ChartList, ChartPanel } from './ChartPanel'; +import { withQueryModels } from './withQueryModels'; +import { ChartList } from './ChartPanel'; export interface GridPanelProps { advancedExportOptions?: Record; diff --git a/packages/components/src/public/QueryModel/QueryModel.ts b/packages/components/src/public/QueryModel/QueryModel.ts index 61871137aa..a9494c1c66 100644 --- a/packages/components/src/public/QueryModel/QueryModel.ts +++ b/packages/components/src/public/QueryModel/QueryModel.ts @@ -235,6 +235,8 @@ export interface QueryConfig { useSavedSettings?: SavedSettings; } +export type QueryConfigMap = Record; + export const DEFAULT_OFFSET = 0; export const DEFAULT_MAX_ROWS = 20; @@ -1322,6 +1324,89 @@ export function saveSettingsToLocalStorage(model: QueryModel): void { localStorage.setItem(localStorageKey(model.id, model.containerPath), JSON.stringify(settings)); } +export type QueryModelMap = Record; + export function removeSettingsFromLocalStorage(model: QueryModel): void { localStorage.removeItem(localStorageKey(model.id, model.containerPath)); } + +export interface Actions { + addMessage: (id: string, message: GridMessage, duration?: number) => void; + addModel: (queryConfig: QueryConfig, load?: boolean, loadSelections?: boolean) => void; + clearSelectedReports: (id: string) => void; + clearSelections: (id: string) => void; + loadAllModels: (loadSelections?: boolean, reloadTotalCount?: boolean) => void; + loadCharts: (id: string) => void; + loadFirstPage: (id: string) => void; + loadLastPage: (id: string) => void; + loadModel: (id: string, loadSelections?: boolean, reloadTotalCount?: boolean) => void; + loadNextPage: (id: string) => void; + loadPreviousPage: (id: string) => void; + loadRows: (id: string) => void; + onModelChange: (id: string, modelChange: ModelChange) => void; + replaceSelections: (id: string, selections: string[]) => void; + resetTotalCountState: () => void; + selectAllRows: (id: string) => void; + selectPage: (id: string, checked: boolean) => void; + selectReport: (id: string, reportId: string, selected: boolean) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectRow: (id: string, checked: boolean, row: Record, useSelectionPivot?: boolean) => void; + setFilters: (id: string, filters: Filter.IFilter[], loadSelections?: boolean) => void; + setMaxRows: (id: string, maxRows: number) => void; + setOffset: (id: string, offset: number, reloadModel?: boolean) => void; + setSchemaQuery: (id: string, schemaQuery: SchemaQuery, loadSelections?: boolean) => void; + setSelections: (id: string, checked: boolean, selections: string[]) => void; + setSorts: (id: string, sorts: QuerySort[]) => void; + setView: (id: string, viewName: string, loadSelections?: boolean) => void; +} + +export enum ChangeType { + add = 'add', + delete = 'delete', + update = 'update', +} + +interface BaseModelChange { + changeType: ChangeType; +} + +export interface AddChange extends BaseModelChange { + changeType: ChangeType.add; +} + +/** + * selectionsForReplace: an optional set of row keys to select after the model is reset. + */ +export interface DeleteOptions { + selectionsForReplace?: string[]; +} + +export interface DeleteChange extends BaseModelChange { + changeType: ChangeType.delete; + options?: DeleteOptions; +} + +/** + * columnsChanged: an optional list of fieldKeys used to check against the filters. If any of the columns have filters + * on the QueryModel we will reset the model, if not we will only reload the model. + */ +export interface UpdateOptions { + columnsChanged?: string[]; +} + +export interface UpdateChange extends BaseModelChange { + changeType: ChangeType.update; + options?: UpdateOptions; +} + +export type ModelChange = AddChange | DeleteChange | UpdateChange; + +export interface RequiresModelAndActions { + actions: Actions; + model: QueryModel; +} + +export interface InjectedQueryModels { + actions: Actions; + queryModels: Record; +} diff --git a/packages/components/src/public/QueryModel/SelectionStatus.tsx b/packages/components/src/public/QueryModel/SelectionStatus.tsx index 8732489ad7..dca14309b5 100644 --- a/packages/components/src/public/QueryModel/SelectionStatus.tsx +++ b/packages/components/src/public/QueryModel/SelectionStatus.tsx @@ -1,7 +1,7 @@ import React, { FC, memo, useCallback, useMemo } from 'react'; import { LoadingSpinner } from '../../internal/components/base/LoadingSpinner'; -import { RequiresModelAndActions } from './withQueryModels'; +import { RequiresModelAndActions } from './QueryModel'; import { useServerContext } from '../../internal/components/base/ServerContext'; export const SelectionStatus: FC = memo(({ actions, model }) => { diff --git a/packages/components/src/public/QueryModel/TabbedGridPanel.tsx b/packages/components/src/public/QueryModel/TabbedGridPanel.tsx index ef31a951a4..91fedebeaa 100644 --- a/packages/components/src/public/QueryModel/TabbedGridPanel.tsx +++ b/packages/components/src/public/QueryModel/TabbedGridPanel.tsx @@ -23,8 +23,7 @@ import { exportTabsXlsx } from '../../internal/actions'; import { useNotificationsContext } from '../../internal/components/notifications/NotificationsContext'; import { GridPanel, GridPanelProps } from './GridPanel'; -import { InjectedQueryModels } from './withQueryModels'; -import { QueryModel } from './QueryModel'; +import { QueryModel, InjectedQueryModels } from './QueryModel'; import { getQueryModelExportParams } from './utils'; interface GridTabProps { diff --git a/packages/components/src/public/QueryModel/testUtils.ts b/packages/components/src/public/QueryModel/testUtils.ts index d4fefcbc26..85a9fbe183 100644 --- a/packages/components/src/public/QueryModel/testUtils.ts +++ b/packages/components/src/public/QueryModel/testUtils.ts @@ -3,8 +3,7 @@ import { QueryInfo } from '../QueryInfo'; import { LoadingState } from '../LoadingState'; -import { QueryModel } from './QueryModel'; -import { Actions } from './withQueryModels'; +import { Actions, QueryModel } from './QueryModel'; /** * @ignore diff --git a/packages/components/src/public/QueryModel/utils.ts b/packages/components/src/public/QueryModel/utils.ts index 3d41a235d9..f2f716b5cd 100644 --- a/packages/components/src/public/QueryModel/utils.ts +++ b/packages/components/src/public/QueryModel/utils.ts @@ -14,12 +14,24 @@ import { QuerySort } from '../QuerySort'; import { QueryColumn } from '../QueryColumn'; import { EXPORT_TYPES } from '../../internal/constants'; -import { SELECTION_SNAPSHOT_SEP } from '../SchemaQuery'; +import { SchemaQuery, SELECTION_SNAPSHOT_SEP } from '../SchemaQuery'; import { getSelectedRows } from '../../internal/query/selectRows'; import { caseInsensitive } from '../../internal/util/utils'; import { ActionValue } from './grid/actions/Action'; -import { QueryModel } from './QueryModel'; +import { + getSettingsFromLocalStorage, InjectedQueryModels, + locationHasQueryParamSettings, + QueryModel, + QueryModelMap, + SavedSettings, saveSettingsToLocalStorage +} from './QueryModel'; +import { Draft, produce } from 'immer'; +import { RequestHandler } from '../../internal/request'; +import { ComponentType, PureComponent } from 'react'; +import { SearchParamsProps } from './withQueryModels'; +import { LoadingState } from '../LoadingState'; +import { naturalSort } from '../sort'; export function filterToString(filter: Filter.IFilter): string { return `${filter.getColumnName()}-${filter.getFilterType().getURLSuffix()}-${filter.getValue()}`; @@ -203,3 +215,199 @@ export async function createOrderedSnapshotSelectionKey(model: QueryModel): Prom const orderedRows = rows.map(row => caseInsensitive(row, pkFieldKey).value.toString()); return _createSnapshotSelectionKey(model, orderedRows); } + +// N.B. This is similar to useRequestHandler() but we cannot use hooks in withQueryModels or QueryModelManager, so we +// have to use class variables instead. Additionally, we cannot make use of React.createRef() since that returns an +// immutable reference unlike React.useRef() which is mutable. +// Exported for unit tests +export class RequestManager { + _requests: Record> = {}; + + public cancelAllRequests = (): void => { + Object.values(this._requests).forEach(allReq => { + Object.values(allReq).forEach(req => { + req?.abort(); + }); + }); + this._requests = {}; + }; + + public getRequestHandler(id: string, requestType: string): RequestHandler { + return request => { + const bucket = this._requests[id] || (this._requests[id] = {}); + + // Abort in-flight request + bucket[requestType]?.abort(); + + // If the bucket was detached during the abort() call, + // then re-attach it before assigning the new request. + if (this._requests[id] !== bucket) { + this._requests[id] = bucket; + } + + bucket[requestType] = request; + + // Remove the request once the request has completed + request.addEventListener( + 'loadend', + () => { + const bucket_ = this._requests[id]; + if (bucket_?.[requestType] === request) { + delete bucket_[requestType]; + + if (Object.keys(bucket_).length === 0) { + delete this._requests[id]; + } + } + }, + { once: true } + ); + }; + } +} + +export function applySavedSettings(id: string, model: QueryModel): QueryModel { + const settings = getSettingsFromLocalStorage(id, model.containerPath); + if (settings !== undefined) { + const { filterArray, maxRows, sorts, viewName } = settings; + const mutations: Partial> = { maxRows, sorts }; + + if (model.useSavedSettings === SavedSettings.all) { + mutations.filterArray = filterArray; + + if (viewName !== undefined) { + mutations.schemaQuery = new SchemaQuery(model.schemaName, model.queryName, viewName); + } + } + + const modelWithSavedSettings = model.mutate(mutations as Partial); + + if (model.useSavedSettings === SavedSettings.noFilters) { + // If we're retrieving saved settings, but ignoring filters, we need to resave the settings without the + // filters or app behavior will be confusing. For example: you create a sample, and are navigated to a grid + // with no filters, then you edit a sample on that grid. When you navigate back, after editing, the filter + // that was removed after creation is now back. + saveSettingsToLocalStorage(modelWithSavedSettings); + } + + return modelWithSavedSettings; + } + return model; +} + +export function initModels(queryConfigs, searchParams): QueryModelMap { + return Object.keys(queryConfigs).reduce((models, id) => { + // We expect the key value for each QueryConfig to be the id. If a user were to mistakenly set the id + // to something different on the QueryConfig then actions would break + // e.g. actions.loadNextPage(model.id) would not work. + let model = new QueryModel({ id, ...queryConfigs[id] }); + const hasQueryParamSettings = locationHasQueryParamSettings(model.urlPrefix, searchParams); + + if (model.bindURL && hasQueryParamSettings) { + model = model.mutate(model.attributesForURLQueryParams(searchParams, true)); + } else if (model.useSavedSettings !== SavedSettings.none) { + if (!model.containerPath) { + console.error('A model.containerPath is required when useSavedSettings is true: ' + model.id); + } else { + model = applySavedSettings(model.id, model); + } + } + + models[id] = model; + return models; + }, {}); +} + +function columnHasFilter(fieldKey: string, filters: Filter.IFilter[]): boolean { + fieldKey = fieldKey.toLowerCase(); + return filters.some(filter => filter.getColumnName().toLowerCase() === fieldKey); +} + +export function columnsHaveFilter(columnFieldKeys: string[], filters: Filter.IFilter[]): boolean { + return columnFieldKeys.some(fieldKey => columnHasFilter(fieldKey, filters)); +} + +/** + * Resets queryInfo state to initialized state. Use this when you need to load/reload QueryInfo. + * Note: This method intentionally has side effects, it is only to be used inside of an Immer produce() callback. + * @param model The model to reset queryInfo state on. + */ +export function resetQueryInfoState(model: Draft): void { + model.queryInfo = undefined; + model.queryInfoError = undefined; + model.queryInfoLoadingState = LoadingState.INITIALIZED; +} + +/** + * Resets totalCount state to initialized state. Use this when you need to load/reload QueryInfo. + * Note: This method intentionally has side effects, it is only to be used inside of an Immer produce() callback. + * @param model The model to reset queryInfo state on. + */ +export function resetTotalCountState(model: Draft): void { + model.rowCount = undefined; + model.totalCountError = undefined; + model.totalCountLoadingState = LoadingState.INITIALIZED; +} + +/** + * Resets rows state to initialized state. Use this when you need to load/reload selections. + * Note: This method intentionally has side effects, it is only to be used inside of an Immer produce() callback. + * @param model The model to reset selection state on. + */ +export function resetRowsState(model: Draft): void { + model.messages = undefined; + model.offset = 0; + model.orderedRows = undefined; + model.viewError = undefined; + model.rowsError = undefined; + model.rows = undefined; + model.rowCount = undefined; + model.rowsLoadingState = LoadingState.INITIALIZED; +} + +/** + * Resets selection state to initialized state. Use this when you need to load/reload selections. + * Note: This method intentionally has side effects, it is only to be used inside of an Immer produce() callback. + * @param model The model to reset selection state on. + */ +export function resetSelectionState(model: Draft): void { + model.selections = undefined; + model.selectionsError = undefined; + model.selectionsLoadingState = LoadingState.INITIALIZED; + model.selectionPivot = undefined; +} + +/** + * Resets the model to the first page, resets the selection state, and resets the total count state. + * @param model The model to reset + */ +export function resetModelState(model: Draft): void { + resetRowsState(model); + resetSelectionState(model); + resetTotalCountState(model); +} + +/** + * Compares two query params objects, returns true if they are equal, false otherwise. + * @param oldParams + * @param newParams + */ +export function paramsEqual(oldParams, newParams): boolean { + const keys = Object.keys(oldParams); + const oldKeyStr = keys.sort(naturalSort).join(';'); + const newKeyStr = Object.keys(newParams).sort(naturalSort).join(';'); + + if (oldKeyStr === newKeyStr) { + // If the keys are the same we need to do a deep comparison + for (const key of Object.keys(oldParams)) { + if (oldParams[key] !== newParams[key]) { + return false; + } + } + + return true; + } + + // If the keys have changed we can assume the params are different. + return false; +} diff --git a/packages/components/src/public/QueryModel/withQueryModels.tsx b/packages/components/src/public/QueryModel/withQueryModels.tsx index 34ee95a32a..b0c86ad179 100644 --- a/packages/components/src/public/QueryModel/withQueryModels.tsx +++ b/packages/components/src/public/QueryModel/withQueryModels.tsx @@ -2,7 +2,7 @@ import React, { ComponentType, FC, PureComponent, ReactNode } from 'react'; import { Filter } from '@labkey/api'; // eslint cannot find Draft for some reason, but Intellij can. -import { Draft, produce, WritableDraft } from 'immer'; +import { produce, WritableDraft } from 'immer'; import { SetURLSearchParams, useSearchParams } from 'react-router-dom'; import { getQueryParams } from '../../internal/util/URL'; @@ -10,22 +10,40 @@ import { getQueryParams } from '../../internal/util/URL'; import { SchemaQuery } from '../SchemaQuery'; import { QuerySort } from '../QuerySort'; import { isLoading, LoadingState } from '../LoadingState'; -import { naturalSort } from '../sort'; import { resolveErrorMessage } from '../../internal/util/messaging'; import { selectRows } from '../../internal/query/selectRows'; import { incrementClientSideMetricCount } from '../../internal/actions'; -import { filterArraysEqual, getSelectRowCountColumnsStr, sortArraysEqual } from './utils'; +import { + applySavedSettings, + columnsHaveFilter, + filterArraysEqual, + getSelectRowCountColumnsStr, + initModels, + paramsEqual, + RequestManager, + resetModelState, + resetQueryInfoState, + resetRowsState, + resetSelectionState, + resetTotalCountState, + sortArraysEqual, +} from './utils'; import { DefaultQueryModelLoader, QueryModelLoader } from './QueryModelLoader'; import { RequestHandler } from '../../internal/request'; import { - getSettingsFromLocalStorage, + Actions, + ChangeType, GridMessage, + InjectedQueryModels, locationHasQueryParamSettings, + ModelChange, QueryConfig, + QueryConfigMap, QueryModel, + QueryModelMap, removeSettingsFromLocalStorage, SavedSettings, saveSettingsToLocalStorage, @@ -58,99 +76,6 @@ export function withSearchParams(Component: WithSearchParamsComponent): Co return Wrapped; } -function columnHasFilter(fieldKey: string, filters: Filter.IFilter[]): boolean { - fieldKey = fieldKey.toLowerCase(); - return filters.some(filter => filter.getColumnName().toLowerCase() === fieldKey); -} - -function columnsHaveFilter(columnFieldKeys: string[], filters: Filter.IFilter[]): boolean { - return columnFieldKeys.some(fieldKey => columnHasFilter(fieldKey, filters)); -} - -export enum ChangeType { - add = 'add', - delete = 'delete', - update = 'update', -} - -interface BaseModelChange { - changeType: ChangeType; -} - -export interface AddChange extends BaseModelChange { - changeType: ChangeType.add; -} - -/** - * selectionsForReplace: an optional set of row keys to select after the model is reset. - */ -export interface DeleteOptions { - selectionsForReplace?: string[]; -} - -export interface DeleteChange extends BaseModelChange { - changeType: ChangeType.delete; - options?: DeleteOptions; -} - -/** - * columnsChanged: an optional list of fieldKeys used to check against the filters. If any of the columns have filters - * on the QueryModel we will reset the model, if not we will only reload the model. - */ -export interface UpdateOptions { - columnsChanged?: string[]; -} - -export interface UpdateChange extends BaseModelChange { - changeType: ChangeType.update; - options?: UpdateOptions; -} - -export type ModelChange = AddChange | DeleteChange | UpdateChange; - -export interface Actions { - addMessage: (id: string, message: GridMessage, duration?: number) => void; - addModel: (queryConfig: QueryConfig, load?: boolean, loadSelections?: boolean) => void; - clearSelectedReports: (id: string) => void; - clearSelections: (id: string) => void; - loadAllModels: (loadSelections?: boolean, reloadTotalCount?: boolean) => void; - loadCharts: (id: string) => void; - loadFirstPage: (id: string) => void; - loadLastPage: (id: string) => void; - loadModel: (id: string, loadSelections?: boolean, reloadTotalCount?: boolean) => void; - loadNextPage: (id: string) => void; - loadPreviousPage: (id: string) => void; - loadRows: (id: string) => void; - onModelChange: (id: string, modelChange: ModelChange) => void; - replaceSelections: (id: string, selections: string[]) => void; - resetTotalCountState: () => void; - selectAllRows: (id: string) => void; - selectPage: (id: string, checked: boolean) => void; - selectReport: (id: string, reportId: string, selected: boolean) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - selectRow: (id: string, checked: boolean, row: Record, useSelectionPivot?: boolean) => void; - setFilters: (id: string, filters: Filter.IFilter[], loadSelections?: boolean) => void; - setMaxRows: (id: string, maxRows: number) => void; - setOffset: (id: string, offset: number, reloadModel?: boolean) => void; - setSchemaQuery: (id: string, schemaQuery: SchemaQuery, loadSelections?: boolean) => void; - setSelections: (id: string, checked: boolean, selections: string[]) => void; - setSorts: (id: string, sorts: QuerySort[]) => void; - setView: (id: string, viewName: string, loadSelections?: boolean) => void; -} - -export interface RequiresModelAndActions { - actions: Actions; - model: QueryModel; -} - -export interface InjectedQueryModels { - actions: Actions; - queryModels: Record; -} - -export type QueryConfigMap = Record; -export type QueryModelMap = Record; - export interface MakeQueryModels { autoLoad?: boolean; modelLoader?: QueryModelLoader; @@ -161,170 +86,6 @@ interface State { queryModels: QueryModelMap; } -/** - * Resets queryInfo state to initialized state. Use this when you need to load/reload QueryInfo. - * Note: This method intentionally has side effects, it is only to be used inside of an Immer produce() callback. - * @param model The model to reset queryInfo state on. - */ -const resetQueryInfoState = (model: Draft): void => { - model.queryInfo = undefined; - model.queryInfoError = undefined; - model.queryInfoLoadingState = LoadingState.INITIALIZED; -}; - -/** - * Resets totalCount state to initialized state. Use this when you need to load/reload QueryInfo. - * Note: This method intentionally has side effects, it is only to be used inside of an Immer produce() callback. - * @param model The model to reset queryInfo state on. - */ -const resetTotalCountState = (model: Draft): void => { - model.rowCount = undefined; - model.totalCountError = undefined; - model.totalCountLoadingState = LoadingState.INITIALIZED; -}; - -/** - * Resets rows state to initialized state. Use this when you need to load/reload selections. - * Note: This method intentionally has side effects, it is only to be used inside of an Immer produce() callback. - * @param model The model to reset selection state on. - */ -const resetRowsState = (model: Draft): void => { - model.messages = undefined; - model.offset = 0; - model.orderedRows = undefined; - model.viewError = undefined; - model.rowsError = undefined; - model.rows = undefined; - model.rowCount = undefined; - model.rowsLoadingState = LoadingState.INITIALIZED; -}; - -/** - * Resets selection state to initialized state. Use this when you need to load/reload selections. - * Note: This method intentionally has side effects, it is only to be used inside of an Immer produce() callback. - * @param model The model to reset selection state on. - */ -const resetSelectionState = (model: Draft): void => { - model.selections = undefined; - model.selectionsError = undefined; - model.selectionsLoadingState = LoadingState.INITIALIZED; - model.selectionPivot = undefined; -}; - -/** - * Resets the model to the first page, resets the selection state, and resets the total count state. - * @param model The model to reset - */ -const resetModelState = (model: Draft): void => { - resetRowsState(model); - resetSelectionState(model); - resetTotalCountState(model); -}; - -/** - * Compares two query params objects, returns true if they are equal, false otherwise. - * @param oldParams - * @param newParams - */ -const paramsEqual = (oldParams, newParams): boolean => { - const keys = Object.keys(oldParams); - const oldKeyStr = keys.sort(naturalSort).join(';'); - const newKeyStr = Object.keys(newParams).sort(naturalSort).join(';'); - - if (oldKeyStr === newKeyStr) { - // If the keys are the same we need to do a deep comparison - for (const key of Object.keys(oldParams)) { - if (oldParams[key] !== newParams[key]) { - return false; - } - } - - return true; - } - - // If the keys have changed we can assume the params are different. - return false; -}; - -function applySavedSettings(id: string, model: QueryModel): QueryModel { - const settings = getSettingsFromLocalStorage(id, model.containerPath); - if (settings !== undefined) { - const { filterArray, maxRows, sorts, viewName } = settings; - const mutations: Partial> = { maxRows, sorts }; - - if (model.useSavedSettings === SavedSettings.all) { - mutations.filterArray = filterArray; - - if (viewName !== undefined) { - mutations.schemaQuery = new SchemaQuery(model.schemaName, model.queryName, viewName); - } - } - - const modelWithSavedSettings = model.mutate(mutations as Partial); - - if (model.useSavedSettings === SavedSettings.noFilters) { - // If we're retrieving saved settings, but ignoring filters, we need to resave the settings without the - // filters or app behavior will be confusing. For example: you create a sample, and are navigated to a grid - // with no filters, then you edit a sample on that grid. When you navigate back, after editing, the filter - // that was removed after creation is now back. - saveSettingsToLocalStorage(modelWithSavedSettings); - } - - return modelWithSavedSettings; - } - return model; -} - -// N.B. This is similar to useRequestHandler() but we cannot use a hook here, so we have to use class -// variables instead. Additionally, we cannot make use of React.createRef() since that returns an immutable -// reference unlike React.useRef() which is mutable. -// Exported for unit tests -export class RequestManager { - _requests: Record> = {}; - - public cancelAllRequests = (): void => { - Object.values(this._requests).forEach(allReq => { - Object.values(allReq).forEach(req => { - req?.abort(); - }); - }); - this._requests = {}; - }; - - public getRequestHandler(id: string, requestType: string): RequestHandler { - return request => { - const bucket = this._requests[id] || (this._requests[id] = {}); - - // Abort in-flight request - bucket[requestType]?.abort(); - - // If the bucket was detached during the abort() call, - // then re-attach it before assigning the new request. - if (this._requests[id] !== bucket) { - this._requests[id] = bucket; - } - - bucket[requestType] = request; - - // Remove the request once the request has completed - request.addEventListener( - 'loadend', - () => { - const bucket_ = this._requests[id]; - if (bucket_?.[requestType] === request) { - delete bucket_[requestType]; - - if (Object.keys(bucket_).length === 0) { - delete this._requests[id]; - } - } - }, - { once: true } - ); - }; - } -} - /** * A wrapper for LabKey selectRows API. For in-depth documentation and examples see components/docs/QueryModel.md. * @param ComponentToWrap A component that implements generic Props and InjectedQueryModels. @@ -335,30 +96,6 @@ export function withQueryModels( ): ComponentType { type WrappedProps = MakeQueryModels & Props & SearchParamsProps; - const initModels = (props: WrappedProps): QueryModelMap => { - const { searchParams, queryConfigs } = props; - return Object.keys(queryConfigs).reduce((models, id) => { - // We expect the key value for each QueryConfig to be the id. If a user were to mistakenly set the id - // to something different on the QueryConfig then actions would break - // e.g. actions.loadNextPage(model.id) would not work. - let model = new QueryModel({ id, ...queryConfigs[id] }); - const hasQueryParamSettings = locationHasQueryParamSettings(model.urlPrefix, searchParams); - - if (model.bindURL && hasQueryParamSettings) { - model = model.mutate(model.attributesForURLQueryParams(searchParams, true)); - } else if (model.useSavedSettings !== SavedSettings.none) { - if (!model.containerPath) { - console.error('A model.containerPath is required when useSavedSettings is true: ' + model.id); - } else { - model = applySavedSettings(model.id, model); - } - } - - models[id] = model; - return models; - }, {}); - }; - class ComponentWithQueryModels extends PureComponent { static defaultProps; @@ -367,7 +104,9 @@ export function withQueryModels( constructor(props: WrappedProps) { super(props); - this.state = produce({} as State, () => ({ queryModels: initModels(props) })); + this.state = produce({} as State, () => ({ + queryModels: initModels(props.queryConfigs, props.searchParams), + })); this.actions = { addModel: this.addModel, From fc538eb9a1743d14462a2670d63865ce6244ab16 Mon Sep 17 00:00:00 2001 From: alanv Date: Sat, 18 Apr 2026 12:49:05 -0500 Subject: [PATCH 02/23] useQueryModels: stub implementation --- .../src/public/QueryModel/useQueryModels.ts | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 packages/components/src/public/QueryModel/useQueryModels.ts diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts new file mode 100644 index 0000000000..6662554f1d --- /dev/null +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -0,0 +1,287 @@ +import { useEffect, useRef, useSyncExternalStore } from 'react'; +import { Draft, produce } from 'immer'; +import { SetURLSearchParams, useSearchParams } from 'react-router-dom'; +import { Filter } from '@labkey/api'; + +import { + Actions, + GridMessage, + InjectedQueryModels, + ModelChange, + QueryConfig, + QueryConfigMap, + QueryModel, +} from './QueryModel'; +import { initModels, RequestManager } from './utils'; +import { SchemaQuery } from '../SchemaQuery'; +import { QuerySort } from '../QuerySort'; + +const NOOP = () => {}; + +type ModelUpdater = (model: Draft) => void; +type VoidFn = () => void; +type StateUpdater = (state: InjectedQueryModels) => InjectedQueryModels; + +class QueryModelManager { + actions: Actions; + state: InjectedQueryModels; + + private onStateChange: VoidFn; + private requestManager: RequestManager; + private searchParams: URLSearchParams; + private setSearchParams: SetURLSearchParams; + + constructor(queryConfigs: QueryConfigMap, searchParams: URLSearchParams, setSearchParams: SetURLSearchParams) { + this.requestManager = new RequestManager(); + this.searchParams = searchParams; + this.setSearchParams = setSearchParams; + this.actions = { + addMessage: this.addMessage, + addModel: this.addModel, + clearSelectedReports: this.clearSelectedReports, + clearSelections: this.clearSelections, + loadAllModels: this.loadAllModels, + loadCharts: this.loadCharts, + loadFirstPage: this.loadFirstPage, + loadLastPage: this.loadLastPage, + loadNextPage: this.loadNextPage, + loadModel: this.loadModel, + loadPreviousPage: this.loadPreviousPage, + loadRows: this.loadRows, + onModelChange: this.onModelChange, + replaceSelections: this.replaceSelections, + resetTotalCountState: this.resetTotalCountState, + selectAllRows: this.selectAllRows, + selectPage: this.selectPage, + selectReport: this.selectReport, + selectRow: this.selectRow, + setFilters: this.setFilters, + setMaxRows: this.setMaxRows, + setOffset: this.setOffset, + setSchemaQuery: this.setSchemaQuery, + setSelections: this.setSelections, + setSorts: this.setSorts, + setView: this.setView, + }; + this.state = { + queryModels: initModels(queryConfigs, searchParams), + actions: this.actions, + }; + } + + updateRouter = (searchParams: URLSearchParams, setSearchParams: SetURLSearchParams) => { + this.setSearchParams = setSearchParams; + + if (searchParams !== this.searchParams) { + this.searchParams = searchParams; + this.bindURL(); + } + }; + + cleanup = () => { + this.onStateChange = NOOP; + }; + + destroy = () => { + this.requestManager.cancelAllRequests(); + }; + + subscribe = (onStateChange: VoidFn): VoidFn => { + this.onStateChange = onStateChange; + return this.cleanup; + }; + + getSnapshot = (): InjectedQueryModels => { + return this.state; + }; + + setState = (updater: StateUpdater): void => { + const updatedState = updater(this.state); + + if (this.state !== updatedState) { + this.state = updatedState; + this.onStateChange(); + } + }; + + updateModel = (modelId: string, updater: ModelUpdater) => { + this.setState((currentState: InjectedQueryModels) => { + const model = currentState.queryModels[modelId]; + if (!model) return currentState; + return { + ...currentState, + queryModels: { + ...currentState.queryModels, + [modelId]: produce(model, updater), + }, + }; + }); + }; + + bindURL = (): void => { + // TODO: implement + }; + + addMessage = (id: string, message: GridMessage, duration?: number): void => { + this.updateModel(id, (model: Draft) => { + if (model.messages === undefined) { + model.messages = []; + } + model.messages.push(message); + }); + + if (duration) setTimeout(() => this.removeMessage(id, message), duration); + }; + + addModel = (queryConfig: QueryConfig, load?: boolean, loadSelections?: boolean): void => { + // TODO: implement + }; + + clearSelectedReports = (id: string): void => { + // TODO: implement + }; + + clearSelections = (id: string): void => { + // TODO: implement + }; + + loadAllModels = (loadSelections?: boolean, reloadTotalCount?: boolean): void => { + // TODO: implement + }; + + loadCharts = (id: string): void => { + // TODO: implement + }; + + loadFirstPage = (id: string): void => { + // TODO: implement + }; + + loadLastPage = (id: string): void => { + // TODO: implement + }; + + loadModel = (id: string, loadSelections?: boolean, reloadTotalCount?: boolean): void => { + // TODO: implement + }; + + loadNextPage = (id: string): void => { + // TODO: implement + }; + + loadPreviousPage = (id: string): void => { + // TODO: implement + }; + + loadRows = (id: string): void => { + // TODO: implement + }; + + onModelChange = (id: string, modelChange: ModelChange): void => { + // TODO: implement + }; + + removeMessage = (id: string, message: GridMessage) => { + this.updateModel(id, (model: Draft) => { + if (model.messages !== undefined) { + model.messages = model.messages.filter(m => m.content !== message.content); + } + }); + }; + + replaceSelections = (id: string, selections: string[]): void => { + // TODO: implement + }; + + resetTotalCountState = (): void => { + // TODO: implement + }; + + selectAllRows = (id: string): void => { + // TODO: implement + }; + + selectPage = (id: string, checked: boolean): void => { + // TODO: implement + }; + + selectReport = (id: string, reportId: string, selected: boolean): void => { + // TODO: implement + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectRow = (id: string, checked: boolean, row: Record, useSelectionPivot?: boolean): void => { + // TODO: implement + }; + + setFilters = (id: string, filters: Filter.IFilter[], loadSelections?: boolean): void => { + // TODO: implement + }; + + setMaxRows = (id: string, maxRows: number): void => { + // TODO: implement + }; + + setOffset = (id: string, offset: number, reloadModel?: boolean): void => { + // TODO: implement + }; + + setSchemaQuery = (id: string, schemaQuery: SchemaQuery, loadSelections?: boolean): void => { + // TODO: implement + }; + + setSelections = (id: string, checked: boolean, selections: string[]): void => { + // TODO: implement + }; + + setSorts = (id: string, sorts: QuerySort[]): void => { + // TODO: implement + }; + + setView = (id: string, viewName: string, loadSelections?: boolean): void => { + // TODO: implement + }; +} + +const DEFAULT_SEARCH_PARAMS = new URLSearchParams(); +const DEFAULT_SET_SEARCH_PARAMS = () => {}; +type OptionalSearchParams = [URLSearchParams, SetURLSearchParams]; + +function useOptionalSearchParams(): OptionalSearchParams { + let searchParams; + let setSearchParams; + try { + [searchParams, setSearchParams] = useSearchParams(); + } catch (error) { + // We are not in a react-router context, so we revert to injecting a default set of these props + searchParams = DEFAULT_SEARCH_PARAMS; + setSearchParams = DEFAULT_SET_SEARCH_PARAMS; + } + + return [searchParams, setSearchParams]; +} + +export function useQueryModels(queryConfigs: QueryConfigMap) { + const [searchParams, setSearchParams] = useOptionalSearchParams(); + const manager = useRef(null); + + /* eslint-disable react-hooks/refs */ + if (!manager.current) { + manager.current = new QueryModelManager(queryConfigs, searchParams, setSearchParams); + } + + const state = useSyncExternalStore(manager.current.subscribe, manager.current.getSnapshot); + + useEffect(() => { + return () => { + manager.current.destroy(); + }; + }, []); + + useEffect(() => { + manager.current.updateRouter(searchParams, setSearchParams); + }, [searchParams, setSearchParams]); + /* eslint-enable react-hooks/refs */ + + return state; +} From d706d517965090955ee5a12948c76db88b62031d Mon Sep 17 00:00:00 2001 From: alanv Date: Sat, 18 Apr 2026 13:28:01 -0500 Subject: [PATCH 03/23] QueryModel/utils.ts - Add bindURL --- .../components/src/public/QueryModel/utils.ts | 24 ++++++++++++++++++ .../src/public/QueryModel/withQueryModels.tsx | 25 ++----------------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/components/src/public/QueryModel/utils.ts b/packages/components/src/public/QueryModel/utils.ts index f2f716b5cd..35e35cbcd9 100644 --- a/packages/components/src/public/QueryModel/utils.ts +++ b/packages/components/src/public/QueryModel/utils.ts @@ -32,6 +32,8 @@ import { ComponentType, PureComponent } from 'react'; import { SearchParamsProps } from './withQueryModels'; import { LoadingState } from '../LoadingState'; import { naturalSort } from '../sort'; +import { SetURLSearchParams } from 'react-router-dom'; +import { getQueryParams } from '../../internal/util/URL'; export function filterToString(filter: Filter.IFilter): string { return `${filter.getColumnName()}-${filter.getFilterType().getURLSuffix()}-${filter.getValue()}`; @@ -411,3 +413,25 @@ export function paramsEqual(oldParams, newParams): boolean { // If the keys have changed we can assume the params are different. return false; } + +export function bindURL(setSearchParams: SetURLSearchParams, prefix: string, params: Record) { + setSearchParams( + currentParams => { + const queryParams = getQueryParams(currentParams); + return Object.keys(queryParams).reduce( + (result, key) => { + // Only copy params that aren't related to the current model, we initialize the result with the + // updated params below. + if (!key.startsWith(prefix + '.')) { + result[key] = queryParams[key]; + } + return result; + }, + // QueryModel.urlQueryParams returns Record, but getQueryParams and setSearchParams + // use Record + params as Record + ); + }, + { replace: true } + ); +} diff --git a/packages/components/src/public/QueryModel/withQueryModels.tsx b/packages/components/src/public/QueryModel/withQueryModels.tsx index b0c86ad179..65dfa718d2 100644 --- a/packages/components/src/public/QueryModel/withQueryModels.tsx +++ b/packages/components/src/public/QueryModel/withQueryModels.tsx @@ -5,8 +5,6 @@ import { Filter } from '@labkey/api'; import { produce, WritableDraft } from 'immer'; import { SetURLSearchParams, useSearchParams } from 'react-router-dom'; -import { getQueryParams } from '../../internal/util/URL'; - import { SchemaQuery } from '../SchemaQuery'; import { QuerySort } from '../QuerySort'; import { isLoading, LoadingState } from '../LoadingState'; @@ -18,6 +16,7 @@ import { incrementClientSideMetricCount } from '../../internal/actions'; import { applySavedSettings, + bindURL, columnsHaveFilter, filterArraysEqual, getSelectRowCountColumnsStr, @@ -221,27 +220,7 @@ export function withQueryModels( } const model = this.state.queryModels[id]; - const { urlPrefix, urlQueryParams } = model; - - setSearchParams( - currentParams => { - const queryParams = getQueryParams(currentParams); - return Object.keys(queryParams).reduce( - (result, key) => { - // Only copy params that aren't related to the current model, we initialize the result with the - // updated params below. - if (!key.startsWith(urlPrefix + '.')) { - result[key] = queryParams[key]; - } - return result; - }, - // QueryModel.urlQueryParams returns Record but getQueryParams and setSearchParams - // use Record - urlQueryParams as Record - ); - }, - { replace: true } - ); + bindURL(setSearchParams, model.urlPrefix, model.urlQueryParams); }; updateModelFromURL = (id: string): void => { From 371cc4d0381e7cc0563e0806e9ca64c53636e3d6 Mon Sep 17 00:00:00 2001 From: alanv Date: Sat, 18 Apr 2026 13:50:59 -0500 Subject: [PATCH 04/23] QueryModelManager - implement addModel, maybeLoad, updateModelsFromURL, bindURL --- .../src/public/QueryModel/useQueryModels.ts | 131 ++++++++++++++++-- 1 file changed, 120 insertions(+), 11 deletions(-) diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts index 6662554f1d..1960176f8f 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.ts +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -7,16 +7,21 @@ import { Actions, GridMessage, InjectedQueryModels, + locationHasQueryParamSettings, ModelChange, QueryConfig, QueryConfigMap, QueryModel, + saveSettingsToLocalStorage, } from './QueryModel'; -import { initModels, RequestManager } from './utils'; +import { applySavedSettings, bindURL, initModels, paramsEqual, RequestManager } from './utils'; import { SchemaQuery } from '../SchemaQuery'; import { QuerySort } from '../QuerySort'; +import { isLoading, LoadingState } from '../LoadingState'; const NOOP = () => {}; +const DEFAULT_SEARCH_PARAMS = new URLSearchParams(); +const DEFAULT_SET_SEARCH_PARAMS = () => {}; type ModelUpdater = (model: Draft) => void; type VoidFn = () => void; @@ -74,7 +79,7 @@ class QueryModelManager { if (searchParams !== this.searchParams) { this.searchParams = searchParams; - this.bindURL(); + this.updateModelsFromURL(); } }; @@ -104,22 +109,93 @@ class QueryModelManager { } }; - updateModel = (modelId: string, updater: ModelUpdater) => { + updateModel = (id: string, updater: ModelUpdater): void => { this.setState((currentState: InjectedQueryModels) => { - const model = currentState.queryModels[modelId]; + const model = currentState.queryModels[id]; if (!model) return currentState; return { ...currentState, queryModels: { ...currentState.queryModels, - [modelId]: produce(model, updater), + [id]: produce(model, updater), }, }; }); + + if (this.state.queryModels[id].bindURL) this.bindURL(id); + }; + + maybeLoad = ( + id: string, + loadQueryInfo = false, + loadRows = false, + loadSelections = false, + reloadTotalCount = false, + selectionsForReplace?: string[] + ): void => { + if (loadQueryInfo) { + // Postpone loading any rows or selections if we're loading the QueryInfo. + this.loadQueryInfo(id, loadRows, loadSelections); + } else { + if (loadRows) { + this.loadRows(id, loadSelections, selectionsForReplace); + this.loadTotalCount(id, reloadTotalCount); + } else if (loadSelections) { + this.loadSelections(id); + } else if (selectionsForReplace !== undefined) { + this.replaceSelections(id, selectionsForReplace); + } + } }; - bindURL = (): void => { - // TODO: implement + bindURL = (id: string): void => { + const { setSearchParams } = this; + + // We're rendering a component outside a react-router context, so we can't bind to the URL + if (setSearchParams === DEFAULT_SET_SEARCH_PARAMS) return; + + const model = this.state.queryModels[id]; + bindURL(setSearchParams, model.urlPrefix, model.urlQueryParams); + }; + + updateModelFromURL = (id: string) => { + const { searchParams } = this; + + if (searchParams === DEFAULT_SEARCH_PARAMS) return; + + let loadModel = false; + let loadSelections = false; + + this.updateModel(id, (model: Draft) => { + const modelParamsFromURL = {}; + for (const [key, value] of searchParams.entries()) { + if (key.startsWith(model.urlPrefix + '.')) { + modelParamsFromURL[key] = value; + } + } + + if (!isLoading(model.queryInfoLoadingState) && !paramsEqual(modelParamsFromURL, model.urlQueryParams)) { + Object.assign(model, model.attributesForURLQueryParams(searchParams)); + loadModel = true; + // If we have selections or previously attempted to load them, we'll want to reload them when the model + // is updated from the URL because it can affect selections. + loadSelections = !!model.selections || !!model.selectionsError; + + // since URL param changes could change the filterArray, need to reload the totalCount (issue 47660) + model.totalCountLoadingState = LoadingState.INITIALIZED; + } + }); + + if (loadModel) { + this.maybeLoad(id, false, true, loadSelections); + saveSettingsToLocalStorage(this.state.queryModels[id]); + } + }; + + updateModelsFromURL = () => { + Object.values(this.state.queryModels) + .filter(model => model.bindURL) + .forEach(model => this.updateModelFromURL(model.id)); }; addMessage = (id: string, message: GridMessage, duration?: number): void => { @@ -134,7 +210,30 @@ class QueryModelManager { }; addModel = (queryConfig: QueryConfig, load?: boolean, loadSelections?: boolean): void => { - // TODO: implement + const { searchParams } = this; + let id; + + this.setState(currentState => { + let queryModel = new QueryModel(queryConfig); + id = queryModel.id; + const hasQueryParamSettings = locationHasQueryParamSettings(queryModel.urlPrefix, searchParams); + + if (queryModel.bindURL && hasQueryParamSettings) { + queryModel = queryModel.mutate(queryModel.attributesForURLQueryParams(searchParams)); + } else if (queryModel.useSavedSettings && queryModel.containerPath) { + queryModel = applySavedSettings(id, queryModel); + } + + return { + ...currentState, + queryModels: { + ...currentState.queryModels, + [id]: queryModel, + }, + }; + }); + + this.maybeLoad(id, load, load, loadSelections); }; clearSelectedReports = (id: string): void => { @@ -173,7 +272,19 @@ class QueryModelManager { // TODO: implement }; - loadRows = (id: string): void => { + loadQueryInfo = (id: string, loadRows: boolean, loadSelections: boolean): void => { + // TODO: implement + }; + + loadRows = (id: string, loadSelections = false, selectionsForReplace?: string[]): void => { + // TODO: implement + }; + + loadSelections = (id: string): void => { + // TODO: implement + }; + + loadTotalCount = (id: string, reloadTotalCount: boolean): void => { // TODO: implement }; @@ -243,8 +354,6 @@ class QueryModelManager { }; } -const DEFAULT_SEARCH_PARAMS = new URLSearchParams(); -const DEFAULT_SET_SEARCH_PARAMS = () => {}; type OptionalSearchParams = [URLSearchParams, SetURLSearchParams]; function useOptionalSearchParams(): OptionalSearchParams { From a812f8c986c1e01b9a7409c5f72588ddc54d18e1 Mon Sep 17 00:00:00 2001 From: alanv Date: Sat, 18 Apr 2026 14:20:42 -0500 Subject: [PATCH 05/23] QueryModelManager - implement pagination methods, setSorts, setMaxRows, add syncURL, as saveSettings, fix minor issues --- .../src/public/QueryModel/useQueryModels.ts | 114 ++++++++++++++---- 1 file changed, 93 insertions(+), 21 deletions(-) diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts index 1960176f8f..416ad8737c 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.ts +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -14,7 +14,7 @@ import { QueryModel, saveSettingsToLocalStorage, } from './QueryModel'; -import { applySavedSettings, bindURL, initModels, paramsEqual, RequestManager } from './utils'; +import { applySavedSettings, bindURL, initModels, paramsEqual, RequestManager, sortArraysEqual } from './utils'; import { SchemaQuery } from '../SchemaQuery'; import { QuerySort } from '../QuerySort'; import { isLoading, LoadingState } from '../LoadingState'; @@ -112,17 +112,18 @@ class QueryModelManager { updateModel = (id: string, updater: ModelUpdater): void => { this.setState((currentState: InjectedQueryModels) => { const model = currentState.queryModels[id]; + if (!model) return currentState; + + const newModel = produce(model, updater); + + if (newModel === model) return currentState; + return { ...currentState, - queryModels: { - ...currentState.queryModels, - [id]: produce(model, updater), - }, + queryModels: { ...currentState.queryModels, [id]: newModel }, }; }); - - if (this.state.queryModels[id].bindURL) this.bindURL(id); }; maybeLoad = ( @@ -158,6 +159,10 @@ class QueryModelManager { bindURL(setSearchParams, model.urlPrefix, model.urlQueryParams); }; + syncURL = (id: string): void => { + if (this.state.queryModels[id]?.bindURL) this.bindURL(id); + }; + updateModelFromURL = (id: string) => { const { searchParams } = this; @@ -188,7 +193,7 @@ class QueryModelManager { if (loadModel) { this.maybeLoad(id, false, true, loadSelections); - saveSettingsToLocalStorage(this.state.queryModels[id]); + this.saveSettings(id); } }; @@ -209,13 +214,12 @@ class QueryModelManager { if (duration) setTimeout(() => this.removeMessage(id, message), duration); }; - addModel = (queryConfig: QueryConfig, load?: boolean, loadSelections?: boolean): void => { + addModel = (queryConfig: QueryConfig, load = true, loadSelections = false): void => { const { searchParams } = this; - let id; + let queryModel = new QueryModel(queryConfig); + const id = queryModel.id; this.setState(currentState => { - let queryModel = new QueryModel(queryConfig); - id = queryModel.id; const hasQueryParamSettings = locationHasQueryParamSettings(queryModel.urlPrefix, searchParams); if (queryModel.bindURL && hasQueryParamSettings) { @@ -234,10 +238,14 @@ class QueryModelManager { }); this.maybeLoad(id, load, load, loadSelections); + this.syncURL(id); }; clearSelectedReports = (id: string): void => { - // TODO: implement + this.updateModel(id, (model: Draft) => { + model.selectedReportIds = []; + }); + this.syncURL(id); }; clearSelections = (id: string): void => { @@ -253,11 +261,28 @@ class QueryModelManager { }; loadFirstPage = (id: string): void => { - // TODO: implement + let shouldLoad = false; + this.updateModel(id, (model: Draft) => { + if (!model.isFirstPage) { + shouldLoad = true; + model.offset = 0; + } + }); + + this.maybeLoad(id, false, shouldLoad); + this.syncURL(id); }; loadLastPage = (id: string): void => { - // TODO: implement + let shouldLoad = false; + this.updateModel(id, (model: Draft) => { + if (!model.isLastPage) { + shouldLoad = true; + model.offset = model.lastPageOffset; + } + }); + this.maybeLoad(id, false, shouldLoad); + this.syncURL(id); }; loadModel = (id: string, loadSelections?: boolean, reloadTotalCount?: boolean): void => { @@ -265,11 +290,27 @@ class QueryModelManager { }; loadNextPage = (id: string): void => { - // TODO: implement + let shouldLoad = false; + this.updateModel(id, (model: Draft) => { + if (!model.isLastPage) { + shouldLoad = true; + model.offset = model.offset + model.maxRows; + } + }); + this.maybeLoad(id, false, shouldLoad); + this.syncURL(id); }; loadPreviousPage = (id: string): void => { - // TODO: implement + let shouldLoad = false; + this.updateModel(id, (model: Draft) => { + if (!model.isFirstPage) { + shouldLoad = true; + model.offset = model.offset - model.maxRows; + } + }); + this.maybeLoad(id, false, shouldLoad); + this.syncURL(id); }; loadQueryInfo = (id: string, loadRows: boolean, loadSelections: boolean): void => { @@ -308,6 +349,10 @@ class QueryModelManager { // TODO: implement }; + saveSettings = (id: string): void => { + saveSettingsToLocalStorage(this.state.queryModels[id]); + }; + selectAllRows = (id: string): void => { // TODO: implement }; @@ -330,11 +375,29 @@ class QueryModelManager { }; setMaxRows = (id: string, maxRows: number): void => { - // TODO: implement + let shouldLoad = false; + this.updateModel(id, (model: Draft) => { + if (model.maxRows !== maxRows) { + model.maxRows = maxRows; + model.offset = 0; + shouldLoad = true; + } + }); + this.maybeLoad(id, false, shouldLoad); + this.saveSettings(id); + this.syncURL(id); }; - setOffset = (id: string, offset: number, reloadModel?: boolean): void => { - // TODO: implement + setOffset = (id: string, offset: number, reloadModel = true): void => { + let shouldLoad = false; + this.updateModel(id, (model: Draft) => { + if (model.offset !== offset) { + model.offset = offset; + shouldLoad = true; + } + }); + this.maybeLoad(id, false, reloadModel && shouldLoad); + this.syncURL(id); }; setSchemaQuery = (id: string, schemaQuery: SchemaQuery, loadSelections?: boolean): void => { @@ -346,7 +409,16 @@ class QueryModelManager { }; setSorts = (id: string, sorts: QuerySort[]): void => { - // TODO: implement + let shouldLoad = false; + this.updateModel(id, (model: Draft) => { + if (!sortArraysEqual(model.sorts, sorts)) { + shouldLoad = true; + model.sorts = sorts; + } + }); + this.maybeLoad(id, false, shouldLoad); + this.saveSettings(id); + this.syncURL(id); }; setView = (id: string, viewName: string, loadSelections?: boolean): void => { From e50dc525853bf806feefab67c9ae5410cde09465 Mon Sep 17 00:00:00 2001 From: alanv Date: Sat, 18 Apr 2026 15:25:20 -0500 Subject: [PATCH 06/23] QueryModelLoader: add loadTotalCount Move network code from withQueryModels to DefaultQueryModelLoader --- .../src/public/QueryModel/QueryModelLoader.ts | 28 +++++++++++++++++++ .../src/public/QueryModel/withQueryModels.tsx | 25 ++--------------- .../src/test/MockQueryModelLoader.ts | 5 ++++ 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/packages/components/src/public/QueryModel/QueryModelLoader.ts b/packages/components/src/public/QueryModel/QueryModelLoader.ts index 006298ba40..3b1463b26f 100644 --- a/packages/components/src/public/QueryModel/QueryModelLoader.ts +++ b/packages/components/src/public/QueryModel/QueryModelLoader.ts @@ -24,6 +24,8 @@ import { QueryInfo } from '../QueryInfo'; import { naturalSortByProperty } from '../sort'; import { GridMessage, QueryModel } from './QueryModel'; +import { getSelectRowCountColumnsStr } from './utils'; +import { selectRows } from '../../internal/query/selectRows'; export function bindColumnRenderers(columns: ExtendedMap): ExtendedMap { if (columns) { @@ -86,6 +88,8 @@ export interface QueryModelLoader { */ loadSelections: (model: QueryModel, requestHandler?: RequestHandler) => Promise>; + loadTotalCount: (model: QueryModel, requestHandler?: RequestHandler) => Promise; + /** * Replaces the currently selected items with the given set of selections. * @param model: QueryModel @@ -172,6 +176,30 @@ export const DefaultQueryModelLoader: QueryModelLoader = { ); return new Set(result?.selected ?? []); }, + async loadTotalCount(model: QueryModel, requestHandler: RequestHandler): Promise { + const loadRowsConfig = model.loadRowsConfig; + const queryInfo = model.queryInfo; + const columns = getSelectRowCountColumnsStr( + loadRowsConfig.columns, + loadRowsConfig.filterArray, + queryInfo?.getPkCols() + ); + + const { rowCount } = await selectRows({ + ...loadRowsConfig, + columns, + includeDetailsColumn: false, + // includeMetadata: false, // TODO don't require metadata in selectRows response processing + includeTotalCount: true, + includeUpdateColumn: false, + maxRows: 1, + offset: 0, + sort: undefined, + requestHandler, + }); + + return rowCount; + }, setSelections(model, checked, selections) { const { selectionKey, selectionContainerPath } = model; return setSelected(selectionKey, checked, selections, selectionContainerPath); diff --git a/packages/components/src/public/QueryModel/withQueryModels.tsx b/packages/components/src/public/QueryModel/withQueryModels.tsx index 65dfa718d2..f546c567d5 100644 --- a/packages/components/src/public/QueryModel/withQueryModels.tsx +++ b/packages/components/src/public/QueryModel/withQueryModels.tsx @@ -10,8 +10,6 @@ import { QuerySort } from '../QuerySort'; import { isLoading, LoadingState } from '../LoadingState'; import { resolveErrorMessage } from '../../internal/util/messaging'; -import { selectRows } from '../../internal/query/selectRows'; - import { incrementClientSideMetricCount } from '../../internal/actions'; import { @@ -19,7 +17,6 @@ import { bindURL, columnsHaveFilter, filterArraysEqual, - getSelectRowCountColumnsStr, initModels, paramsEqual, RequestManager, @@ -624,27 +621,11 @@ export function withQueryModels( ); try { - const loadRowsConfig = this.state.queryModels[id].loadRowsConfig; - const queryInfo = this.state.queryModels[id].queryInfo; - const columns = getSelectRowCountColumnsStr( - loadRowsConfig.columns, - loadRowsConfig.filterArray, - queryInfo?.getPkCols() + const rowCount = await this.props.modelLoader.loadTotalCount( + this.state.queryModels[id], + this.requestManager.getRequestHandler(id, 'loadTotalCount') ); - const { rowCount } = await selectRows({ - ...loadRowsConfig, - columns, - includeDetailsColumn: false, - // includeMetadata: false, // TODO don't require metadata in selectRows response processing - includeTotalCount: true, - includeUpdateColumn: false, - maxRows: 1, - offset: 0, - sort: undefined, - requestHandler: this.requestManager.getRequestHandler(id, 'loadTotalCount'), - }); - this.setState( produce((draft: WritableDraft) => { const model = draft.queryModels[id]; diff --git a/packages/components/src/test/MockQueryModelLoader.ts b/packages/components/src/test/MockQueryModelLoader.ts index 65a645ab74..32b2950255 100644 --- a/packages/components/src/test/MockQueryModelLoader.ts +++ b/packages/components/src/test/MockQueryModelLoader.ts @@ -2,6 +2,7 @@ import { QueryModelLoader, RowsResponse } from '../public/QueryModel/QueryModelL import { QueryInfo } from '../public/QueryInfo'; import { QueryModel } from '../public/QueryModel/QueryModel'; import { SelectResponse } from '../internal/actions'; +import { RequestHandler } from '../internal/request'; export class MockQueryModelLoader implements QueryModelLoader { queryInfo: QueryInfo; @@ -52,6 +53,10 @@ export class MockQueryModelLoader implements QueryModelLoader { return Promise.reject('Not implemented!'); }; + loadTotalCount = async (model: QueryModel, requestHandler: RequestHandler) => { + return this.rowsResponse.orderedRows.length; + }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars setSelections = (model: QueryModel, checked: boolean, selections: string[]): Promise => { return new Promise(resolve => { From ae9dcebda7cf411c9c40086650453b4657002bb8 Mon Sep 17 00:00:00 2001 From: alanv Date: Sat, 18 Apr 2026 15:46:46 -0500 Subject: [PATCH 07/23] QueryModelManager: implement loadAllModels, loadCharts, loadModel, loadQueryInfo, loadAllQueryInfos useQueryModels: add options, wire up QueryModelLoader --- .../src/public/QueryModel/useQueryModels.ts | 112 +++++++++++++++--- 1 file changed, 98 insertions(+), 14 deletions(-) diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts index 416ad8737c..afe4b2f25d 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.ts +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -12,12 +12,15 @@ import { QueryConfig, QueryConfigMap, QueryModel, + removeSettingsFromLocalStorage, saveSettingsToLocalStorage, } from './QueryModel'; import { applySavedSettings, bindURL, initModels, paramsEqual, RequestManager, sortArraysEqual } from './utils'; import { SchemaQuery } from '../SchemaQuery'; import { QuerySort } from '../QuerySort'; import { isLoading, LoadingState } from '../LoadingState'; +import { DefaultQueryModelLoader, QueryModelLoader } from './QueryModelLoader'; +import { resolveErrorMessage } from '../../internal/util/messaging'; const NOOP = () => {}; const DEFAULT_SEARCH_PARAMS = new URLSearchParams(); @@ -31,12 +34,18 @@ class QueryModelManager { actions: Actions; state: InjectedQueryModels; + private modelLoader: QueryModelLoader; private onStateChange: VoidFn; private requestManager: RequestManager; private searchParams: URLSearchParams; private setSearchParams: SetURLSearchParams; - constructor(queryConfigs: QueryConfigMap, searchParams: URLSearchParams, setSearchParams: SetURLSearchParams) { + constructor( + queryConfigs: QueryConfigMap, + searchParams: URLSearchParams, + setSearchParams: SetURLSearchParams, + modelLoader?: QueryModelLoader + ) { this.requestManager = new RequestManager(); this.searchParams = searchParams; this.setSearchParams = setSearchParams; @@ -72,6 +81,7 @@ class QueryModelManager { queryModels: initModels(queryConfigs, searchParams), actions: this.actions, }; + this.modelLoader = modelLoader ?? DefaultQueryModelLoader; } updateRouter = (searchParams: URLSearchParams, setSearchParams: SetURLSearchParams) => { @@ -252,12 +262,37 @@ class QueryModelManager { // TODO: implement }; - loadAllModels = (loadSelections?: boolean, reloadTotalCount?: boolean): void => { - // TODO: implement + loadAllModels = (loadSelections = false, reloadTotalCount = true): void => { + Object.keys(this.state.queryModels).forEach(id => this.loadModel(id, loadSelections, reloadTotalCount)); }; - loadCharts = (id: string): void => { - // TODO: implement + loadCharts = async (id: string): Promise => { + this.updateModel(id, (model: Draft) => { + model.chartsLoadingState = LoadingState.LOADING; + }); + + try { + const charts = await this.modelLoader.loadCharts(this.state.queryModels[id]); + this.updateModel(id, (model: Draft) => { + model.charts = charts; + model.chartsLoadingState = LoadingState.LOADED; + model.chartsError = undefined; + }); + } catch (error) { + this.updateModel(id, (model: Draft) => { + let chartsError = resolveErrorMessage(error); + + if (chartsError === undefined) { + const schemaQuery = model.schemaQuery.toString(); + chartsError = `Error while loading selections for SchemaQuery: ${schemaQuery}`; + } + + console.error(`Error loading charts for model ${id}`, chartsError); + removeSettingsFromLocalStorage(this.state.queryModels[id]); + model.chartsLoadingState = LoadingState.LOADED; + model.chartsError = chartsError; + }); + } }; loadFirstPage = (id: string): void => { @@ -285,8 +320,8 @@ class QueryModelManager { this.syncURL(id); }; - loadModel = (id: string, loadSelections?: boolean, reloadTotalCount?: boolean): void => { - // TODO: implement + loadModel = (id: string, loadSelections = false, reloadTotalCount = false): void => { + this.loadQueryInfo(id, true, loadSelections, reloadTotalCount); }; loadNextPage = (id: string): void => { @@ -313,8 +348,45 @@ class QueryModelManager { this.syncURL(id); }; - loadQueryInfo = (id: string, loadRows: boolean, loadSelections: boolean): void => { - // TODO: implement + loadQueryInfo = async ( + id: string, + loadRows = false, + loadSelections = false, + reloadTotalCount = false + ): Promise => { + if (!this.state.queryModels[id]) return; + + this.updateModel(id, (model: Draft) => { + model.queryInfoLoadingState = LoadingState.LOADING; + }); + + try { + const queryInfo = await this.modelLoader.loadQueryInfo(this.state.queryModels[id]); + this.updateModel(id, (model: Draft) => { + model.queryInfo = queryInfo; + model.queryInfoLoadingState = LoadingState.LOADED; + model.queryInfoError = undefined; + model.viewError = undefined; + }); + this.maybeLoad(id, false, loadRows, loadSelections, reloadTotalCount); + } catch (error) { + this.updateModel(id, (model: Draft) => { + let queryInfoError = resolveErrorMessage(error); + + if (queryInfoError === undefined) { + queryInfoError = `Error while loading QueryInfo for SchemaQuery: ${model.schemaQuery.toString()}`; + } + + console.error(`Error loading QueryInfo for model ${id}:`, queryInfoError); + removeSettingsFromLocalStorage(this.state.queryModels[id]); + model.queryInfoLoadingState = LoadingState.LOADED; + model.queryInfoError = queryInfoError; + }); + } + }; + + loadAllQueryInfos = (): void => { + Object.keys(this.state.queryModels).forEach(id => this.loadQueryInfo(id, false, false)); }; loadRows = (id: string, loadSelections = false, selectionsForReplace?: string[]): void => { @@ -400,7 +472,7 @@ class QueryModelManager { this.syncURL(id); }; - setSchemaQuery = (id: string, schemaQuery: SchemaQuery, loadSelections?: boolean): void => { + setSchemaQuery = (id: string, schemaQuery: SchemaQuery, loadSelections = false): void => { // TODO: implement }; @@ -421,7 +493,7 @@ class QueryModelManager { this.syncURL(id); }; - setView = (id: string, viewName: string, loadSelections?: boolean): void => { + setView = (id: string, viewName: string, loadSelections = false): void => { // TODO: implement }; } @@ -442,22 +514,34 @@ function useOptionalSearchParams(): OptionalSearchParams { return [searchParams, setSearchParams]; } -export function useQueryModels(queryConfigs: QueryConfigMap) { +interface UseQueryModelsOptions { + autoLoad?: boolean; + modelLoader?: QueryModelLoader; +} + +export function useQueryModels(queryConfigs: QueryConfigMap, options: UseQueryModelsOptions = {}): InjectedQueryModels { + const { autoLoad = false, modelLoader } = options; const [searchParams, setSearchParams] = useOptionalSearchParams(); const manager = useRef(null); /* eslint-disable react-hooks/refs */ if (!manager.current) { - manager.current = new QueryModelManager(queryConfigs, searchParams, setSearchParams); + manager.current = new QueryModelManager(queryConfigs, searchParams, setSearchParams, modelLoader); } const state = useSyncExternalStore(manager.current.subscribe, manager.current.getSnapshot); useEffect(() => { + // Note: It's not ideal because it will try to load selections for models that aren't active or aren't used in + // grids (e.g., details models). We should add a loadSelections attribute to QueryModel so we can opt in to + // loading selections for our grid models only. See Issue 48758 for additional context. + if (autoLoad) manager.current.loadAllModels(true); + else manager.current.loadAllQueryInfos(); + return () => { manager.current.destroy(); }; - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps -- only run on mount useEffect(() => { manager.current.updateRouter(searchParams, setSearchParams); From 245696a4e89da8792dc38addf319ffb1e5ad3fec Mon Sep 17 00:00:00 2001 From: alanv Date: Sat, 18 Apr 2026 16:18:54 -0500 Subject: [PATCH 08/23] QueryModelManager: implement loadTotalCount, loadSelections, loadRows --- .../src/public/QueryModel/useQueryModels.ts | 185 +++++++++++++++++- 1 file changed, 178 insertions(+), 7 deletions(-) diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts index afe4b2f25d..af813c9b21 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.ts +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -15,12 +15,23 @@ import { removeSettingsFromLocalStorage, saveSettingsToLocalStorage, } from './QueryModel'; -import { applySavedSettings, bindURL, initModels, paramsEqual, RequestManager, sortArraysEqual } from './utils'; +import { + applySavedSettings, + bindURL, + initModels, + paramsEqual, + RequestManager, + resetRowsState, + resetSelectionState, + resetTotalCountState, + sortArraysEqual, +} from './utils'; import { SchemaQuery } from '../SchemaQuery'; import { QuerySort } from '../QuerySort'; import { isLoading, LoadingState } from '../LoadingState'; import { DefaultQueryModelLoader, QueryModelLoader } from './QueryModelLoader'; import { resolveErrorMessage } from '../../internal/util/messaging'; +import { incrementClientSideMetricCount } from '../../internal/actions'; const NOOP = () => {}; const DEFAULT_SEARCH_PARAMS = new URLSearchParams(); @@ -389,16 +400,176 @@ class QueryModelManager { Object.keys(this.state.queryModels).forEach(id => this.loadQueryInfo(id, false, false)); }; - loadRows = (id: string, loadSelections = false, selectionsForReplace?: string[]): void => { - // TODO: implement + loadRows = async (id: string, loadSelections = false, selectionsForReplace?: string[]): Promise => { + // Issue 53192 + if (!this.state.queryModels[id].isQueryInfoLoaded) return; + + this.updateModel(id, (model: Draft) => { + model.rowsLoadingState = LoadingState.LOADING; + model.selectionsError = undefined; + }); + + try { + // If we have selectionsForReplace, then skip request cancellation optimization + const requestHandler = selectionsForReplace + ? undefined + : this.requestManager.getRequestHandler(id, 'loadRows'); + const { messages, rows, orderedRows, rowCount } = await this.modelLoader.loadRows( + this.state.queryModels[id], + requestHandler + ); + this.updateModel(id, (model: Draft) => { + model.messages = messages; + model.rows = rows; + model.orderedRows = orderedRows; + // only update the rowCount on the model if we aren't loading the totalCount + model.rowCount = !model.includeTotalCount ? rowCount : model.rowCount; + model.rowsLoadingState = LoadingState.LOADED; + model.rowsError = undefined; + model.selectionPivot = undefined; + }); + this.maybeLoad(id, false, false, loadSelections, false, selectionsForReplace); + } catch (error) { + if (error?.status === 0) return; + + let shouldAttemptLoadAgain = false; + this.updateModel(id, (model: Draft) => { + const calcFieldNames = model.queryInfo + .getAllColumns() + .filter(c => c.isCalculatedField) + .map(c => c.fieldKey); // Issue 53325 + let rowsError = resolveErrorMessage(error, 'data', undefined, 'load'); + + if (rowsError === undefined) { + rowsError = `Error while loading rows for SchemaQuery: ${model.schemaQuery.toString()}`; + } + + console.error(`Error loading rows for model ${id}: `, rowsError); + removeSettingsFromLocalStorage(this.state.queryModels[id]); + + if ( + rowsError?.indexOf('The requested view') === 0 && + rowsError?.indexOf(' does not exist for this user.') > 0 + ) { + // Issue 49378: if the view doesn't exist, use the default view + shouldAttemptLoadAgain = true; + model.schemaQuery = new SchemaQuery(model.schemaName, model.queryName); + resetRowsState(model); + resetTotalCountState(model); + resetSelectionState(model); + model.viewError = rowsError + ' Returning to the default view.'; + incrementClientSideMetricCount('QueryModel', 'ViewDoesNotExist'); + } else if (!model.viewError && calcFieldNames.length > 0) { + // Issue 51204: if we have a calculated field, they are likely causing the problem so retry without them + shouldAttemptLoadAgain = true; + model.omittedColumns = model.omittedColumns.concat(calcFieldNames); + resetRowsState(model); + resetTotalCountState(model); + resetSelectionState(model); + model.viewError = + rowsError + + (rowsError.endsWith('.') ? '' : '.') + + ' All calculated fields have been omitted from the view.'; + incrementClientSideMetricCount('QueryModel', 'CalculatedFieldError'); + } else { + model.rowsLoadingState = LoadingState.LOADED; + model.rowsError = rowsError; + model.selectionPivot = undefined; + } + }); + + if (shouldAttemptLoadAgain) { + this.maybeLoad(id, false, true, true, true); + this.syncURL(id); + this.saveSettings(id); + } + } }; - loadSelections = (id: string): void => { - // TODO: implement + setSelectionsError = (id: string, error: any, action: string): void => { + this.updateModel(id, (model: Draft) => { + let selectionsError = resolveErrorMessage(error); + + if (selectionsError === undefined) { + const schemaQuery = model.schemaQuery.toString(); + selectionsError = `Error while ${action} selections for SchemaQuery: ${schemaQuery}`; + } + + console.error(`Error setting selections for model ${id}:`, selectionsError); + model.selectionsError = selectionsError; + model.selectionsLoadingState = LoadingState.LOADED; + removeSettingsFromLocalStorage(this.state.queryModels[id]); + }); }; - loadTotalCount = (id: string, reloadTotalCount: boolean): void => { - // TODO: implement + loadSelections = async (id: string): Promise => { + this.updateModel(id, (model: Draft) => { + model.selectionsLoadingState = LoadingState.LOADING; + }); + + try { + const selections = await this.modelLoader.loadSelections( + this.state.queryModels[id], + this.requestManager.getRequestHandler(id, 'loadSelections') + ); + + this.updateModel(id, (model: Draft) => { + model.selections = selections; + model.selectionsLoadingState = LoadingState.LOADED; + model.selectionsError = undefined; + }); + } catch (error) { + if (error?.status === 0) return; + this.setSelectionsError(id, error, 'loading'); + } + }; + + loadTotalCount = async (id: string, reloadTotalCount = 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) { + return; + } + + // if usage didn't request loading the totalCount, skip it + if (!this.state.queryModels[id].includeTotalCount) { + this.updateModel(id, (model: Draft) => { + model.totalCountLoadingState = LoadingState.LOADED; + }); + return; + } + + this.updateModel(id, (model: Draft) => { + model.totalCountLoadingState = LoadingState.LOADING; + }); + + try { + const rowCount = await this.modelLoader.loadTotalCount( + this.state.queryModels[id], + this.requestManager.getRequestHandler(id, 'loadTotalCount') + ); + this.updateModel(id, (model: Draft) => { + model.rowCount = rowCount; + model.totalCountLoadingState = LoadingState.LOADED; + model.totalCountError = undefined; + }); + } catch (error) { + if (error?.status === 0) return; + this.updateModel(id, (model: Draft) => { + let rowsError = resolveErrorMessage(error); + + if (rowsError === undefined) { + rowsError = `Error while loading total count for SchemaQuery: ${model.schemaQuery.toString()}`; + } + + console.error(`Error loading rows for model ${id}: `, rowsError); + removeSettingsFromLocalStorage(this.state.queryModels[id]); + model.totalCountLoadingState = LoadingState.LOADED; + model.totalCountError = rowsError; + }); + } }; onModelChange = (id: string, modelChange: ModelChange): void => { From 00da7319600a8bc73e6d5dea7a3c12a0893bd107 Mon Sep 17 00:00:00 2001 From: alanv Date: Sat, 18 Apr 2026 16:25:59 -0500 Subject: [PATCH 09/23] QueryModelManager: implement replaceSelections, resetTotalCountState, selectAllRows, selectPage, selectReport --- .../src/public/QueryModel/useQueryModels.ts | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts index af813c9b21..1151e3620f 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.ts +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -584,28 +584,76 @@ class QueryModelManager { }); }; - replaceSelections = (id: string, selections: string[]): void => { - // TODO: implement + replaceSelections = async (id: string, selections: string[]): Promise => { + this.updateModel(id, (model: Draft) => { + model.selectionsLoadingState = LoadingState.LOADING; + }); + + try { + await this.modelLoader.replaceSelections(this.state.queryModels[id], selections); + this.updateModel(id, (model: Draft) => { + model.selections = new Set(selections); + model.selectionsError = undefined; + model.selectionPivot = undefined; + model.selectionsLoadingState = LoadingState.LOADED; + }); + } catch (error) { + if (error?.status === 0) return; + this.setSelectionsError(id, error, 'replace'); + } }; + /** + * Reset the totalCount state for all models so that the next time loadModel or loadAllModels() is called, + * it will also call the loadTotalCount(). + */ resetTotalCountState = (): void => { - // TODO: implement + this.setState(state => { + const queryModels = {}; + Object.keys(state.queryModels).forEach(id => { + const model = state.queryModels[id]; + queryModels[id] = produce(model, resetTotalCountState); + }); + return { ...state, queryModels }; + }); }; saveSettings = (id: string): void => { saveSettingsToLocalStorage(this.state.queryModels[id]); }; - selectAllRows = (id: string): void => { - // TODO: implement + selectAllRows = async (id: string): Promise => { + this.updateModel(id, (model: Draft) => { + model.selectionsLoadingState = LoadingState.LOADING; + }); + + try { + const selections = await this.modelLoader.selectAllRows(this.state.queryModels[id]); + this.updateModel(id, (model: Draft) => { + model.selections = selections; + model.selectionsError = undefined; + model.selectionPivot = undefined; + model.selectionsLoadingState = LoadingState.LOADED; + }); + } catch (error) { + if (error?.status === 0) return; + this.setSelectionsError(id, error, 'setting'); + } }; selectPage = (id: string, checked: boolean): void => { - // TODO: implement + this.setSelections(id, checked, this.state.queryModels[id].orderedRows); }; selectReport = (id: string, reportId: string, selected: boolean): void => { - // TODO: implement + this.updateModel(id, (model: Draft) => { + if (selected && !model.selectedReportIds.includes(reportId)) { + model.selectedReportIds.push(reportId); + } else if (!selected) { + model.selectedReportIds = model.selectedReportIds.filter(id => id !== reportId); + } + }); + this.syncURL(id); }; // eslint-disable-next-line @typescript-eslint/no-explicit-any From 52f568260cb31f8743a173528d3a4e7305e4c028 Mon Sep 17 00:00:00 2001 From: alanv Date: Sat, 18 Apr 2026 16:56:44 -0500 Subject: [PATCH 10/23] QueryModelManager: implement clearSelections, onModelChange, selectRow, setFilters, setSchemaQuery, setSelections, setView --- .../src/public/QueryModel/useQueryModels.ts | 180 ++++++++++++++++-- 1 file changed, 168 insertions(+), 12 deletions(-) diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts index 1151e3620f..745a39cb44 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.ts +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -5,6 +5,7 @@ import { Filter } from '@labkey/api'; import { Actions, + ChangeType, GridMessage, InjectedQueryModels, locationHasQueryParamSettings, @@ -18,9 +19,12 @@ import { import { applySavedSettings, bindURL, + columnsHaveFilter, + filterArraysEqual, initModels, paramsEqual, RequestManager, + resetModelState, resetRowsState, resetSelectionState, resetTotalCountState, @@ -269,8 +273,30 @@ class QueryModelManager { this.syncURL(id); }; - clearSelections = (id: string): void => { - // TODO: implement + clearSelections = async (id: string): Promise => { + const loading = this.state.queryModels[id].selectionsLoadingState === LoadingState.LOADING; + + if (!loading) { + this.updateModel(id, (model: Draft) => { + model.selectionsLoadingState = LoadingState.LOADING; + }); + } + + try { + await this.modelLoader.clearSelections(this.state.queryModels[id]); + + this.updateModel(id, (model: Draft) => { + model.selections = new Set(); + model.selectionPivot = undefined; + model.selectionsError = undefined; + + if (!loading) { + model.selectionsLoadingState = LoadingState.LOADED; + } + }); + } catch (error) { + this.setSelectionsError(id, error, 'clearing'); + } }; loadAllModels = (loadSelections = false, reloadTotalCount = true): void => { @@ -573,7 +599,36 @@ class QueryModelManager { }; onModelChange = (id: string, modelChange: ModelChange): void => { - // TODO: implement + let shouldLoadTotalCount = false; + let selectionsForReplace: string[]; + + this.updateModel(id, (model: Draft) => { + if (modelChange.changeType === ChangeType.add) { + shouldLoadTotalCount = true; + } else if (modelChange.changeType === ChangeType.delete) { + selectionsForReplace = modelChange.options?.selectionsForReplace ?? []; + shouldLoadTotalCount = true; + resetModelState(model); + } else if (modelChange.changeType === ChangeType.update) { + const columnsChanged = modelChange.options?.columnsChanged; + const hasChangeOnFilteredColumn = + columnsChanged !== undefined && columnsHaveFilter(columnsChanged, model.filters); + const unknownChangeWithFilters = columnsChanged === undefined && model.filters.length > 0; + + // If we don't know what columns were changed, and we have filters, or there is a change on a + // column with filters, then we need to reset the model, otherwise the grid could be put in a + // bad state. See Issue 51897. + if (hasChangeOnFilteredColumn || unknownChangeWithFilters) { + shouldLoadTotalCount = true; + resetModelState(model); + } + } + }); + + // Loading & replacing selections are mutually exclusive, if we aren't replacing anything then load + const loadSelections = selectionsForReplace === undefined; + this.maybeLoad(id, false, true, loadSelections, shouldLoadTotalCount, selectionsForReplace); + this.syncURL(id); }; removeMessage = (id: string, message: GridMessage) => { @@ -598,7 +653,6 @@ class QueryModelManager { model.selectionsLoadingState = LoadingState.LOADED; }); } catch (error) { - if (error?.status === 0) return; this.setSelectionsError(id, error, 'replace'); } }; @@ -636,7 +690,6 @@ class QueryModelManager { model.selectionsLoadingState = LoadingState.LOADED; }); } catch (error) { - if (error?.status === 0) return; this.setSelectionsError(id, error, 'setting'); } }; @@ -658,11 +711,64 @@ class QueryModelManager { // eslint-disable-next-line @typescript-eslint/no-explicit-any selectRow = (id: string, checked: boolean, row: Record, useSelectionPivot?: boolean): void => { - // TODO: implement + const model = this.state.queryModels[id]; + const pkCols = model.queryInfo.getPkCols(); + + if (pkCols.length === 1) { + const pkValue = row[pkCols[0].name]?.value?.toString(); + + if (!pkValue) { + console.warn(`Unable to resolve PK value for model ${id} row`, row); + return; + } + + if (useSelectionPivot && model.selectionPivot) { + const pivotIdx = model.orderedRows.findIndex(key => key === model.selectionPivot.selection); + const selectedIdx = model.orderedRows.findIndex(key => key === pkValue); + + // If we cannot make sense of the indices, then just treat this as a normal selection + if (pivotIdx === -1 || selectedIdx === -1 || pivotIdx === selectedIdx) { + this.setSelections(id, checked, [pkValue]); + return; + } + + // Select all rows relative to/from the pivot row + let selections: string[]; + if (pivotIdx < selectedIdx) { + selections = model.orderedRows.slice(pivotIdx + 1, selectedIdx + 1); + } else { + selections = model.orderedRows.slice(selectedIdx, pivotIdx); + } + + this.setSelections(id, model.selectionPivot.checked, selections); + } else { + this.setSelections(id, checked, [pkValue]); + } + } else { + const msg = `Cannot set row selection for model ${id}. The model has multiple PK Columns.`; + console.warn(msg, pkCols); + } }; - setFilters = (id: string, filters: Filter.IFilter[], loadSelections?: boolean): void => { - // TODO: implement + setFilters = (id: string, filters: Filter.IFilter[], loadSelections = false): void => { + let shouldLoad = false; + this.updateModel(id, (model: Draft) => { + if (!filterArraysEqual(model.filterArray, filters)) { + shouldLoad = true; + model.filterArray = filters; + // Changing filters affects row count, so we need to reset the offset, or pagination can get into an + // impossible state (e.g., page 3 on a grid with one row of data). + model.offset = 0; + model.totalCountLoadingState = LoadingState.INITIALIZED; + if (shouldLoad && loadSelections) { + model.selectionsLoadingState = LoadingState.INITIALIZED; + } + } + }); + // When filters change, we need to reload selections and counts. + this.maybeLoad(id, false, shouldLoad, shouldLoad && loadSelections, true); + this.saveSettings(id); + this.syncURL(id); }; setMaxRows = (id: string, maxRows: number): void => { @@ -692,11 +798,47 @@ class QueryModelManager { }; setSchemaQuery = (id: string, schemaQuery: SchemaQuery, loadSelections = false): void => { - // TODO: implement + // Note: we don't use the setSchemaQuery method anywhere, we should remove it from Actions + throw new Error('setSchemaQuery is not implemented'); }; - setSelections = (id: string, checked: boolean, selections: string[]): void => { - // TODO: implement + setSelections = async (id: string, checked: boolean, selections: string[]): Promise => { + const loading = this.state.queryModels[id].selectionsLoadingState === LoadingState.LOADING; + + if (!loading) { + this.updateModel(id, (model: Draft) => { + model.selectionsLoadingState = LoadingState.LOADING; + }); + } + + try { + await this.modelLoader.setSelections(this.state.queryModels[id], checked, selections); + this.updateModel(id, (model: Draft) => { + // If there are selections made, then ensure the model.selections is initialized + if (!model.selections && selections.length > 0) { + model.selections = new Set(); + } + + selections.forEach(selection => { + if (checked) { + model.selections.add(selection); + } else { + model.selections.delete(selection); + } + }); + + // Set the selection pivot row iff a single row is selected + if (selections.length === 1) { + model.selectionPivot = { checked, selection: selections[0] }; + } + + model.selectionsError = undefined; + + if (!loading) model.selectionsLoadingState = LoadingState.LOADED; + }); + } catch (error) { + this.setSelectionsError(id, error, 'setting'); + } }; setSorts = (id: string, sorts: QuerySort[]): void => { @@ -713,7 +855,21 @@ class QueryModelManager { }; setView = (id: string, viewName: string, loadSelections = false): void => { - // TODO: implement + let shouldLoad = false; + this.updateModel(id, (model: Draft) => { + if (model.viewName !== viewName) { + shouldLoad = true; + model.schemaQuery = new SchemaQuery(model.schemaName, model.queryName, viewName); + // We need to reset all data for the model because changing the view will change things such as + // columns and rowCount. If we don't do this, we'll render a grid with empty rows/columns. + resetRowsState(model); + resetTotalCountState(model); + resetSelectionState(model); + } + }); + this.maybeLoad(id, false, shouldLoad, shouldLoad && loadSelections); + this.saveSettings(id); + this.syncURL(id); }; } From a35f87c950f739bf67e8096b27e95536ab269c1c Mon Sep 17 00:00:00 2001 From: alanv Date: Sat, 18 Apr 2026 17:15:26 -0500 Subject: [PATCH 11/23] Add useQueryModels.test.tsx --- .../public/QueryModel/useQueryModels.test.tsx | 780 ++++++++++++++++++ .../src/public/QueryModel/useQueryModels.ts | 2 +- 2 files changed, 781 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/public/QueryModel/useQueryModels.test.tsx diff --git a/packages/components/src/public/QueryModel/useQueryModels.test.tsx b/packages/components/src/public/QueryModel/useQueryModels.test.tsx new file mode 100644 index 0000000000..147b04d160 --- /dev/null +++ b/packages/components/src/public/QueryModel/useQueryModels.test.tsx @@ -0,0 +1,780 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { Filter } from '@labkey/api'; + +import { makeQueryInfo, makeTestData, sleep } from '../../internal/test/testHelpers'; +import { MockQueryModelLoader } from '../../test/MockQueryModelLoader'; +import mixturesQueryInfo from '../../test/data/mixtures-getQueryDetails.json'; +import mixturesQuery from '../../test/data/mixtures-getQueryPaging.json'; +import aminoAcidsQueryInfo from '../../test/data/assayAminoAcidsData-getQueryDetails.json'; +import aminoAcidsQuery from '../../test/data/assayAminoAcidsData-getQuery.json'; + +import { SchemaQuery } from '../SchemaQuery'; +import { QueryInfo } from '../QueryInfo'; +import { LoadingState } from '../LoadingState'; +import { QuerySort } from '../QuerySort'; + +import { QueryModel, ChangeType } from './QueryModel'; +import { RowsResponse } from './QueryModelLoader'; +import { QueryModelManager, useQueryModels } from './useQueryModels'; + +// @ts-expect-error Need to use require() for mocking +// eslint-disable-next-line @typescript-eslint/no-var-requires +const rrd = require('react-router-dom'); + +const MIXTURES_SCHEMA_QUERY = new SchemaQuery('exp.data', 'mixtures'); +const AMINO_ACIDS_SCHEMA_QUERY = new SchemaQuery('assay.General.Amino Acids', 'Runs'); +let MIXTURES_QUERY_INFO: QueryInfo; +let MIXTURES_DATA: RowsResponse; +let AMINO_ACIDS_QUERY_INFO: QueryInfo; +let AMINO_ACIDS_DATA: RowsResponse; + +beforeAll(() => { + MIXTURES_QUERY_INFO = makeQueryInfo(mixturesQueryInfo); + AMINO_ACIDS_QUERY_INFO = makeQueryInfo(aminoAcidsQueryInfo); + MIXTURES_DATA = makeTestData(mixturesQuery); + AMINO_ACIDS_DATA = makeTestData(aminoAcidsQuery); +}); + +/** + * Extends MockQueryModelLoader with resolvable implementations for loadSelections, replaceSelections, selectAllRows, + * and loadCharts so selection/chart paths are testable. + */ +class TestQueryModelLoader extends MockQueryModelLoader { + selections = new Set(); + charts: any[] = []; + + loadSelections = jest.fn(async () => { + await sleep(); + return new Set(this.selections); + }); + + replaceSelections = jest.fn(async (_model: QueryModel, selections: string[]) => { + await sleep(); + this.selections = new Set(selections); + return { count: this.selections.size }; + }); + + selectAllRows = jest.fn(async (model: QueryModel) => { + await sleep(); + const all = new Set(model.orderedRows ?? []); + this.selections = all; + return new Set(all); + }); + + loadCharts = jest.fn(async () => { + await sleep(); + return this.charts.slice(); + }); +} + +const makeManager = ( + configs: Record>, + loader: MockQueryModelLoader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA), + searchParams: URLSearchParams = new URLSearchParams(), + setSearchParams: jest.Mock = jest.fn() +) => { + const manager = new QueryModelManager(configs, searchParams, setSearchParams, loader); + // Register a no-op subscriber so onStateChange calls don't throw. + manager.subscribe(() => {}); + return { manager, loader, setSearchParams }; +}; + +describe('QueryModelManager', () => { + describe('constructor', () => { + test('initializes models from configs', () => { + const { manager } = makeManager({ a: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + const model = manager.state.queryModels.a; + expect(model).toBeDefined(); + expect(model.id).toBe('a'); + expect(model.schemaQuery).toBe(MIXTURES_SCHEMA_QUERY); + expect(model.queryInfoLoadingState).toBe(LoadingState.INITIALIZED); + expect(model.rowsLoadingState).toBe(LoadingState.INITIALIZED); + }); + + test('exposes actions reference on state', () => { + const { manager } = makeManager({ a: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + expect(manager.state.actions).toBe(manager.actions); + expect(typeof manager.actions.loadModel).toBe('function'); + }); + + test('reads bindURL params from initial searchParams', () => { + const searchParams = new URLSearchParams({ 'query.p': '3' }); + const { manager } = makeManager( + { model: { schemaQuery: MIXTURES_SCHEMA_QUERY, bindURL: true } }, + undefined, + searchParams + ); + expect(manager.state.queryModels.model.offset).toBe(40); // maxRows 20 * (page 3 - 1) + }); + }); + + describe('loadQueryInfo + loadRows', () => { + test('happy path through loadModel', async () => { + const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + manager.loadModel('model'); + expect(manager.state.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADING); + await sleep(); + await sleep(); + const model = manager.state.queryModels.model; + expect(model.queryInfoLoadingState).toBe(LoadingState.LOADED); + expect(model.rowsLoadingState).toBe(LoadingState.LOADED); + expect(model.queryInfo).toBeDefined(); + expect(model.rows).toBeDefined(); + expect(model.orderedRows.length).toBeGreaterThan(0); + }); + + test('loadRows bails when queryInfo not loaded (Issue 53192)', async () => { + const { manager, loader } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + await manager.loadRows('model'); + expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.INITIALIZED); + expect((loader as TestQueryModelLoader).loadCharts).not.toHaveBeenCalled(); + }); + + test('surfaces queryInfo error', async () => { + const { manager, loader } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + loader.queryInfoException = { exception: 'QI boom' }; + manager.loadModel('model'); + await sleep(); + const model = manager.state.queryModels.model; + expect(model.queryInfoLoadingState).toBe(LoadingState.LOADED); + expect(model.queryInfoError).toBe('QI boom'); + }); + + test('surfaces rows error', async () => { + const { manager, loader } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + manager.loadModel('model'); + await sleep(); + loader.rowsException = { exception: 'rows boom' }; + await manager.loadRows('model'); + const model = manager.state.queryModels.model; + expect(model.rowsLoadingState).toBe(LoadingState.LOADED); + expect(model.rowsError).toBe('rows boom'); + }); + + test('view-does-not-exist recovery falls back to default view (Issue 49378)', async () => { + const viewError = + 'The requested view \'bogus\' does not exist for this user.'; + const setSearchParams = jest.fn(); + const { manager, loader } = makeManager( + { + model: { + schemaQuery: new SchemaQuery('exp.data', 'mixtures', 'bogus'), + bindURL: true, + }, + }, + undefined, + new URLSearchParams(), + setSearchParams + ); + manager.loadModel('model'); + await sleep(); + loader.rowsException = { exception: viewError }; + await manager.loadRows('model'); + // First failure — schemaQuery should reset to default view and trigger retry. + let model = manager.state.queryModels.model; + expect(model.schemaQuery.viewName).toBeUndefined(); + expect(model.viewError).toContain('Returning to the default view.'); + // The retry is scheduled via maybeLoad — clear the exception so it succeeds. + loader.rowsException = undefined; + await sleep(); + await sleep(); + model = manager.state.queryModels.model; + expect(model.rowsLoadingState).toBe(LoadingState.LOADED); + expect(setSearchParams).toHaveBeenCalled(); + }); + + test('cancelled request (status 0) is swallowed', async () => { + const { manager, loader } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + manager.loadModel('model'); + await sleep(); + loader.rowsException = { status: 0 }; + await manager.loadRows('model'); + // Error should NOT surface, state stays LOADING because the short-circuit returned. + expect(manager.state.queryModels.model.rowsError).toBeUndefined(); + expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADING); + }); + }); + + describe('loadTotalCount', () => { + test('short-circuits to LOADED when includeTotalCount is false', async () => { + const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + manager.loadModel('model'); + await sleep(); + await sleep(); + expect(manager.state.queryModels.model.totalCountLoadingState).toBe(LoadingState.LOADED); + }); + + test('loads count when includeTotalCount is true', async () => { + const { manager } = makeManager({ + model: { schemaQuery: MIXTURES_SCHEMA_QUERY, includeTotalCount: true }, + }); + manager.loadModel('model'); + await sleep(); + await sleep(); + const model = manager.state.queryModels.model; + expect(model.totalCountLoadingState).toBe(LoadingState.LOADED); + expect(model.rowCount).toBe(MIXTURES_DATA.orderedRows.length); + }); + + test('skips load when already loaded and reload flag is false', async () => { + const { manager, loader } = makeManager({ + model: { schemaQuery: MIXTURES_SCHEMA_QUERY, includeTotalCount: true }, + }); + manager.loadModel('model'); + await sleep(); + await sleep(); + const spy = jest.spyOn(loader, 'loadTotalCount'); + await manager.loadTotalCount('model', false); + expect(spy).not.toHaveBeenCalled(); + await manager.loadTotalCount('model', true); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('pagination', () => { + const setup = async () => { + const result = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + result.manager.loadModel('model'); + await sleep(); + await sleep(); + return result; + }; + + test('loadFirstPage on first page is a no-op', async () => { + const { manager } = await setup(); + const before = manager.state; + manager.loadFirstPage('model'); + expect(manager.state.queryModels.model.offset).toBe(0); + // No reload triggered — rowsLoadingState stays LOADED. + expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); + // State reference unchanged (updateModel short-circuits when produce returns same ref). + expect(manager.state).toBe(before); + }); + + test('loadNextPage increments offset and triggers reload', async () => { + const { manager } = await setup(); + const { maxRows } = manager.state.queryModels.model; + manager.loadNextPage('model'); + expect(manager.state.queryModels.model.offset).toBe(maxRows); + expect(manager.state.queryModels.model.currentPage).toBe(2); + expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADING); + await sleep(); + expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); + }); + + test('loadLastPage sets offset and loadNextPage past the last page is a no-op', async () => { + const { manager } = await setup(); + manager.loadLastPage('model'); + const lastOffset = manager.state.queryModels.model.lastPageOffset; + expect(manager.state.queryModels.model.offset).toBe(lastOffset); + await sleep(); + const before = manager.state; + manager.loadNextPage('model'); + expect(manager.state.queryModels.model.offset).toBe(lastOffset); + expect(manager.state).toBe(before); + }); + + test('setMaxRows resets offset to 0', async () => { + const { manager } = await setup(); + manager.loadLastPage('model'); + await sleep(); + manager.setMaxRows('model', 40); + const model = manager.state.queryModels.model; + expect(model.maxRows).toBe(40); + expect(model.offset).toBe(0); + expect(model.rowsLoadingState).toBe(LoadingState.LOADING); + }); + + test('setOffset honors reloadModel flag', async () => { + const { manager } = await setup(); + manager.setOffset('model', 40, false); + expect(manager.state.queryModels.model.offset).toBe(40); + // reloadModel=false → no reload triggered + expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); + }); + }); + + describe('filter/sort/view changes', () => { + const setup = async () => { + const result = makeManager({ + model: { schemaQuery: MIXTURES_SCHEMA_QUERY, includeTotalCount: true }, + }); + result.manager.loadModel('model'); + await sleep(); + await sleep(); + return result; + }; + + test('setFilters updates filters, resets offset, triggers reload', async () => { + const { manager } = await setup(); + // Move off page 1 so we can verify offset reset. + manager.loadNextPage('model'); + await sleep(); + expect(manager.state.queryModels.model.offset).toBeGreaterThan(0); + + const filter = Filter.create('Name', 'DMXP', Filter.Types.EQUAL); + manager.setFilters('model', [filter]); + const model = manager.state.queryModels.model; + expect(model.filterArray).toHaveLength(1); + expect(model.offset).toBe(0); + // rows reload kicks off synchronously; totalCount reload follows because includeTotalCount is true. + expect(model.rowsLoadingState).toBe(LoadingState.LOADING); + expect(model.totalCountLoadingState).toBe(LoadingState.LOADING); + await sleep(); + expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); + }); + + test('setFilters no-op when filters are equal', async () => { + const { manager } = await setup(); + const f = Filter.create('Name', 'X'); + manager.setFilters('model', [f]); + await sleep(); + await sleep(); + const before = manager.state; + manager.setFilters('model', [f]); + expect(manager.state).toBe(before); + }); + + test('setSorts reloads on change, no-op when equal', async () => { + const { manager } = await setup(); + manager.setSorts('model', [new QuerySort({ fieldKey: 'Name' })]); + expect(manager.state.queryModels.model.sorts).toHaveLength(1); + expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADING); + await sleep(); + + const before = manager.state; + manager.setSorts('model', [new QuerySort({ fieldKey: 'Name' })]); + expect(manager.state).toBe(before); + }); + + test('setView resets rows and totalCount state', async () => { + const { manager } = await setup(); + manager.setView('model', 'FakeView'); + const model = manager.state.queryModels.model; + expect(model.schemaQuery.viewName).toBe('FakeView'); + // Rows cleared by resetRowsState; reload is in flight. + expect(model.rows).toBeUndefined(); + expect(model.orderedRows).toBeUndefined(); + expect(model.rowCount).toBeUndefined(); + expect(model.rowsLoadingState).toBe(LoadingState.LOADING); + await sleep(); + expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); + }); + + test('setSchemaQuery throws (intentionally unimplemented)', () => { + const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + expect(() => manager.setSchemaQuery('model', AMINO_ACIDS_SCHEMA_QUERY)).toThrow(/not implemented/); + }); + }); + + describe('selections', () => { + const setup = async () => { + const loader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA); + const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, loader); + manager.loadModel('model'); + await sleep(); + await sleep(); + return { manager, loader }; + }; + + test('setSelections adds/removes and manages selection pivot', async () => { + const { manager } = await setup(); + await manager.setSelections('model', true, ['a']); + expect(manager.state.queryModels.model.selections).toEqual(new Set(['a'])); + // Single-row selection sets pivot + expect(manager.state.queryModels.model.selectionPivot).toEqual({ checked: true, selection: 'a' }); + + // Multi-row selection does not set pivot + await manager.setSelections('model', true, ['b', 'c']); + expect(manager.state.queryModels.model.selections).toEqual(new Set(['a', 'b', 'c'])); + expect(manager.state.queryModels.model.selectionPivot).toEqual({ checked: true, selection: 'a' }); + + // Unchecking a single row updates the pivot + await manager.setSelections('model', false, ['a']); + expect(manager.state.queryModels.model.selections).toEqual(new Set(['b', 'c'])); + expect(manager.state.queryModels.model.selectionPivot).toEqual({ checked: false, selection: 'a' }); + }); + + test('selectRow with single PK column selects that row', async () => { + const { manager } = await setup(); + const firstKey = manager.state.queryModels.model.orderedRows[0]; + const firstRow = manager.state.queryModels.model.getRow(firstKey); + manager.selectRow('model', true, firstRow); + await sleep(); + expect(manager.state.queryModels.model.selections.has(firstKey)).toBe(true); + expect(manager.state.queryModels.model.selectionPivot).toEqual({ + checked: true, + selection: firstKey, + }); + }); + + test('selectRow with useSelectionPivot selects a range', async () => { + const { manager } = await setup(); + const ordered = manager.state.queryModels.model.orderedRows; + const pivotKey = ordered[0]; + manager.selectRow('model', true, manager.state.queryModels.model.getRow(pivotKey)); + await sleep(); + // Shift-click a row 5 indices away + manager.selectRow('model', true, manager.state.queryModels.model.getRow(ordered[5]), true); + await sleep(); + const selections = manager.state.queryModels.model.selections; + expect(selections.size).toBe(6); // pivot + 5 rows + }); + + test('selectPage selects all ordered rows on the page', async () => { + const { manager } = await setup(); + manager.selectPage('model', true); + await sleep(); + const model = manager.state.queryModels.model; + expect(model.selections.size).toBe(model.orderedRows.length); + }); + + test('clearSelections empties the selection set', async () => { + const { manager } = await setup(); + await manager.setSelections('model', true, ['a', 'b', 'c']); + await manager.clearSelections('model'); + expect(manager.state.queryModels.model.selections.size).toBe(0); + expect(manager.state.queryModels.model.selectionPivot).toBeUndefined(); + }); + + test('selectAllRows pulls from loader', async () => { + const { manager, loader } = await setup(); + await manager.selectAllRows('model'); + expect((loader as TestQueryModelLoader).selectAllRows).toHaveBeenCalled(); + expect(manager.state.queryModels.model.selections.size).toBeGreaterThan(0); + }); + + test('replaceSelections writes new set and clears pivot', async () => { + const { manager } = await setup(); + await manager.setSelections('model', true, ['x']); + expect(manager.state.queryModels.model.selectionPivot).toBeDefined(); + await manager.replaceSelections('model', ['1', '2', '3']); + expect(manager.state.queryModels.model.selections).toEqual(new Set(['1', '2', '3'])); + expect(manager.state.queryModels.model.selectionPivot).toBeUndefined(); + }); + + test('loadSelections populates selections from loader', async () => { + const loader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA); + loader.selections = new Set(['row-1', 'row-2']); + const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, loader); + manager.loadModel('model'); + await sleep(); + await sleep(); + await manager.loadSelections('model'); + expect(manager.state.queryModels.model.selections).toEqual(new Set(['row-1', 'row-2'])); + expect(manager.state.queryModels.model.selectionsLoadingState).toBe(LoadingState.LOADED); + }); + + test('loadSelections surfaces errors via setSelectionsError', async () => { + const loader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA); + loader.loadSelections = jest.fn(() => Promise.reject(new Error('nope'))); + const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, loader); + manager.loadModel('model'); + await sleep(); + await sleep(); + await manager.loadSelections('model'); + const model = manager.state.queryModels.model; + expect(model.selectionsError).toBeDefined(); + expect(model.selectionsLoadingState).toBe(LoadingState.LOADED); + }); + }); + + describe('reports', () => { + test('selectReport adds/removes a reportId', () => { + const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + manager.selectReport('model', 'db:1', true); + expect(manager.state.queryModels.model.selectedReportIds).toEqual(['db:1']); + manager.selectReport('model', 'db:1', false); + expect(manager.state.queryModels.model.selectedReportIds).toEqual([]); + }); + + test('clearSelectedReports empties list', () => { + const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + manager.selectReport('model', 'db:1', true); + manager.selectReport('model', 'db:2', true); + manager.clearSelectedReports('model'); + expect(manager.state.queryModels.model.selectedReportIds).toEqual([]); + }); + }); + + describe('messages', () => { + test('addMessage appends and removeMessage filters by content', () => { + const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + manager.addMessage('model', { content: 'hi' }); + expect(manager.state.queryModels.model.messages).toEqual([{ content: 'hi' }]); + manager.addMessage('model', { content: 'there' }); + manager.removeMessage('model', { content: 'hi' }); + expect(manager.state.queryModels.model.messages).toEqual([{ content: 'there' }]); + }); + + test('addMessage with duration auto-removes after timeout', () => { + jest.useFakeTimers(); + const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + manager.addMessage('model', { content: 'vanishes' }, 500); + expect(manager.state.queryModels.model.messages).toHaveLength(1); + jest.advanceTimersByTime(500); + expect(manager.state.queryModels.model.messages).toHaveLength(0); + jest.useRealTimers(); + }); + }); + + describe('onModelChange', () => { + const setup = async () => { + const loader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA); + const result = makeManager( + { model: { schemaQuery: MIXTURES_SCHEMA_QUERY, includeTotalCount: true } }, + loader + ); + result.manager.loadModel('model'); + await sleep(); + await sleep(); + return { ...result, loader }; + }; + + test('add triggers totalCount reload but preserves data', async () => { + const { manager } = await setup(); + const rowsBefore = manager.state.queryModels.model.rows; + manager.onModelChange('model', { changeType: ChangeType.add }); + // totalCount was reset to INITIALIZED so it reloads via loadTotalCount after loadRows + expect(manager.state.queryModels.model.rows).toBe(rowsBefore); + expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADING); + }); + + test('delete resets the full model and sets selectionsForReplace path', async () => { + const { manager } = await setup(); + manager.onModelChange('model', { + changeType: ChangeType.delete, + options: { selectionsForReplace: ['keep-me'] }, + }); + // resetModelState clears rows/total/selection state + const model = manager.state.queryModels.model; + expect(model.rows).toBeUndefined(); + expect(model.selectionsLoadingState).toBe(LoadingState.INITIALIZED); + await sleep(); + await sleep(); + expect(manager.state.queryModels.model.selections).toEqual(new Set(['keep-me'])); + }); + + test('update with filtered column resets the model', async () => { + const { manager } = await setup(); + manager.setFilters('model', [Filter.create('Name', 'X')]); + await sleep(); + await sleep(); + manager.onModelChange('model', { + changeType: ChangeType.update, + options: { columnsChanged: ['Name'] }, + }); + expect(manager.state.queryModels.model.rows).toBeUndefined(); + }); + + test('update without filter intersection leaves rows in place but reloads', async () => { + const { manager } = await setup(); + manager.setFilters('model', [Filter.create('Name', 'X')]); + await sleep(); + await sleep(); + const rowsBefore = manager.state.queryModels.model.rows; + manager.onModelChange('model', { + changeType: ChangeType.update, + options: { columnsChanged: ['Unrelated'] }, + }); + expect(manager.state.queryModels.model.rows).toBe(rowsBefore); + }); + }); + + describe('addModel / loadAllModels / resetTotalCountState', () => { + test('addModel without load leaves model INITIALIZED', async () => { + const { manager } = makeManager({}); + manager.addModel({ id: 'new', schemaQuery: MIXTURES_SCHEMA_QUERY }, false); + expect(manager.state.queryModels.new.queryInfoLoadingState).toBe(LoadingState.INITIALIZED); + await sleep(); + await sleep(); + expect(manager.state.queryModels.new.queryInfoLoadingState).toBe(LoadingState.INITIALIZED); + }); + + test('addModel with load kicks off loadModel', async () => { + const { manager } = makeManager({}); + manager.addModel({ id: 'new', schemaQuery: MIXTURES_SCHEMA_QUERY }, true); + expect(manager.state.queryModels.new.queryInfoLoadingState).toBe(LoadingState.LOADING); + await sleep(); + await sleep(); + expect(manager.state.queryModels.new.queryInfoLoadingState).toBe(LoadingState.LOADED); + expect(manager.state.queryModels.new.rowsLoadingState).toBe(LoadingState.LOADED); + }); + + test('resetTotalCountState resets every model', async () => { + const { manager } = makeManager({ + a: { schemaQuery: MIXTURES_SCHEMA_QUERY, includeTotalCount: true }, + b: { schemaQuery: MIXTURES_SCHEMA_QUERY, includeTotalCount: true }, + }); + manager.loadModel('a'); + manager.loadModel('b'); + await sleep(); + await sleep(); + expect(manager.state.queryModels.a.totalCountLoadingState).toBe(LoadingState.LOADED); + expect(manager.state.queryModels.b.totalCountLoadingState).toBe(LoadingState.LOADED); + + manager.resetTotalCountState(); + expect(manager.state.queryModels.a.totalCountLoadingState).toBe(LoadingState.INITIALIZED); + expect(manager.state.queryModels.b.totalCountLoadingState).toBe(LoadingState.INITIALIZED); + }); + }); + + describe('URL binding', () => { + test('bindURL calls setSearchParams when model.bindURL is true', () => { + const setSearchParams = jest.fn(); + const { manager } = makeManager( + { model: { schemaQuery: MIXTURES_SCHEMA_QUERY, bindURL: true } }, + undefined, + new URLSearchParams(), + setSearchParams + ); + manager.syncURL('model'); + expect(setSearchParams).toHaveBeenCalled(); + }); + + test('syncURL is a no-op when model.bindURL is false', () => { + const setSearchParams = jest.fn(); + const { manager } = makeManager( + { model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, + undefined, + new URLSearchParams(), + setSearchParams + ); + manager.syncURL('model'); + expect(setSearchParams).not.toHaveBeenCalled(); + }); + + test('updateRouter applies new URL params to the model', async () => { + const setSearchParams = jest.fn(); + const { manager } = makeManager( + { model: { schemaQuery: MIXTURES_SCHEMA_QUERY, bindURL: true } }, + undefined, + new URLSearchParams(), + setSearchParams + ); + manager.loadModel('model'); + await sleep(); + await sleep(); + manager.updateRouter(new URLSearchParams({ 'query.p': '2' }), setSearchParams); + expect(manager.state.queryModels.model.offset).toBe(20); + }); + }); + + describe('destroy', () => { + test('cancels outstanding requests', () => { + const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); + const cancelSpy = jest.spyOn((manager as any).requestManager, 'cancelAllRequests'); + manager.destroy(); + expect(cancelSpy).toHaveBeenCalled(); + }); + }); +}); + +describe('useQueryModels', () => { + beforeEach(() => { + rrd.__setSearchParams(new URLSearchParams()); + rrd.__setSetSearchParams(jest.fn()); + }); + + test('returns initial state synchronously and kicks off queryInfo load on mount', async () => { + const loader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA); + const { result } = renderHook(() => + useQueryModels({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, { modelLoader: loader }) + ); + // By the time renderHook returns, the mount effect has run. + expect(result.current.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADING); + await act(async () => { + await sleep(); + }); + expect(result.current.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED); + }); + + test('autoLoad triggers loadAllModels (queryInfo + rows)', async () => { + const loader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA); + const { result } = renderHook(() => + useQueryModels({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, { autoLoad: true, modelLoader: loader }) + ); + await act(async () => { + await sleep(); + await sleep(); + }); + const model = result.current.queryModels.model; + expect(model.queryInfoLoadingState).toBe(LoadingState.LOADED); + expect(model.rowsLoadingState).toBe(LoadingState.LOADED); + // When autoLoad is true, loadAllModels(!!loader.loadSelections) passes true, so selections load too. + expect(loader.loadSelections).toHaveBeenCalled(); + }); + + test('invoking actions updates state', async () => { + const loader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA); + const { result } = renderHook(() => + useQueryModels({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, { autoLoad: true, modelLoader: loader }) + ); + await act(async () => { + await sleep(); + await sleep(); + }); + + await act(async () => { + result.current.actions.loadNextPage(result.current.queryModels.model.id); + await sleep(); + }); + expect(result.current.queryModels.model.currentPage).toBe(2); + expect(result.current.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); + }); + + test('unmount destroys manager and cancels requests', async () => { + const loader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA); + const { unmount } = renderHook(() => + useQueryModels({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, { modelLoader: loader }) + ); + await act(async () => { + await sleep(); + await sleep(); + }); + // Unmount should not throw even when an action resolves afterward. + expect(() => unmount()).not.toThrow(); + }); + + test('reads initial settings from the URL', async () => { + const loader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA); + rrd.__setSearchParams(new URLSearchParams({ 'query.p': '2' })); + const { result } = renderHook(() => + useQueryModels( + { model: { schemaQuery: MIXTURES_SCHEMA_QUERY, bindURL: true } }, + { autoLoad: true, modelLoader: loader } + ) + ); + await act(async () => { + await sleep(); + await sleep(); + }); + expect(result.current.queryModels.model.offset).toBe(20); + expect(result.current.queryModels.model.currentPage).toBe(2); + }); + + test('writes URL params on actions when bindURL is true', async () => { + const loader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA); + const setSearchParams = jest.fn(); + rrd.__setSetSearchParams(setSearchParams); + const { result } = renderHook(() => + useQueryModels( + { model: { schemaQuery: MIXTURES_SCHEMA_QUERY, bindURL: true } }, + { autoLoad: true, modelLoader: loader } + ) + ); + await act(async () => { + await sleep(); + await sleep(); + }); + await act(async () => { + result.current.actions.loadLastPage(result.current.queryModels.model.id); + await sleep(); + }); + // bindURL → setSearchParams invoked; verify the updater resolves to "query.p=34" + const updater = setSearchParams.mock.lastCall[0]; + const nextParams = updater(new URLSearchParams({ other: 'still here' })); + expect(nextParams).toEqual({ 'query.p': '34', other: 'still here' }); + }); +}); diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts index 745a39cb44..844b8f27a5 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.ts +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -45,7 +45,7 @@ type ModelUpdater = (model: Draft) => void; type VoidFn = () => void; type StateUpdater = (state: InjectedQueryModels) => InjectedQueryModels; -class QueryModelManager { +export class QueryModelManager { actions: Actions; state: InjectedQueryModels; From d5fe87e2646cd3a250906eceaa2cc849a97bbc28 Mon Sep 17 00:00:00 2001 From: alanv Date: Sat, 18 Apr 2026 17:24:40 -0500 Subject: [PATCH 12/23] Move RequestManager tests to utils.test.ts --- .../QueryModel/{withQueryModels.test.ts => utils.test.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename packages/components/src/public/QueryModel/{withQueryModels.test.ts => utils.test.ts} (98%) diff --git a/packages/components/src/public/QueryModel/withQueryModels.test.ts b/packages/components/src/public/QueryModel/utils.test.ts similarity index 98% rename from packages/components/src/public/QueryModel/withQueryModels.test.ts rename to packages/components/src/public/QueryModel/utils.test.ts index 40f7c750a2..83361a331a 100644 --- a/packages/components/src/public/QueryModel/withQueryModels.test.ts +++ b/packages/components/src/public/QueryModel/utils.test.ts @@ -1,4 +1,4 @@ -import { RequestManager } from './withQueryModels'; +import { RequestManager } from './utils'; describe('RequestManager', () => { let manager: RequestManager; From bfc0efea22a8f385cb6bdd66ee498ea730114cdb Mon Sep 17 00:00:00 2001 From: alanv Date: Sat, 18 Apr 2026 17:41:24 -0500 Subject: [PATCH 13/23] Cleanup --- .../components/src/public/QueryModel/useQueryModels.ts | 3 ++- packages/components/src/public/QueryModel/utils.ts | 9 ++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts index 844b8f27a5..31eba179a6 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.ts +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -61,6 +61,7 @@ export class QueryModelManager { setSearchParams: SetURLSearchParams, modelLoader?: QueryModelLoader ) { + this.onStateChange = NOOP; this.requestManager = new RequestManager(); this.searchParams = searchParams; this.setSearchParams = setSearchParams; @@ -197,7 +198,7 @@ export class QueryModelManager { let loadSelections = false; this.updateModel(id, (model: Draft) => { - const modelParamsFromURL = {}; + const modelParamsFromURL: Record = {}; for (const [key, value] of searchParams.entries()) { if (key.startsWith(model.urlPrefix + '.')) { modelParamsFromURL[key] = value; diff --git a/packages/components/src/public/QueryModel/utils.ts b/packages/components/src/public/QueryModel/utils.ts index 35e35cbcd9..78d884c2ce 100644 --- a/packages/components/src/public/QueryModel/utils.ts +++ b/packages/components/src/public/QueryModel/utils.ts @@ -20,16 +20,15 @@ import { caseInsensitive } from '../../internal/util/utils'; import { ActionValue } from './grid/actions/Action'; import { - getSettingsFromLocalStorage, InjectedQueryModels, + getSettingsFromLocalStorage, locationHasQueryParamSettings, QueryModel, QueryModelMap, - SavedSettings, saveSettingsToLocalStorage + SavedSettings, + saveSettingsToLocalStorage, } from './QueryModel'; -import { Draft, produce } from 'immer'; +import { Draft } from 'immer'; import { RequestHandler } from '../../internal/request'; -import { ComponentType, PureComponent } from 'react'; -import { SearchParamsProps } from './withQueryModels'; import { LoadingState } from '../LoadingState'; import { naturalSort } from '../sort'; import { SetURLSearchParams } from 'react-router-dom'; From 714651cc099f01ee24e11e28da6c06bf7f1e6bdc Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 20 Apr 2026 09:41:33 -0500 Subject: [PATCH 14/23] Only call saveSettings/syncURL if needed --- .../src/public/QueryModel/useQueryModels.ts | 55 ++++++++++++++----- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts index 31eba179a6..68359db697 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.ts +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -343,7 +343,10 @@ export class QueryModelManager { }); this.maybeLoad(id, false, shouldLoad); - this.syncURL(id); + + if (shouldLoad) { + this.syncURL(id); + } }; loadLastPage = (id: string): void => { @@ -355,7 +358,10 @@ export class QueryModelManager { } }); this.maybeLoad(id, false, shouldLoad); - this.syncURL(id); + + if (shouldLoad) { + this.syncURL(id); + } }; loadModel = (id: string, loadSelections = false, reloadTotalCount = false): void => { @@ -371,7 +377,10 @@ export class QueryModelManager { } }); this.maybeLoad(id, false, shouldLoad); - this.syncURL(id); + + if (shouldLoad) { + this.syncURL(id); + } }; loadPreviousPage = (id: string): void => { @@ -383,7 +392,10 @@ export class QueryModelManager { } }); this.maybeLoad(id, false, shouldLoad); - this.syncURL(id); + + if (shouldLoad) { + this.syncURL(id); + } }; loadQueryInfo = async ( @@ -626,7 +638,7 @@ export class QueryModelManager { } }); - // Loading & replacing selections are mutually exclusive, if we aren't replacing anything then load + // Loading & replacing selections are mutually exclusive, if we aren't replacing anything, then load const loadSelections = selectionsForReplace === undefined; this.maybeLoad(id, false, true, loadSelections, shouldLoadTotalCount, selectionsForReplace); this.syncURL(id); @@ -768,8 +780,11 @@ export class QueryModelManager { }); // When filters change, we need to reload selections and counts. this.maybeLoad(id, false, shouldLoad, shouldLoad && loadSelections, true); - this.saveSettings(id); - this.syncURL(id); + + if (shouldLoad) { + this.saveSettings(id); + this.syncURL(id); + } }; setMaxRows = (id: string, maxRows: number): void => { @@ -782,8 +797,11 @@ export class QueryModelManager { } }); this.maybeLoad(id, false, shouldLoad); - this.saveSettings(id); - this.syncURL(id); + + if (shouldLoad) { + this.saveSettings(id); + this.syncURL(id); + } }; setOffset = (id: string, offset: number, reloadModel = true): void => { @@ -795,7 +813,10 @@ export class QueryModelManager { } }); this.maybeLoad(id, false, reloadModel && shouldLoad); - this.syncURL(id); + + if (shouldLoad) { + this.syncURL(id); + } }; setSchemaQuery = (id: string, schemaQuery: SchemaQuery, loadSelections = false): void => { @@ -851,8 +872,11 @@ export class QueryModelManager { } }); this.maybeLoad(id, false, shouldLoad); - this.saveSettings(id); - this.syncURL(id); + + if (shouldLoad) { + this.saveSettings(id); + this.syncURL(id); + } }; setView = (id: string, viewName: string, loadSelections = false): void => { @@ -869,8 +893,11 @@ export class QueryModelManager { } }); this.maybeLoad(id, false, shouldLoad, shouldLoad && loadSelections); - this.saveSettings(id); - this.syncURL(id); + + if (shouldLoad) { + this.saveSettings(id); + this.syncURL(id); + } }; } From 416267c38963936fd86406eb505a19cc23da79a6 Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 20 Apr 2026 10:56:35 -0500 Subject: [PATCH 15/23] useQueryModels: add docstring --- .../components/src/public/QueryModel/useQueryModels.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts index 68359db697..036b42e26e 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.ts +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -922,6 +922,13 @@ interface UseQueryModelsOptions { modelLoader?: QueryModelLoader; } +/** + * A hook that creates a managed set of QueryModels for a given QueryConfigMap returning InjectedQueryModels. + * Functionally equivalent to withQueryModels but often more convenient because it doesn't require wrapping your + * component. Note: changes to queryConfigs are not handled, useQueryModels only ever uses the original queryConfigs + * object passed to it, as properly diffing the queryConfigs object is effectively impossible when considering user + * changes to the models. + */ export function useQueryModels(queryConfigs: QueryConfigMap, options: UseQueryModelsOptions = {}): InjectedQueryModels { const { autoLoad = false, modelLoader } = options; const [searchParams, setSearchParams] = useOptionalSearchParams(); From 37c3d914adeb0db3e8073f183cae05d6eddac5e1 Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 20 Apr 2026 11:02:32 -0500 Subject: [PATCH 16/23] useQueryModels.test.tsx - Remove usages of sleep --- .../public/QueryModel/useQueryModels.test.tsx | 212 ++++++++---------- .../src/test/MockQueryModelLoader.ts | 21 +- 2 files changed, 102 insertions(+), 131 deletions(-) diff --git a/packages/components/src/public/QueryModel/useQueryModels.test.tsx b/packages/components/src/public/QueryModel/useQueryModels.test.tsx index 147b04d160..bd73e20ac8 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.test.tsx +++ b/packages/components/src/public/QueryModel/useQueryModels.test.tsx @@ -1,8 +1,7 @@ -import React from 'react'; -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { Filter } from '@labkey/api'; -import { makeQueryInfo, makeTestData, sleep } from '../../internal/test/testHelpers'; +import { makeQueryInfo, makeTestData } from '../../internal/test/testHelpers'; import { MockQueryModelLoader } from '../../test/MockQueryModelLoader'; import mixturesQueryInfo from '../../test/data/mixtures-getQueryDetails.json'; import mixturesQuery from '../../test/data/mixtures-getQueryPaging.json'; @@ -14,12 +13,12 @@ import { QueryInfo } from '../QueryInfo'; import { LoadingState } from '../LoadingState'; import { QuerySort } from '../QuerySort'; -import { QueryModel, ChangeType } from './QueryModel'; +import { ChangeType, QueryModel } from './QueryModel'; import { RowsResponse } from './QueryModelLoader'; import { QueryModelManager, useQueryModels } from './useQueryModels'; // @ts-expect-error Need to use require() for mocking -// eslint-disable-next-line @typescript-eslint/no-var-requires + const rrd = require('react-router-dom'); const MIXTURES_SCHEMA_QUERY = new SchemaQuery('exp.data', 'mixtures'); @@ -44,32 +43,24 @@ class TestQueryModelLoader extends MockQueryModelLoader { selections = new Set(); charts: any[] = []; - loadSelections = jest.fn(async () => { - await sleep(); - return new Set(this.selections); - }); + loadSelections = jest.fn(async () => new Set(this.selections)); replaceSelections = jest.fn(async (_model: QueryModel, selections: string[]) => { - await sleep(); this.selections = new Set(selections); return { count: this.selections.size }; }); selectAllRows = jest.fn(async (model: QueryModel) => { - await sleep(); const all = new Set(model.orderedRows ?? []); this.selections = all; return new Set(all); }); - loadCharts = jest.fn(async () => { - await sleep(); - return this.charts.slice(); - }); + loadCharts = jest.fn(async () => this.charts.slice()); } const makeManager = ( - configs: Record>, + configs: Record & { schemaQuery: SchemaQuery }>, loader: MockQueryModelLoader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA), searchParams: URLSearchParams = new URLSearchParams(), setSearchParams: jest.Mock = jest.fn() @@ -114,11 +105,9 @@ describe('QueryModelManager', () => { const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); manager.loadModel('model'); expect(manager.state.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADING); - await sleep(); - await sleep(); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); const model = manager.state.queryModels.model; expect(model.queryInfoLoadingState).toBe(LoadingState.LOADED); - expect(model.rowsLoadingState).toBe(LoadingState.LOADED); expect(model.queryInfo).toBeDefined(); expect(model.rows).toBeDefined(); expect(model.orderedRows.length).toBeGreaterThan(0); @@ -135,16 +124,19 @@ describe('QueryModelManager', () => { const { manager, loader } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); loader.queryInfoException = { exception: 'QI boom' }; manager.loadModel('model'); - await sleep(); + await waitFor(() => + expect(manager.state.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED) + ); const model = manager.state.queryModels.model; - expect(model.queryInfoLoadingState).toBe(LoadingState.LOADED); expect(model.queryInfoError).toBe('QI boom'); }); test('surfaces rows error', async () => { const { manager, loader } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); manager.loadModel('model'); - await sleep(); + await waitFor(() => + expect(manager.state.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED) + ); loader.rowsException = { exception: 'rows boom' }; await manager.loadRows('model'); const model = manager.state.queryModels.model; @@ -153,8 +145,7 @@ describe('QueryModelManager', () => { }); test('view-does-not-exist recovery falls back to default view (Issue 49378)', async () => { - const viewError = - 'The requested view \'bogus\' does not exist for this user.'; + const viewError = "The requested view 'bogus' does not exist for this user."; const setSearchParams = jest.fn(); const { manager, loader } = makeManager( { @@ -168,7 +159,9 @@ describe('QueryModelManager', () => { setSearchParams ); manager.loadModel('model'); - await sleep(); + await waitFor(() => + expect(manager.state.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED) + ); loader.rowsException = { exception: viewError }; await manager.loadRows('model'); // First failure — schemaQuery should reset to default view and trigger retry. @@ -177,17 +170,16 @@ describe('QueryModelManager', () => { expect(model.viewError).toContain('Returning to the default view.'); // The retry is scheduled via maybeLoad — clear the exception so it succeeds. loader.rowsException = undefined; - await sleep(); - await sleep(); - model = manager.state.queryModels.model; - expect(model.rowsLoadingState).toBe(LoadingState.LOADED); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); expect(setSearchParams).toHaveBeenCalled(); }); test('cancelled request (status 0) is swallowed', async () => { const { manager, loader } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); manager.loadModel('model'); - await sleep(); + await waitFor(() => + expect(manager.state.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED) + ); loader.rowsException = { status: 0 }; await manager.loadRows('model'); // Error should NOT surface, state stays LOADING because the short-circuit returned. @@ -200,9 +192,9 @@ describe('QueryModelManager', () => { test('short-circuits to LOADED when includeTotalCount is false', async () => { const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); manager.loadModel('model'); - await sleep(); - await sleep(); - expect(manager.state.queryModels.model.totalCountLoadingState).toBe(LoadingState.LOADED); + await waitFor(() => + expect(manager.state.queryModels.model.totalCountLoadingState).toBe(LoadingState.LOADED) + ); }); test('loads count when includeTotalCount is true', async () => { @@ -210,10 +202,10 @@ describe('QueryModelManager', () => { model: { schemaQuery: MIXTURES_SCHEMA_QUERY, includeTotalCount: true }, }); manager.loadModel('model'); - await sleep(); - await sleep(); + await waitFor(() => + expect(manager.state.queryModels.model.totalCountLoadingState).toBe(LoadingState.LOADED) + ); const model = manager.state.queryModels.model; - expect(model.totalCountLoadingState).toBe(LoadingState.LOADED); expect(model.rowCount).toBe(MIXTURES_DATA.orderedRows.length); }); @@ -222,8 +214,9 @@ describe('QueryModelManager', () => { model: { schemaQuery: MIXTURES_SCHEMA_QUERY, includeTotalCount: true }, }); manager.loadModel('model'); - await sleep(); - await sleep(); + await waitFor(() => + expect(manager.state.queryModels.model.totalCountLoadingState).toBe(LoadingState.LOADED) + ); const spy = jest.spyOn(loader, 'loadTotalCount'); await manager.loadTotalCount('model', false); expect(spy).not.toHaveBeenCalled(); @@ -236,8 +229,9 @@ describe('QueryModelManager', () => { const setup = async () => { const result = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); result.manager.loadModel('model'); - await sleep(); - await sleep(); + await waitFor(() => + expect(result.manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED) + ); return result; }; @@ -259,8 +253,7 @@ describe('QueryModelManager', () => { expect(manager.state.queryModels.model.offset).toBe(maxRows); expect(manager.state.queryModels.model.currentPage).toBe(2); expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADING); - await sleep(); - expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); }); test('loadLastPage sets offset and loadNextPage past the last page is a no-op', async () => { @@ -268,7 +261,7 @@ describe('QueryModelManager', () => { manager.loadLastPage('model'); const lastOffset = manager.state.queryModels.model.lastPageOffset; expect(manager.state.queryModels.model.offset).toBe(lastOffset); - await sleep(); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); const before = manager.state; manager.loadNextPage('model'); expect(manager.state.queryModels.model.offset).toBe(lastOffset); @@ -278,7 +271,7 @@ describe('QueryModelManager', () => { test('setMaxRows resets offset to 0', async () => { const { manager } = await setup(); manager.loadLastPage('model'); - await sleep(); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); manager.setMaxRows('model', 40); const model = manager.state.queryModels.model; expect(model.maxRows).toBe(40); @@ -301,8 +294,9 @@ describe('QueryModelManager', () => { model: { schemaQuery: MIXTURES_SCHEMA_QUERY, includeTotalCount: true }, }); result.manager.loadModel('model'); - await sleep(); - await sleep(); + await waitFor(() => + expect(result.manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED) + ); return result; }; @@ -310,8 +304,8 @@ describe('QueryModelManager', () => { const { manager } = await setup(); // Move off page 1 so we can verify offset reset. manager.loadNextPage('model'); - await sleep(); expect(manager.state.queryModels.model.offset).toBeGreaterThan(0); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); const filter = Filter.create('Name', 'DMXP', Filter.Types.EQUAL); manager.setFilters('model', [filter]); @@ -321,16 +315,14 @@ describe('QueryModelManager', () => { // rows reload kicks off synchronously; totalCount reload follows because includeTotalCount is true. expect(model.rowsLoadingState).toBe(LoadingState.LOADING); expect(model.totalCountLoadingState).toBe(LoadingState.LOADING); - await sleep(); - expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); }); test('setFilters no-op when filters are equal', async () => { const { manager } = await setup(); const f = Filter.create('Name', 'X'); manager.setFilters('model', [f]); - await sleep(); - await sleep(); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); const before = manager.state; manager.setFilters('model', [f]); expect(manager.state).toBe(before); @@ -341,7 +333,7 @@ describe('QueryModelManager', () => { manager.setSorts('model', [new QuerySort({ fieldKey: 'Name' })]); expect(manager.state.queryModels.model.sorts).toHaveLength(1); expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADING); - await sleep(); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); const before = manager.state; manager.setSorts('model', [new QuerySort({ fieldKey: 'Name' })]); @@ -358,8 +350,7 @@ describe('QueryModelManager', () => { expect(model.orderedRows).toBeUndefined(); expect(model.rowCount).toBeUndefined(); expect(model.rowsLoadingState).toBe(LoadingState.LOADING); - await sleep(); - expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); }); test('setSchemaQuery throws (intentionally unimplemented)', () => { @@ -373,8 +364,7 @@ describe('QueryModelManager', () => { const loader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA); const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, loader); manager.loadModel('model'); - await sleep(); - await sleep(); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); return { manager, loader }; }; @@ -401,8 +391,7 @@ describe('QueryModelManager', () => { const firstKey = manager.state.queryModels.model.orderedRows[0]; const firstRow = manager.state.queryModels.model.getRow(firstKey); manager.selectRow('model', true, firstRow); - await sleep(); - expect(manager.state.queryModels.model.selections.has(firstKey)).toBe(true); + await waitFor(() => expect(manager.state.queryModels.model.selections.has(firstKey)).toBe(true)); expect(manager.state.queryModels.model.selectionPivot).toEqual({ checked: true, selection: firstKey, @@ -414,20 +403,19 @@ describe('QueryModelManager', () => { const ordered = manager.state.queryModels.model.orderedRows; const pivotKey = ordered[0]; manager.selectRow('model', true, manager.state.queryModels.model.getRow(pivotKey)); - await sleep(); + await waitFor(() => expect(manager.state.queryModels.model.selections.size).toBe(1)); // Shift-click a row 5 indices away manager.selectRow('model', true, manager.state.queryModels.model.getRow(ordered[5]), true); - await sleep(); - const selections = manager.state.queryModels.model.selections; - expect(selections.size).toBe(6); // pivot + 5 rows + await waitFor(() => expect(manager.state.queryModels.model.selections.size).toBe(6)); }); test('selectPage selects all ordered rows on the page', async () => { const { manager } = await setup(); manager.selectPage('model', true); - await sleep(); - const model = manager.state.queryModels.model; - expect(model.selections.size).toBe(model.orderedRows.length); + await waitFor(() => { + const model = manager.state.queryModels.model; + expect(model.selections.size).toBe(model.orderedRows.length); + }); }); test('clearSelections empties the selection set', async () => { @@ -459,8 +447,7 @@ describe('QueryModelManager', () => { loader.selections = new Set(['row-1', 'row-2']); const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, loader); manager.loadModel('model'); - await sleep(); - await sleep(); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); await manager.loadSelections('model'); expect(manager.state.queryModels.model.selections).toEqual(new Set(['row-1', 'row-2'])); expect(manager.state.queryModels.model.selectionsLoadingState).toBe(LoadingState.LOADED); @@ -471,8 +458,7 @@ describe('QueryModelManager', () => { loader.loadSelections = jest.fn(() => Promise.reject(new Error('nope'))); const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, loader); manager.loadModel('model'); - await sleep(); - await sleep(); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); await manager.loadSelections('model'); const model = manager.state.queryModels.model; expect(model.selectionsError).toBeDefined(); @@ -527,8 +513,9 @@ describe('QueryModelManager', () => { loader ); result.manager.loadModel('model'); - await sleep(); - await sleep(); + await waitFor(() => + expect(result.manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED) + ); return { ...result, loader }; }; @@ -551,16 +538,13 @@ describe('QueryModelManager', () => { const model = manager.state.queryModels.model; expect(model.rows).toBeUndefined(); expect(model.selectionsLoadingState).toBe(LoadingState.INITIALIZED); - await sleep(); - await sleep(); - expect(manager.state.queryModels.model.selections).toEqual(new Set(['keep-me'])); + await waitFor(() => expect(manager.state.queryModels.model.selections).toEqual(new Set(['keep-me']))); }); test('update with filtered column resets the model', async () => { const { manager } = await setup(); manager.setFilters('model', [Filter.create('Name', 'X')]); - await sleep(); - await sleep(); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); manager.onModelChange('model', { changeType: ChangeType.update, options: { columnsChanged: ['Name'] }, @@ -571,8 +555,7 @@ describe('QueryModelManager', () => { test('update without filter intersection leaves rows in place but reloads', async () => { const { manager } = await setup(); manager.setFilters('model', [Filter.create('Name', 'X')]); - await sleep(); - await sleep(); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); const rowsBefore = manager.state.queryModels.model.rows; manager.onModelChange('model', { changeType: ChangeType.update, @@ -587,19 +570,16 @@ describe('QueryModelManager', () => { const { manager } = makeManager({}); manager.addModel({ id: 'new', schemaQuery: MIXTURES_SCHEMA_QUERY }, false); expect(manager.state.queryModels.new.queryInfoLoadingState).toBe(LoadingState.INITIALIZED); - await sleep(); - await sleep(); - expect(manager.state.queryModels.new.queryInfoLoadingState).toBe(LoadingState.INITIALIZED); }); test('addModel with load kicks off loadModel', async () => { const { manager } = makeManager({}); manager.addModel({ id: 'new', schemaQuery: MIXTURES_SCHEMA_QUERY }, true); expect(manager.state.queryModels.new.queryInfoLoadingState).toBe(LoadingState.LOADING); - await sleep(); - await sleep(); - expect(manager.state.queryModels.new.queryInfoLoadingState).toBe(LoadingState.LOADED); - expect(manager.state.queryModels.new.rowsLoadingState).toBe(LoadingState.LOADED); + await waitFor(() => { + expect(manager.state.queryModels.new.queryInfoLoadingState).toBe(LoadingState.LOADED); + expect(manager.state.queryModels.new.rowsLoadingState).toBe(LoadingState.LOADED); + }); }); test('resetTotalCountState resets every model', async () => { @@ -609,10 +589,10 @@ describe('QueryModelManager', () => { }); manager.loadModel('a'); manager.loadModel('b'); - await sleep(); - await sleep(); - expect(manager.state.queryModels.a.totalCountLoadingState).toBe(LoadingState.LOADED); - expect(manager.state.queryModels.b.totalCountLoadingState).toBe(LoadingState.LOADED); + await waitFor(() => { + expect(manager.state.queryModels.a.totalCountLoadingState).toBe(LoadingState.LOADED); + expect(manager.state.queryModels.b.totalCountLoadingState).toBe(LoadingState.LOADED); + }); manager.resetTotalCountState(); expect(manager.state.queryModels.a.totalCountLoadingState).toBe(LoadingState.INITIALIZED); @@ -654,8 +634,7 @@ describe('QueryModelManager', () => { setSearchParams ); manager.loadModel('model'); - await sleep(); - await sleep(); + await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); manager.updateRouter(new URLSearchParams({ 'query.p': '2' }), setSearchParams); expect(manager.state.queryModels.model.offset).toBe(20); }); @@ -684,10 +663,7 @@ describe('useQueryModels', () => { ); // By the time renderHook returns, the mount effect has run. expect(result.current.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADING); - await act(async () => { - await sleep(); - }); - expect(result.current.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED); + await waitFor(() => expect(result.current.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED)); }); test('autoLoad triggers loadAllModels (queryInfo + rows)', async () => { @@ -695,13 +671,10 @@ describe('useQueryModels', () => { const { result } = renderHook(() => useQueryModels({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, { autoLoad: true, modelLoader: loader }) ); - await act(async () => { - await sleep(); - await sleep(); + await waitFor(() => { + expect(result.current.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED); + expect(result.current.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); }); - const model = result.current.queryModels.model; - expect(model.queryInfoLoadingState).toBe(LoadingState.LOADED); - expect(model.rowsLoadingState).toBe(LoadingState.LOADED); // When autoLoad is true, loadAllModels(!!loader.loadSelections) passes true, so selections load too. expect(loader.loadSelections).toHaveBeenCalled(); }); @@ -711,28 +684,26 @@ describe('useQueryModels', () => { const { result } = renderHook(() => useQueryModels({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, { autoLoad: true, modelLoader: loader }) ); - await act(async () => { - await sleep(); - await sleep(); + await waitFor(() => { + expect(result.current.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED); + expect(result.current.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); }); - await act(async () => { + act(() => { result.current.actions.loadNextPage(result.current.queryModels.model.id); - await sleep(); }); - expect(result.current.queryModels.model.currentPage).toBe(2); - expect(result.current.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); + await waitFor(() => { + expect(result.current.queryModels.model.currentPage).toBe(2); + expect(result.current.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); + }); }); test('unmount destroys manager and cancels requests', async () => { const loader = new TestQueryModelLoader(MIXTURES_QUERY_INFO, MIXTURES_DATA); - const { unmount } = renderHook(() => + const { result, unmount } = renderHook(() => useQueryModels({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, { modelLoader: loader }) ); - await act(async () => { - await sleep(); - await sleep(); - }); + await waitFor(() => expect(result.current.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED)); // Unmount should not throw even when an action resolves afterward. expect(() => unmount()).not.toThrow(); }); @@ -746,10 +717,7 @@ describe('useQueryModels', () => { { autoLoad: true, modelLoader: loader } ) ); - await act(async () => { - await sleep(); - await sleep(); - }); + await waitFor(() => expect(result.current.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); expect(result.current.queryModels.model.offset).toBe(20); expect(result.current.queryModels.model.currentPage).toBe(2); }); @@ -764,14 +732,14 @@ describe('useQueryModels', () => { { autoLoad: true, modelLoader: loader } ) ); - await act(async () => { - await sleep(); - await sleep(); + await waitFor(() => { + expect(result.current.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED); + expect(result.current.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED); }); - await act(async () => { + act(() => { result.current.actions.loadLastPage(result.current.queryModels.model.id); - await sleep(); }); + await waitFor(() => expect(result.current.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); // bindURL → setSearchParams invoked; verify the updater resolves to "query.p=34" const updater = setSearchParams.mock.lastCall[0]; const nextParams = updater(new URLSearchParams({ other: 'still here' })); diff --git a/packages/components/src/test/MockQueryModelLoader.ts b/packages/components/src/test/MockQueryModelLoader.ts index 32b2950255..31bd1ce216 100644 --- a/packages/components/src/test/MockQueryModelLoader.ts +++ b/packages/components/src/test/MockQueryModelLoader.ts @@ -48,8 +48,9 @@ export class MockQueryModelLoader implements QueryModelLoader { }); }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - loadSelections = (model: QueryModel): Promise => { + // Promise so we can override the value without type errors (see useQueryModels.test.ts) + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any + loadSelections = (model: QueryModel): Promise => { return Promise.reject('Not implemented!'); }; @@ -57,7 +58,6 @@ export class MockQueryModelLoader implements QueryModelLoader { return this.rowsResponse.orderedRows.length; }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars setSelections = (model: QueryModel, checked: boolean, selections: string[]): Promise => { return new Promise(resolve => { setTimeout(() => { @@ -66,13 +66,15 @@ export class MockQueryModelLoader implements QueryModelLoader { }); }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - replaceSelections = (model: QueryModel, selections): Promise => { + // Promise so we can override the value without type errors (see useQueryModels.test.ts) + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any + replaceSelections = (model: QueryModel, selections): Promise => { return Promise.reject('Not implemented!'); }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - selectAllRows = (model: QueryModel): Promise => { + // Promise so we can override the value without type errors (see useQueryModels.test.ts) + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any + selectAllRows = (model: QueryModel): Promise => { return Promise.reject('Not implemented!'); }; @@ -85,8 +87,9 @@ export class MockQueryModelLoader implements QueryModelLoader { }); }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - loadCharts = (model: QueryModel): Promise => { + // Promise so we can override the value without type errors (see useQueryModels.test.ts) + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any + loadCharts = (model: QueryModel): Promise => { return Promise.reject('Not Implemented!'); }; } From 0e273ac96343d916b47dd7332333de1b334cc88a Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 20 Apr 2026 11:32:12 -0500 Subject: [PATCH 17/23] APIKeysPanel: use useQueryModels --- .../internal/components/user/APIKeysPanel.tsx | 89 +++++++++---------- 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/packages/components/src/internal/components/user/APIKeysPanel.tsx b/packages/components/src/internal/components/user/APIKeysPanel.tsx index 7aabaf308f..a2ba6b569d 100644 --- a/packages/components/src/internal/components/user/APIKeysPanel.tsx +++ b/packages/components/src/internal/components/user/APIKeysPanel.tsx @@ -19,18 +19,13 @@ import { AppContext, useAppContext } from '../../AppContext'; import { setCopyValue } from '../../events'; import { isApp, isFeatureEnabled } from '../../app/utils'; import { ProductFeature } from '../../app/constants'; -import { - ChangeType, - InjectedQueryModels, - QueryConfigMap, - RequiresModelAndActions, -} from '../../../public/QueryModel/QueryModel'; -import { withQueryModels } from '../../../public/QueryModel/withQueryModels'; +import { ChangeType, QueryConfigMap, RequiresModelAndActions } from '../../../public/QueryModel/QueryModel'; import { SCHEMAS } from '../../schemas'; import { GridPanel } from '../../../public/QueryModel/GridPanel'; import { Modal } from '../../Modal'; import { getHelpLink, HelpLink } from '../../util/helpLinks'; import { biologicsIsPrimaryApp } from '../../app/products'; +import { useQueryModels } from '../../../public/QueryModel/useQueryModels'; const API_KEYS_QUERY_HREF = ActionURL.buildURL('query', 'executeQuery.view', '/', { schemaName: 'core', @@ -68,15 +63,15 @@ const APIKeysButtonsComponent: FC = props => { const noun = model.selections?.size > 1 ? 'Keys' : 'Key'; return (
- {showConfirmDelete && ( Deletion cannot be undone. Do you want to proceed? @@ -90,7 +85,7 @@ APIKeysButtonsComponent.displayName = 'APIKeysButtonsComponent'; interface KeyGeneratorProps { afterCreate?: () => void; noun: string; - type: 'session' | 'apikey'; + type: 'apikey' | 'session'; } interface ModalProps extends KeyGeneratorProps { @@ -139,23 +134,23 @@ export const KeyGeneratorModal: FC = props => { return ( {type === 'apikey' && !keyValue && (
)} @@ -173,10 +168,10 @@ export const KeyGeneratorModal: FC = props => { />
- {showModal && } + {showModal && } ); }; @@ -240,7 +235,7 @@ const SessionKeysSection: FC = memo(() => ( times out your session. Since they expire quickly, session keys are most appropriate for deployments with regulatory compliance requirements.

- + )); SessionKeysSection.displayName = 'SessionKeysSection'; @@ -249,8 +244,22 @@ interface APIKeysGridProps { includeSessionKeys?: boolean; } -const APIKeysPanelGrid: FC = props => { - const { actions, includeSessionKeys, queryModels } = props; +const APIKeysGrid: FC = props => { + const { includeSessionKeys } = props; + const { homeContainer } = useServerContext(); + const configs: QueryConfigMap = useMemo( + () => ({ + model: { + id: 'model', + title: 'Current API Keys', + schemaQuery: SCHEMAS.CORE_TABLES.USER_API_KEYS, + includeTotalCount: true, + containerPath: homeContainer, + }, + }), + [homeContainer] + ); + const { actions, queryModels } = useQueryModels(configs, { autoLoad: true }); const { model } = queryModels; const { moduleContext } = useServerContext(); const [error, setError] = useState(); @@ -275,48 +284,34 @@ const APIKeysPanelGrid: FC = props => {
{error} - {apiEnabled && } + {apiEnabled && } {sessionEnabled && includeSessionKeys && }
); }; -APIKeysPanelGrid.displayName = 'APIKeysPanelGrid'; - -const APIKeysPanelWithQueryModels = withQueryModels(APIKeysPanelGrid); +APIKeysGrid.displayName = 'APIKeysGrid'; export const APIKeysPanel: FC = props => { const { includeSessionKeys } = props; - const { homeContainer, impersonatingUser, moduleContext, user } = useServerContext(); + const { impersonatingUser, moduleContext, user } = useServerContext(); const isImpersonating = !!impersonatingUser; const apiEnabled = isApiKeyGenerationEnabled(moduleContext); const sessionEnabled = isSessionKeyGenerationEnabled(moduleContext); - const configs: QueryConfigMap = useMemo( - () => ({ - model: { - id: 'model', - title: 'Current API Keys', - schemaQuery: SCHEMAS.CORE_TABLES.USER_API_KEYS, - includeTotalCount: true, - containerPath: homeContainer, - }, - }), - [homeContainer] - ); // We are meant to not show this panel for LKSM Starter, but show it in LKS and LKSM Prof+ if (isApp() && !isFeatureEnabled(ProductFeature.ApiKeys, moduleContext)) return null; @@ -378,7 +373,7 @@ export const APIKeysPanel: FC = props => { {disabledMessage} - {!impersonatingUser && } + {!impersonatingUser && } ); From 9eff73f1b126f1732515267eb1fc020c9d8616ca Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 20 Apr 2026 13:36:04 -0500 Subject: [PATCH 18/23] Actions: remove setSchemaQuery --- .../components/releaseNotes/components.md | 8 +++++++ .../src/public/QueryModel/QueryModel.ts | 1 - .../src/public/QueryModel/testUtils.ts | 1 - .../public/QueryModel/useQueryModels.test.tsx | 5 ----- .../src/public/QueryModel/useQueryModels.ts | 6 ----- .../src/public/QueryModel/withQueryModels.tsx | 22 ------------------- 6 files changed, 8 insertions(+), 35 deletions(-) diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index ad9d579ae9..8a2bc016bd 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,14 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 8.0.0 +*Released* ?? April 2026 +- Add useQueryModels hook: functionally equivalent to withQueryModels +- Deprecate withQueryModels +- Actions: remove `setSchemaQuery` + - Backwards incompatible, but likely safe since there are no known usages +- APIKeysPanel: Use useQueryModels + ### version 7.33.4 *Released*: 3 May 2026 - Consolidate Dataclass data update methods - use DIB for update only diff --git a/packages/components/src/public/QueryModel/QueryModel.ts b/packages/components/src/public/QueryModel/QueryModel.ts index a9494c1c66..bc13ef9987 100644 --- a/packages/components/src/public/QueryModel/QueryModel.ts +++ b/packages/components/src/public/QueryModel/QueryModel.ts @@ -1354,7 +1354,6 @@ export interface Actions { setFilters: (id: string, filters: Filter.IFilter[], loadSelections?: boolean) => void; setMaxRows: (id: string, maxRows: number) => void; setOffset: (id: string, offset: number, reloadModel?: boolean) => void; - setSchemaQuery: (id: string, schemaQuery: SchemaQuery, loadSelections?: boolean) => void; setSelections: (id: string, checked: boolean, selections: string[]) => void; setSorts: (id: string, sorts: QuerySort[]) => void; setView: (id: string, viewName: string, loadSelections?: boolean) => void; diff --git a/packages/components/src/public/QueryModel/testUtils.ts b/packages/components/src/public/QueryModel/testUtils.ts index 85a9fbe183..801c57695e 100644 --- a/packages/components/src/public/QueryModel/testUtils.ts +++ b/packages/components/src/public/QueryModel/testUtils.ts @@ -78,7 +78,6 @@ export const makeTestActions = (mockFn = (): any => () => {}, overrides: Partial setFilters: mockFn(), setMaxRows: mockFn(), setOffset: mockFn(), - setSchemaQuery: mockFn(), setSorts: mockFn(), setView: mockFn(), setSelections: mockFn(), diff --git a/packages/components/src/public/QueryModel/useQueryModels.test.tsx b/packages/components/src/public/QueryModel/useQueryModels.test.tsx index bd73e20ac8..09092797be 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.test.tsx +++ b/packages/components/src/public/QueryModel/useQueryModels.test.tsx @@ -352,11 +352,6 @@ describe('QueryModelManager', () => { expect(model.rowsLoadingState).toBe(LoadingState.LOADING); await waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)); }); - - test('setSchemaQuery throws (intentionally unimplemented)', () => { - const { manager } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }); - expect(() => manager.setSchemaQuery('model', AMINO_ACIDS_SCHEMA_QUERY)).toThrow(/not implemented/); - }); }); describe('selections', () => { diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts index 036b42e26e..e59890a121 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.ts +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -88,7 +88,6 @@ export class QueryModelManager { setFilters: this.setFilters, setMaxRows: this.setMaxRows, setOffset: this.setOffset, - setSchemaQuery: this.setSchemaQuery, setSelections: this.setSelections, setSorts: this.setSorts, setView: this.setView, @@ -819,11 +818,6 @@ export class QueryModelManager { } }; - setSchemaQuery = (id: string, schemaQuery: SchemaQuery, loadSelections = false): void => { - // Note: we don't use the setSchemaQuery method anywhere, we should remove it from Actions - throw new Error('setSchemaQuery is not implemented'); - }; - setSelections = async (id: string, checked: boolean, selections: string[]): Promise => { const loading = this.state.queryModels[id].selectionsLoadingState === LoadingState.LOADING; diff --git a/packages/components/src/public/QueryModel/withQueryModels.tsx b/packages/components/src/public/QueryModel/withQueryModels.tsx index f546c567d5..ced412d04c 100644 --- a/packages/components/src/public/QueryModel/withQueryModels.tsx +++ b/packages/components/src/public/QueryModel/withQueryModels.tsx @@ -127,7 +127,6 @@ export function withQueryModels( setFilters: this.setFilters, setOffset: this.setOffset, setMaxRows: this.setMaxRows, - setSchemaQuery: this.setSchemaQuery, setSelections: this.setSelections, setSorts: this.setSorts, setView: this.setView, @@ -941,27 +940,6 @@ export function withQueryModels( ); }; - setSchemaQuery = (id: string, schemaQuery: SchemaQuery, loadSelections = false): void => { - let shouldLoad = false; - this.setState( - produce((draft: WritableDraft) => { - const model = draft.queryModels[id]; - - if (!model.schemaQuery.isEqual(schemaQuery)) { - shouldLoad = true; - // We assume that we'll need a new QueryInfo if we're changing the SchemaQuery, so we reset the - // QueryInfo and all rows related data. - model.schemaQuery = schemaQuery; - resetQueryInfoState(model); - resetRowsState(model); - resetTotalCountState(model); - resetSelectionState(model); - } - }), - () => this.maybeLoad(id, shouldLoad, shouldLoad, shouldLoad && loadSelections) - ); - }; - setFilters = (id: string, filters: Filter.IFilter[], loadSelections = false): void => { let shouldLoad = false; this.setState( From 1be6ab665670fc4b7674304a311956884ea42609 Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 20 Apr 2026 14:07:58 -0500 Subject: [PATCH 19/23] withQueryModels: use useQueryModels (TEMPORARY CHANGE) --- .../src/public/QueryModel/withQueryModels.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/components/src/public/QueryModel/withQueryModels.tsx b/packages/components/src/public/QueryModel/withQueryModels.tsx index ced412d04c..89171933c2 100644 --- a/packages/components/src/public/QueryModel/withQueryModels.tsx +++ b/packages/components/src/public/QueryModel/withQueryModels.tsx @@ -44,6 +44,7 @@ import { SavedSettings, saveSettingsToLocalStorage, } from './QueryModel'; +import { useQueryModels } from './useQueryModels'; export interface SearchParamsProps { searchParams: URLSearchParams; @@ -87,7 +88,7 @@ interface State { * @param ComponentToWrap A component that implements generic Props and InjectedQueryModels. * @returns A react ComponentType that implements generic Props and MakeQueryModels. */ -export function withQueryModels( +export function withQueryModelsOld( ComponentToWrap: ComponentType ): ComponentType { type WrappedProps = MakeQueryModels & Props & SearchParamsProps; @@ -1078,3 +1079,18 @@ export function withQueryModels( return withSearchParams(ComponentWithQueryModels) as ComponentType; } + +// FIXME: Do not merge with this change unless we feel real confident, and are for sure not introducing any backwards +// compat issues. +export function withQueryModels( + ComponentToWrap: ComponentType +): ComponentType { + type WrappedProps = MakeQueryModels & Props & SearchParamsProps; + const ComponentWithQueryModels: FC = props => { + const { autoLoad, queryConfigs, modelLoader, ...rest } = props; + const { actions, queryModels } = useQueryModels(queryConfigs, { autoLoad, modelLoader }); + return ; + }; + + return ComponentWithQueryModels; +} From 1c3803e8ea5b5dfec55b71ef979d2192e116d86e Mon Sep 17 00:00:00 2001 From: alanv Date: Fri, 24 Apr 2026 19:52:24 -0500 Subject: [PATCH 20/23] useQueryModels: default queryConfigs to {} --- packages/components/src/public/QueryModel/useQueryModels.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts index e59890a121..ab9b7fe6eb 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.ts +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -92,6 +92,7 @@ export class QueryModelManager { setSorts: this.setSorts, setView: this.setView, }; + console.log('queryConfigs!', queryConfigs); this.state = { queryModels: initModels(queryConfigs, searchParams), actions: this.actions, @@ -923,7 +924,10 @@ interface UseQueryModelsOptions { * object passed to it, as properly diffing the queryConfigs object is effectively impossible when considering user * changes to the models. */ -export function useQueryModels(queryConfigs: QueryConfigMap, options: UseQueryModelsOptions = {}): InjectedQueryModels { +export function useQueryModels( + queryConfigs: QueryConfigMap = {}, + options: UseQueryModelsOptions = {} +): InjectedQueryModels { const { autoLoad = false, modelLoader } = options; const [searchParams, setSearchParams] = useOptionalSearchParams(); const manager = useRef(null); From 4ab6af67ede45752aafab9f3554b11623424f9df Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 27 Apr 2026 12:18:03 -0500 Subject: [PATCH 21/23] Remove resetQueryInfoState - it is unused --- packages/components/src/public/QueryModel/utils.ts | 11 ----------- .../src/public/QueryModel/withQueryModels.tsx | 1 - 2 files changed, 12 deletions(-) diff --git a/packages/components/src/public/QueryModel/utils.ts b/packages/components/src/public/QueryModel/utils.ts index 78d884c2ce..fb6c66c73e 100644 --- a/packages/components/src/public/QueryModel/utils.ts +++ b/packages/components/src/public/QueryModel/utils.ts @@ -328,17 +328,6 @@ export function columnsHaveFilter(columnFieldKeys: string[], filters: Filter.IFi return columnFieldKeys.some(fieldKey => columnHasFilter(fieldKey, filters)); } -/** - * Resets queryInfo state to initialized state. Use this when you need to load/reload QueryInfo. - * Note: This method intentionally has side effects, it is only to be used inside of an Immer produce() callback. - * @param model The model to reset queryInfo state on. - */ -export function resetQueryInfoState(model: Draft): void { - model.queryInfo = undefined; - model.queryInfoError = undefined; - model.queryInfoLoadingState = LoadingState.INITIALIZED; -} - /** * Resets totalCount state to initialized state. Use this when you need to load/reload QueryInfo. * Note: This method intentionally has side effects, it is only to be used inside of an Immer produce() callback. diff --git a/packages/components/src/public/QueryModel/withQueryModels.tsx b/packages/components/src/public/QueryModel/withQueryModels.tsx index 89171933c2..5a9f38706b 100644 --- a/packages/components/src/public/QueryModel/withQueryModels.tsx +++ b/packages/components/src/public/QueryModel/withQueryModels.tsx @@ -21,7 +21,6 @@ import { paramsEqual, RequestManager, resetModelState, - resetQueryInfoState, resetRowsState, resetSelectionState, resetTotalCountState, From 31f5580d687b1b5c5f4917216b8b83cea7852fd5 Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 27 Apr 2026 12:32:50 -0500 Subject: [PATCH 22/23] QueryModel: make selections default to empty Set --- .../components/src/public/QueryModel/QueryModel.ts | 13 +++++++++++-- packages/components/src/public/QueryModel/utils.ts | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/components/src/public/QueryModel/QueryModel.ts b/packages/components/src/public/QueryModel/QueryModel.ts index bc13ef9987..3f4ef0ce80 100644 --- a/packages/components/src/public/QueryModel/QueryModel.ts +++ b/packages/components/src/public/QueryModel/QueryModel.ts @@ -523,7 +523,16 @@ export class QueryModel { this.rowsLoadingState = LoadingState.INITIALIZED; this.selectedReportIds = []; this.selectionPivot = undefined; - this.selections = undefined; + + // TODO: this is potentially a backwards incompatible change, made because there seems to be a race condition in + // useQueryModels that causes a lot of components to be given a model with undefined selections and we did not + // appropriately guard against that possibility in many, many places, causing hundreds of tests to fail due to + // errors in the JS console. + // I believe this change is safe to make, because a lot of usage assumed it was always defined, and just + // checked the size attr. If it solves the issue that useQueryModels is having, without introducing other + // problems, then we should keep this change, but also update the rest of QueryModel, useQueryModels, and + // withQueryModels to stop assuming selections will ever be undefined (getSelectedIds, various actions, etc) + this.selections = new Set(); this.selectionsError = undefined; this.selectionsLoadingState = LoadingState.INITIALIZED; this.title = queryConfig.title; @@ -1036,7 +1045,7 @@ 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, orderedRows, selections, rowCount } = this; if (!isLoading && hasData && selections) { const selectedOnPage = orderedRows.filter(rowId => selections.has(rowId)).length; diff --git a/packages/components/src/public/QueryModel/utils.ts b/packages/components/src/public/QueryModel/utils.ts index fb6c66c73e..2037fbe1ff 100644 --- a/packages/components/src/public/QueryModel/utils.ts +++ b/packages/components/src/public/QueryModel/utils.ts @@ -361,7 +361,7 @@ export function resetRowsState(model: Draft): void { * @param model The model to reset selection state on. */ export function resetSelectionState(model: Draft): void { - model.selections = undefined; + model.selections = new Set(); // TODO: See note in QueryModel constructor, we may not want to merge this change model.selectionsError = undefined; model.selectionsLoadingState = LoadingState.INITIALIZED; model.selectionPivot = undefined; From faf4d1cb294f882a1e691abcd19c9dc2317765e9 Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 4 May 2026 17:40:12 -0500 Subject: [PATCH 23/23] useQueryModels: syncURL on loadModel --- packages/components/src/public/QueryModel/useQueryModels.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/public/QueryModel/useQueryModels.ts b/packages/components/src/public/QueryModel/useQueryModels.ts index ab9b7fe6eb..3eaad8bbbc 100644 --- a/packages/components/src/public/QueryModel/useQueryModels.ts +++ b/packages/components/src/public/QueryModel/useQueryModels.ts @@ -92,7 +92,6 @@ export class QueryModelManager { setSorts: this.setSorts, setView: this.setView, }; - console.log('queryConfigs!', queryConfigs); this.state = { queryModels: initModels(queryConfigs, searchParams), actions: this.actions, @@ -366,6 +365,7 @@ export class QueryModelManager { loadModel = (id: string, loadSelections = false, reloadTotalCount = false): void => { this.loadQueryInfo(id, true, loadSelections, reloadTotalCount); + this.syncURL(id); }; loadNextPage = (id: string): void => {