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 (
- {showConfirmDelete && ( Deletion cannot be undone. Do you want to proceed? @@ -90,7 +85,7 @@ APIKeysButtonsComponent.displayName = 'APIKeysButtonsComponent'; interface KeyGeneratorProps { afterCreate?: () => void; noun: string; - type: 'session' | 'apikey'; + type: 'apikey' | 'session'; } interface ModalProps extends KeyGeneratorProps { @@ -139,23 +134,23 @@ export const KeyGeneratorModal: FC = props => { return ( {type === 'apikey' && !keyValue && (
)} @@ -173,10 +168,10 @@ export const KeyGeneratorModal: FC = props => { />
- {showModal && } + {showModal && } ); }; @@ -240,7 +235,7 @@ const SessionKeysSection: FC = memo(() => ( times out your session. Since they expire quickly, session keys are most appropriate for deployments with regulatory compliance requirements.

- + )); SessionKeysSection.displayName = 'SessionKeysSection'; @@ -249,8 +244,22 @@ interface APIKeysGridProps { includeSessionKeys?: boolean; } -const APIKeysPanelGrid: FC = props => { - const { actions, includeSessionKeys, queryModels } = props; +const APIKeysGrid: FC = props => { + const { includeSessionKeys } = props; + const { homeContainer } = useServerContext(); + const configs: QueryConfigMap = useMemo( + () => ({ + model: { + id: 'model', + title: 'Current API Keys', + schemaQuery: SCHEMAS.CORE_TABLES.USER_API_KEYS, + includeTotalCount: true, + containerPath: homeContainer, + }, + }), + [homeContainer] + ); + const { actions, queryModels } = useQueryModels(configs, { autoLoad: true }); const { model } = queryModels; const { moduleContext } = useServerContext(); const [error, setError] = useState(); @@ -275,48 +284,34 @@ const APIKeysPanelGrid: FC = props => {
{error} - {apiEnabled && } + {apiEnabled && } {sessionEnabled && includeSessionKeys && }
); }; -APIKeysPanelGrid.displayName = 'APIKeysPanelGrid'; - -const APIKeysPanelWithQueryModels = withQueryModels(APIKeysPanelGrid); +APIKeysGrid.displayName = 'APIKeysGrid'; export const APIKeysPanel: FC = props => { const { includeSessionKeys } = props; - const { homeContainer, impersonatingUser, moduleContext, user } = useServerContext(); + const { impersonatingUser, moduleContext, user } = useServerContext(); const isImpersonating = !!impersonatingUser; const apiEnabled = isApiKeyGenerationEnabled(moduleContext); const sessionEnabled = isSessionKeyGenerationEnabled(moduleContext); - const configs: QueryConfigMap = useMemo( - () => ({ - model: { - id: 'model', - title: 'Current API Keys', - schemaQuery: SCHEMAS.CORE_TABLES.USER_API_KEYS, - includeTotalCount: true, - containerPath: homeContainer, - }, - }), - [homeContainer] - ); // We are meant to not show this panel for LKSM Starter, but show it in LKS and LKSM Prof+ if (isApp() && !isFeatureEnabled(ProductFeature.ApiKeys, moduleContext)) return null; @@ -378,7 +373,7 @@ export const APIKeysPanel: FC = props => { {disabledMessage} - {!impersonatingUser && } + {!impersonatingUser && } ); 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!'); }; }