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 (
-
);
};
@@ -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 => {