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/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..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,
- 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 (
-
+
Delete
{showConfirmDelete && (
Deletion cannot be undone. Do you want to proceed?
@@ -90,7 +85,7 @@ APIKeysButtonsComponent.displayName = 'APIKeysButtonsComponent';
interface KeyGeneratorProps {
afterCreate?: () => void;
noun: string;
- type: 'session' | 'apikey';
+ type: 'apikey' | 'session';
}
interface ModalProps extends KeyGeneratorProps {
@@ -139,23 +134,23 @@ export const KeyGeneratorModal: FC = props => {
return (
{type === 'apikey' && !keyValue && (
Description
)}
@@ -173,10 +168,10 @@ export const KeyGeneratorModal: FC = props => {
/>
@@ -217,14 +212,14 @@ export const KeyGenerator: FC = props => {
Generate {noun}
- {showModal && }
+ {showModal && }
);
};
@@ -240,7 +235,7 @@ const SessionKeysSection: FC = memo(() => (
times out your session. Since they expire quickly, session keys are most appropriate for deployments with
regulatory compliance requirements.
-
+
));
SessionKeysSection.displayName = 'SessionKeysSection';
@@ -249,8 +244,22 @@ interface APIKeysGridProps {
includeSessionKeys?: boolean;
}
-const APIKeysPanelGrid: FC = props => {
- const { actions, includeSessionKeys, queryModels } = props;
+const APIKeysGrid: FC = props => {
+ const { includeSessionKeys } = props;
+ const { homeContainer } = useServerContext();
+ const configs: QueryConfigMap = useMemo(
+ () => ({
+ model: {
+ id: 'model',
+ title: 'Current API Keys',
+ schemaQuery: SCHEMAS.CORE_TABLES.USER_API_KEYS,
+ includeTotalCount: true,
+ containerPath: homeContainer,
+ },
+ }),
+ [homeContainer]
+ );
+ const { actions, queryModels } = useQueryModels(configs, { autoLoad: true });
const { model } = queryModels;
const { moduleContext } = useServerContext();
const [error, setError] = useState();
@@ -275,48 +284,34 @@ const APIKeysPanelGrid: FC = props => {
{error}
- {apiEnabled &&
}
+ {apiEnabled &&
}
{sessionEnabled && includeSessionKeys &&
}
);
};
-APIKeysPanelGrid.displayName = 'APIKeysPanelGrid';
-
-const APIKeysPanelWithQueryModels = withQueryModels(APIKeysPanelGrid);
+APIKeysGrid.displayName = 'APIKeysGrid';
export const APIKeysPanel: FC = props => {
const { includeSessionKeys } = props;
- const { homeContainer, impersonatingUser, moduleContext, user } = useServerContext();
+ const { impersonatingUser, moduleContext, user } = useServerContext();
const isImpersonating = !!impersonatingUser;
const apiEnabled = isApiKeyGenerationEnabled(moduleContext);
const sessionEnabled = isSessionKeyGenerationEnabled(moduleContext);
- const configs: QueryConfigMap = useMemo(
- () => ({
- model: {
- id: 'model',
- title: 'Current API Keys',
- schemaQuery: SCHEMAS.CORE_TABLES.USER_API_KEYS,
- includeTotalCount: true,
- containerPath: homeContainer,
- },
- }),
- [homeContainer]
- );
// We are meant to not show this panel for LKSM Starter, but show it in LKS and LKSM Prof+
if (isApp() && !isFeatureEnabled(ProductFeature.ApiKeys, moduleContext)) return null;
@@ -378,7 +373,7 @@ export const APIKeysPanel: FC = props => {
{disabledMessage}
- {!impersonatingUser && }
+ {!impersonatingUser && }
);
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..3f4ef0ce80 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;
@@ -521,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;
@@ -1034,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;
@@ -1322,6 +1333,88 @@ 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;
+ 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/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/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..801c57695e 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
@@ -79,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
new file mode 100644
index 0000000000..09092797be
--- /dev/null
+++ b/packages/components/src/public/QueryModel/useQueryModels.test.tsx
@@ -0,0 +1,743 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { Filter } from '@labkey/api';
+
+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';
+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 { ChangeType, QueryModel } from './QueryModel';
+import { RowsResponse } from './QueryModelLoader';
+import { QueryModelManager, useQueryModels } from './useQueryModels';
+
+// @ts-expect-error Need to use require() for mocking
+
+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 () => new Set(this.selections));
+
+ replaceSelections = jest.fn(async (_model: QueryModel, selections: string[]) => {
+ this.selections = new Set(selections);
+ return { count: this.selections.size };
+ });
+
+ selectAllRows = jest.fn(async (model: QueryModel) => {
+ const all = new Set(model.orderedRows ?? []);
+ this.selections = all;
+ return new Set(all);
+ });
+
+ loadCharts = jest.fn(async () => this.charts.slice());
+}
+
+const makeManager = (
+ configs: Record & { schemaQuery: SchemaQuery }>,
+ 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 waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED));
+ const model = manager.state.queryModels.model;
+ expect(model.queryInfoLoadingState).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 waitFor(() =>
+ expect(manager.state.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED)
+ );
+ const model = manager.state.queryModels.model;
+ expect(model.queryInfoError).toBe('QI boom');
+ });
+
+ test('surfaces rows error', async () => {
+ const { manager, loader } = makeManager({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } });
+ manager.loadModel('model');
+ 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;
+ 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 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.
+ 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 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 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.
+ 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 waitFor(() =>
+ 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 waitFor(() =>
+ expect(manager.state.queryModels.model.totalCountLoadingState).toBe(LoadingState.LOADED)
+ );
+ const model = manager.state.queryModels.model;
+ 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 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();
+ 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 waitFor(() =>
+ expect(result.manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)
+ );
+ 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 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 () => {
+ const { manager } = await setup();
+ manager.loadLastPage('model');
+ const lastOffset = manager.state.queryModels.model.lastPageOffset;
+ expect(manager.state.queryModels.model.offset).toBe(lastOffset);
+ 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);
+ expect(manager.state).toBe(before);
+ });
+
+ test('setMaxRows resets offset to 0', async () => {
+ const { manager } = await setup();
+ manager.loadLastPage('model');
+ 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);
+ 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 waitFor(() =>
+ expect(result.manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)
+ );
+ 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');
+ 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]);
+ 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 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 waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED));
+ 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 waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED));
+
+ 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 waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED));
+ });
+ });
+
+ 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 waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED));
+ 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 waitFor(() => 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 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 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 waitFor(() => {
+ 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 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);
+ });
+
+ 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 waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED));
+ 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 waitFor(() =>
+ expect(result.manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED)
+ );
+ 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 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 waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED));
+ 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 waitFor(() => expect(manager.state.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED));
+ 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);
+ });
+
+ 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 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 () => {
+ const { manager } = makeManager({
+ a: { schemaQuery: MIXTURES_SCHEMA_QUERY, includeTotalCount: true },
+ b: { schemaQuery: MIXTURES_SCHEMA_QUERY, includeTotalCount: true },
+ });
+ manager.loadModel('a');
+ manager.loadModel('b');
+ 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);
+ 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 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);
+ });
+ });
+
+ 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 waitFor(() => 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 waitFor(() => {
+ expect(result.current.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED);
+ expect(result.current.queryModels.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 waitFor(() => {
+ expect(result.current.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED);
+ expect(result.current.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED);
+ });
+
+ act(() => {
+ result.current.actions.loadNextPage(result.current.queryModels.model.id);
+ });
+ 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 { result, unmount } = renderHook(() =>
+ useQueryModels({ model: { schemaQuery: MIXTURES_SCHEMA_QUERY } }, { modelLoader: loader })
+ );
+ 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();
+ });
+
+ 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 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);
+ });
+
+ 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 waitFor(() => {
+ expect(result.current.queryModels.model.queryInfoLoadingState).toBe(LoadingState.LOADED);
+ expect(result.current.queryModels.model.rowsLoadingState).toBe(LoadingState.LOADED);
+ });
+ act(() => {
+ result.current.actions.loadLastPage(result.current.queryModels.model.id);
+ });
+ 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' }));
+ 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
new file mode 100644
index 0000000000..3eaad8bbbc
--- /dev/null
+++ b/packages/components/src/public/QueryModel/useQueryModels.ts
@@ -0,0 +1,960 @@
+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,
+ ChangeType,
+ GridMessage,
+ InjectedQueryModels,
+ locationHasQueryParamSettings,
+ ModelChange,
+ QueryConfig,
+ QueryConfigMap,
+ QueryModel,
+ removeSettingsFromLocalStorage,
+ saveSettingsToLocalStorage,
+} from './QueryModel';
+import {
+ applySavedSettings,
+ bindURL,
+ columnsHaveFilter,
+ filterArraysEqual,
+ initModels,
+ paramsEqual,
+ RequestManager,
+ resetModelState,
+ 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();
+const DEFAULT_SET_SEARCH_PARAMS = () => {};
+
+type ModelUpdater = (model: Draft) => void;
+type VoidFn = () => void;
+type StateUpdater = (state: InjectedQueryModels) => InjectedQueryModels;
+
+export 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,
+ modelLoader?: QueryModelLoader
+ ) {
+ this.onStateChange = NOOP;
+ 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,
+ setSelections: this.setSelections,
+ setSorts: this.setSorts,
+ setView: this.setView,
+ };
+ this.state = {
+ queryModels: initModels(queryConfigs, searchParams),
+ actions: this.actions,
+ };
+ this.modelLoader = modelLoader ?? DefaultQueryModelLoader;
+ }
+
+ updateRouter = (searchParams: URLSearchParams, setSearchParams: SetURLSearchParams) => {
+ this.setSearchParams = setSearchParams;
+
+ if (searchParams !== this.searchParams) {
+ this.searchParams = searchParams;
+ this.updateModelsFromURL();
+ }
+ };
+
+ 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 = (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]: newModel },
+ };
+ });
+ };
+
+ 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 = (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);
+ };
+
+ syncURL = (id: string): void => {
+ if (this.state.queryModels[id]?.bindURL) this.bindURL(id);
+ };
+
+ 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: Record = {};
+ 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);
+ this.saveSettings(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 => {
+ 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 = true, loadSelections = false): void => {
+ const { searchParams } = this;
+ let queryModel = new QueryModel(queryConfig);
+ const id = queryModel.id;
+
+ this.setState(currentState => {
+ 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);
+ this.syncURL(id);
+ };
+
+ clearSelectedReports = (id: string): void => {
+ this.updateModel(id, (model: Draft) => {
+ model.selectedReportIds = [];
+ });
+ this.syncURL(id);
+ };
+
+ 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 => {
+ Object.keys(this.state.queryModels).forEach(id => this.loadModel(id, loadSelections, reloadTotalCount));
+ };
+
+ 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 => {
+ let shouldLoad = false;
+ this.updateModel(id, (model: Draft) => {
+ if (!model.isFirstPage) {
+ shouldLoad = true;
+ model.offset = 0;
+ }
+ });
+
+ this.maybeLoad(id, false, shouldLoad);
+
+ if (shouldLoad) {
+ this.syncURL(id);
+ }
+ };
+
+ loadLastPage = (id: string): void => {
+ let shouldLoad = false;
+ this.updateModel(id, (model: Draft) => {
+ if (!model.isLastPage) {
+ shouldLoad = true;
+ model.offset = model.lastPageOffset;
+ }
+ });
+ this.maybeLoad(id, false, shouldLoad);
+
+ if (shouldLoad) {
+ this.syncURL(id);
+ }
+ };
+
+ loadModel = (id: string, loadSelections = false, reloadTotalCount = false): void => {
+ this.loadQueryInfo(id, true, loadSelections, reloadTotalCount);
+ this.syncURL(id);
+ };
+
+ loadNextPage = (id: string): void => {
+ let shouldLoad = false;
+ this.updateModel(id, (model: Draft) => {
+ if (!model.isLastPage) {
+ shouldLoad = true;
+ model.offset = model.offset + model.maxRows;
+ }
+ });
+ this.maybeLoad(id, false, shouldLoad);
+
+ if (shouldLoad) {
+ this.syncURL(id);
+ }
+ };
+
+ loadPreviousPage = (id: string): void => {
+ let shouldLoad = false;
+ this.updateModel(id, (model: Draft) => {
+ if (!model.isFirstPage) {
+ shouldLoad = true;
+ model.offset = model.offset - model.maxRows;
+ }
+ });
+ this.maybeLoad(id, false, shouldLoad);
+
+ if (shouldLoad) {
+ this.syncURL(id);
+ }
+ };
+
+ 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 = 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);
+ }
+ }
+ };
+
+ 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]);
+ });
+ };
+
+ 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 => {
+ 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) => {
+ this.updateModel(id, (model: Draft) => {
+ if (model.messages !== undefined) {
+ model.messages = model.messages.filter(m => m.content !== message.content);
+ }
+ });
+ };
+
+ 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) {
+ 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 => {
+ 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 = 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) {
+ this.setSelectionsError(id, error, 'setting');
+ }
+ };
+
+ selectPage = (id: string, checked: boolean): void => {
+ this.setSelections(id, checked, this.state.queryModels[id].orderedRows);
+ };
+
+ selectReport = (id: string, reportId: string, selected: boolean): void => {
+ 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
+ selectRow = (id: string, checked: boolean, row: Record, useSelectionPivot?: boolean): void => {
+ 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 = 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);
+
+ if (shouldLoad) {
+ this.saveSettings(id);
+ this.syncURL(id);
+ }
+ };
+
+ setMaxRows = (id: string, maxRows: number): void => {
+ 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);
+
+ if (shouldLoad) {
+ this.saveSettings(id);
+ this.syncURL(id);
+ }
+ };
+
+ 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);
+
+ if (shouldLoad) {
+ this.syncURL(id);
+ }
+ };
+
+ 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 => {
+ let shouldLoad = false;
+ this.updateModel(id, (model: Draft) => {
+ if (!sortArraysEqual(model.sorts, sorts)) {
+ shouldLoad = true;
+ model.sorts = sorts;
+ }
+ });
+ this.maybeLoad(id, false, shouldLoad);
+
+ if (shouldLoad) {
+ this.saveSettings(id);
+ this.syncURL(id);
+ }
+ };
+
+ setView = (id: string, viewName: string, loadSelections = false): void => {
+ 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);
+
+ if (shouldLoad) {
+ this.saveSettings(id);
+ this.syncURL(id);
+ }
+ };
+}
+
+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];
+}
+
+interface UseQueryModelsOptions {
+ autoLoad?: boolean;
+ 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();
+ const manager = useRef(null);
+
+ /* eslint-disable react-hooks/refs */
+ if (!manager.current) {
+ 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);
+ }, [searchParams, setSearchParams]);
+ /* eslint-enable react-hooks/refs */
+
+ return state;
+}
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;
diff --git a/packages/components/src/public/QueryModel/utils.ts b/packages/components/src/public/QueryModel/utils.ts
index 3d41a235d9..2037fbe1ff 100644
--- a/packages/components/src/public/QueryModel/utils.ts
+++ b/packages/components/src/public/QueryModel/utils.ts
@@ -14,12 +14,25 @@ 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,
+ locationHasQueryParamSettings,
+ QueryModel,
+ QueryModelMap,
+ SavedSettings,
+ saveSettingsToLocalStorage,
+} from './QueryModel';
+import { Draft } from 'immer';
+import { RequestHandler } from '../../internal/request';
+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()}`;
@@ -203,3 +216,210 @@ 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 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 = 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;
+}
+
+/**
+ * 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;
+}
+
+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 34ee95a32a..5a9f38706b 100644
--- a/packages/components/src/public/QueryModel/withQueryModels.tsx
+++ b/packages/components/src/public/QueryModel/withQueryModels.tsx
@@ -2,34 +2,48 @@ 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';
-
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,
+ bindURL,
+ columnsHaveFilter,
+ filterArraysEqual,
+ initModels,
+ paramsEqual,
+ RequestManager,
+ resetModelState,
+ 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,
} from './QueryModel';
+import { useQueryModels } from './useQueryModels';
export interface SearchParamsProps {
searchParams: URLSearchParams;
@@ -58,99 +72,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,204 +82,16 @@ 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.
* @returns A react ComponentType that implements generic Props and MakeQueryModels.
*/
-export function withQueryModels(
+export function withQueryModelsOld(
ComponentToWrap: ComponentType
): 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 +100,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,
@@ -392,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,
@@ -482,27 +216,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 => {
@@ -906,27 +620,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];
@@ -1242,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(
@@ -1401,3 +1078,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;
+}
diff --git a/packages/components/src/test/MockQueryModelLoader.ts b/packages/components/src/test/MockQueryModelLoader.ts
index 65a645ab74..31bd1ce216 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;
@@ -47,12 +48,16 @@ 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!');
};
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ loadTotalCount = async (model: QueryModel, requestHandler: RequestHandler) => {
+ return this.rowsResponse.orderedRows.length;
+ };
+
setSelections = (model: QueryModel, checked: boolean, selections: string[]): Promise => {
return new Promise(resolve => {
setTimeout(() => {
@@ -61,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!');
};
@@ -80,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!');
};
}