From d014f5ed28f343f886d26b4b6d2fe69fdc896af3 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 28 Jul 2026 09:29:45 -0700 Subject: [PATCH 1/3] SchemaQuery.detailView --- .../components/editable/LookupCell.tsx | 9 +-- .../internal/components/editable/actions.ts | 5 +- .../internal/components/entities/actions.ts | 3 +- .../lineage/node/LineageNodeDetail.tsx | 3 +- .../internal/components/samples/utils.test.ts | 4 +- packages/components/src/internal/schemas.ts | 4 - .../src/public/QueryModel/QueryModel.ts | 2 +- .../components/src/public/SchemaQuery.test.ts | 78 +++++++++++++++++++ packages/components/src/public/SchemaQuery.ts | 17 +++- 9 files changed, 100 insertions(+), 25 deletions(-) diff --git a/packages/components/src/internal/components/editable/LookupCell.tsx b/packages/components/src/internal/components/editable/LookupCell.tsx index a400df4412..ff2ec4acbf 100644 --- a/packages/components/src/internal/components/editable/LookupCell.tsx +++ b/packages/components/src/internal/components/editable/LookupCell.tsx @@ -7,12 +7,10 @@ import { Filter, Query } from '@labkey/api'; import { List } from 'immutable'; import { QueryColumn } from '../../../public/QueryColumn'; -import { SchemaQuery } from '../../../public/SchemaQuery'; import { LOOKUP_DEFAULT_SIZE } from '../../constants'; import { getContainerFilterForLookups } from '../../query/api'; -import { ViewInfo } from '../../ViewInfo'; import { SelectInputChange } from '../forms/input/SelectInput'; import { TextChoiceInput } from '../forms/input/TextChoiceInput'; import { QuerySelect } from '../forms/QuerySelect'; @@ -80,11 +78,6 @@ const QueryLookupCell: FC = memo(props => { ); }, [col, filteredLookupKeys, filteredLookupValues, forUpdate, lookupValueFilters]); - const schemaQuery = useMemo( - () => new SchemaQuery(lookup.schemaQuery.schemaName, lookup.schemaQuery.queryName, ViewInfo.DETAIL_NAME), - [lookup] - ); - let selectValue = isMultiple ? rawValues : rawValues[0]; // Issue 49502: Some column types have special handling of raw data, i.e. Alias @@ -109,7 +102,7 @@ const QueryLookupCell: FC = memo(props => { onQSChange={onSelectChange} preLoad queryFilters={queryFilters} - schemaQuery={schemaQuery} + schemaQuery={lookup.schemaQuery.detailView} value={selectValue} /> ); diff --git a/packages/components/src/internal/components/editable/actions.ts b/packages/components/src/internal/components/editable/actions.ts index 180737cfdf..c26bbff2a6 100644 --- a/packages/components/src/internal/components/editable/actions.ts +++ b/packages/components/src/internal/components/editable/actions.ts @@ -23,7 +23,6 @@ import { quoteValueWithDelimiters, splitMultiValueForImport, } from '../../util/utils'; -import { ViewInfo } from '../../ViewInfo'; import { getContainerFilterForLookups } from '../../query/api'; @@ -306,8 +305,8 @@ const findLookupValues = async (options: FindLookupValuesOptions): Promise((desc, row) => { diff --git a/packages/components/src/internal/components/entities/actions.ts b/packages/components/src/internal/components/entities/actions.ts index e5c68622a5..81d50bdcfe 100644 --- a/packages/components/src/internal/components/entities/actions.ts +++ b/packages/components/src/internal/components/entities/actions.ts @@ -817,8 +817,7 @@ async function getParentRowIdAndDataType( ): Promise> { const response = await selectRows({ containerPath, - schemaQuery: parentDataType.listingSchemaQuery, - viewName: ViewInfo.DETAIL_NAME, // use this to avoid filters on the default view + schemaQuery: parentDataType.listingSchemaQuery.detailView, // use this to avoid filters on the default view columns: 'LSID, RowId, DataClass, SampleSet', // only one of DataClass or SampleSet will exist filterArray: [Filter.create('LSID', parentIDs, Filter.Types.IN)], }); diff --git a/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx b/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx index 024f022797..5114840116 100644 --- a/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx +++ b/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx @@ -28,7 +28,6 @@ import { DetailsListLineageIO, DetailsListNodes, DetailsListSteps } from './Deta import { InjectedQueryModels, QueryConfigMap, 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 { LINEAGE_DETAIL_REQUIRED_COLS } from '../constants'; @@ -101,7 +100,7 @@ export const LineageNodeDetail: FC = memo(props => { baseFilters: node.pkFilters.map(pkFilter => Filter.create(pkFilter.fieldKey, pkFilter.value)), containerPath: node.containerPath, // Issue 45028: Display details view columns in lineage - schemaQuery: new SchemaQuery(node.schemaName, node.queryName, ViewInfo.DETAIL_NAME), + schemaQuery: new SchemaQuery(node.schemaName, node.queryName).detailView, requiredColumns: LINEAGE_DETAIL_REQUIRED_COLS, }, }; diff --git a/packages/components/src/internal/components/samples/utils.test.ts b/packages/components/src/internal/components/samples/utils.test.ts index 5cc4d792a0..c74586d474 100644 --- a/packages/components/src/internal/components/samples/utils.test.ts +++ b/packages/components/src/internal/components/samples/utils.test.ts @@ -321,7 +321,7 @@ describe('isAllSamplesSchema', () => { }); test('exp.materials query', () => { const sq = SCHEMAS.EXP_TABLES.MATERIALS; - const details = new SchemaQuery(sq.schemaName, sq.queryName, ViewInfo.DETAIL_NAME); + const details = new SchemaQuery(sq.schemaName, sq.queryName).detailView; const otherView = new SchemaQuery(sq.schemaName.toUpperCase(), sq.queryName.toUpperCase(), 'otherView'); expect(isAllSamplesSchema(sq)).toBeTruthy(); expect(isAllSamplesSchema(details)).toBeTruthy(); @@ -393,7 +393,7 @@ describe('isWorkflowInputSamplesSchema', () => { }); test('job input samples', () => { const sq = SCHEMAS.WORKFLOW.JOB_INPUT_SAMPLES; - const details = new SchemaQuery(sq.schemaName, sq.queryName, ViewInfo.DETAIL_NAME); + const details = new SchemaQuery(sq.schemaName, sq.queryName).detailView; const otherView = new SchemaQuery(sq.schemaName.toUpperCase(), sq.queryName.toUpperCase(), 'otherView'); expect(isWorkflowInputSamplesSchema(sq)).toBeTruthy(); expect(isWorkflowInputSamplesSchema(details)).toBeTruthy(); diff --git a/packages/components/src/internal/schemas.ts b/packages/components/src/internal/schemas.ts index fe8613ba42..dd1f73575e 100644 --- a/packages/components/src/internal/schemas.ts +++ b/packages/components/src/internal/schemas.ts @@ -6,8 +6,6 @@ import { List } from 'immutable'; import { SchemaQuery } from '../public/SchemaQuery'; -import { ViewInfo } from './ViewInfo'; - // Created By / Modified By export const CBMB = List(['Created', 'CreatedBy', 'Modified', 'ModifiedBy']); @@ -15,7 +13,6 @@ export const CBMB = List(['Created', 'CreatedBy', 'Modified', 'ModifiedB const ASSAY_SCHEMA = 'assay'; export const ASSAY_TABLES = { ASSAY_LIST: new SchemaQuery(ASSAY_SCHEMA, 'AssayList'), - ASSAY_DETAILS_SQ: new SchemaQuery(ASSAY_SCHEMA, 'AssayList', ViewInfo.DETAIL_NAME), ASSAY_RUN_COUNTS: new SchemaQuery(ASSAY_SCHEMA, 'AssayRunCounts'), ASSAY_RUNS: new SchemaQuery(ASSAY_SCHEMA, 'AssayRuns'), ASSAY_RUNS_PER_SAMPLE: new SchemaQuery(ASSAY_SCHEMA, 'AssayRunsPerSample'), @@ -35,7 +32,6 @@ export const EXP_TABLES = { RUN_GROUPS: new SchemaQuery(EXP_SCHEMA, 'RunGroups'), SCHEMA: EXP_SCHEMA, SAMPLE_SETS: new SchemaQuery(EXP_SCHEMA, 'SampleSets'), - SAMPLE_SETS_DETAILS: new SchemaQuery(EXP_SCHEMA, 'SampleSets', ViewInfo.DETAIL_NAME), SAMPLE_STATUS: new SchemaQuery(EXP_SCHEMA, 'SampleStatus'), DATA_COLORS: new SchemaQuery(EXP_SCHEMA, 'DataColors'), }; diff --git a/packages/components/src/public/QueryModel/QueryModel.ts b/packages/components/src/public/QueryModel/QueryModel.ts index 5eaba3e39a..dc774134b3 100644 --- a/packages/components/src/public/QueryModel/QueryModel.ts +++ b/packages/components/src/public/QueryModel/QueryModel.ts @@ -494,7 +494,7 @@ export class QueryModel { // Note: this default may not be appropriate outside of Biologics/SM if (keyValue !== undefined && schemaQuery.viewName === undefined) { const { schemaName, queryName } = schemaQuery; - this.schemaQuery = new SchemaQuery(schemaName, queryName, ViewInfo.DETAIL_NAME); + this.schemaQuery = new SchemaQuery(schemaName, queryName).detailView; this.bindURL = false; } else { this.schemaQuery = schemaQuery; diff --git a/packages/components/src/public/SchemaQuery.test.ts b/packages/components/src/public/SchemaQuery.test.ts index 0c04a0ccce..b4ae6b66ae 100644 --- a/packages/components/src/public/SchemaQuery.test.ts +++ b/packages/components/src/public/SchemaQuery.test.ts @@ -2,6 +2,8 @@ * Copyright (c) 2020-2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ +import { ViewInfo } from '../internal/ViewInfo'; + import { getSchemaQuery, resolveKey, resolveKeyFromJson, SchemaQuery } from './SchemaQuery'; describe('SchemaQuery', () => { @@ -202,6 +204,82 @@ describe('SchemaQuery', () => { }); }); + describe('detailView', () => { + test('adds the detail view name, preserving schema and query case', () => { + const detailView = new SchemaQuery('Schema.SubSchema', 'Query').detailView; + expect(detailView.schemaName).toEqual('Schema.SubSchema'); + expect(detailView.queryName).toEqual('Query'); + expect(detailView.viewName).toEqual(ViewInfo.DETAIL_NAME); + }); + + test('does not mutate the source schema query', () => { + const sq = new SchemaQuery('s', 'q'); + expect(sq.detailView).not.toBe(sq); + expect(sq.viewName).toBeUndefined(); + }); + + test('replaces an existing view name', () => { + const sq = new SchemaQuery('s', 'q', 'someOtherView'); + expect(sq.detailView).not.toBe(sq); + expect(sq.detailView.viewName).toEqual(ViewInfo.DETAIL_NAME); + expect(sq.viewName).toEqual('someOtherView'); + }); + + test('returns itself when it is already the detail view', () => { + const sq = new SchemaQuery('s', 'q', ViewInfo.DETAIL_NAME); + expect(sq.detailView).toBe(sq); + }); + + test('is idempotent', () => { + const detailView = new SchemaQuery('s', 'q').detailView; + expect(detailView.detailView).toBe(detailView); + }); + + test('memoizes the derived instance', () => { + const sq = new SchemaQuery('s', 'q'); + expect(sq.detailView).toBe(sq.detailView); + }); + + test('memoization does not affect structural equality of the source', () => { + const sq = new SchemaQuery('s', 'q'); + expect(sq.detailView.viewName).toEqual(ViewInfo.DETAIL_NAME); + expect(sq).toEqual(new SchemaQuery('s', 'q')); + }); + + test('view name comparison is case-insensitive', () => { + const lowerViewName = ViewInfo.DETAIL_NAME.toLowerCase(); + const sq = new SchemaQuery('s', 'q', lowerViewName); + expect(sq.detailView).toBe(sq); + expect(sq.detailView.viewName).toEqual(lowerViewName); + }); + + test('undefined schema and query names', () => { + const detailView = new SchemaQuery(undefined, undefined).detailView; + expect(detailView.schemaName).toBeUndefined(); + expect(detailView.queryName).toBeUndefined(); + expect(detailView.viewName).toEqual(ViewInfo.DETAIL_NAME); + }); + + test('equivalent to explicitly constructing the detail view', () => { + const explicit = new SchemaQuery('schema.subschema', 'query', ViewInfo.DETAIL_NAME); + expect(SQ.detailView.isEqual(explicit)).toEqual(true); + expect(SQ.detailView.isEqual(SQ)).toEqual(false); + expect(SQ.detailView.isEqual(SQ, false)).toEqual(true); + }); + + test('getKey encodes the detail view name', () => { + const detailView = new SchemaQuery('s', 'q').detailView; + expect(detailView.getKey()).toEqual('s/q/$t$tdetails$t$t'); + expect(detailView.getKey(false)).toEqual('s/q'); + }); + + test('toString includes the detail view name', () => { + const detailView = new SchemaQuery('s', 'q').detailView; + expect(detailView.toString()).toEqual('s|q|~~DETAILS~~'); + expect(detailView.toString(false)).toEqual('s|q'); + }); + }); + describe('isEqual', () => { test('null or undefined argument', () => { expect(SQ.isEqual(undefined)).toEqual(false); diff --git a/packages/components/src/public/SchemaQuery.ts b/packages/components/src/public/SchemaQuery.ts index 4cbf503641..083b081460 100644 --- a/packages/components/src/public/SchemaQuery.ts +++ b/packages/components/src/public/SchemaQuery.ts @@ -2,6 +2,8 @@ * Copyright (c) 2020-2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ +import { ViewInfo } from '../internal/ViewInfo'; + const APP_SELECTION_PREFIX = 'appkey'; export const SELECTION_SNAPSHOT_SEP = '__snapshot__'; // Defined in this module to prevent circular imports @@ -83,9 +85,10 @@ function equalsIgnoreCase(a?: string, b?: string): boolean { } export class SchemaQuery { - schemaName: string; - queryName: string; - viewName: string; + readonly schemaName: string; + readonly queryName: string; + readonly viewName?: string; + #detailView?: SchemaQuery; constructor(schemaName: string, queryName: string, viewName?: string) { this.schemaName = schemaName; @@ -93,6 +96,14 @@ export class SchemaQuery { this.viewName = viewName; } + get detailView(): SchemaQuery { + if (equalsIgnoreCase(this.viewName, ViewInfo.DETAIL_NAME)) return this; + if (!this.#detailView) { + this.#detailView = new SchemaQuery(this.schemaName, this.queryName, ViewInfo.DETAIL_NAME); + } + return this.#detailView; + } + isEqual(sq: SchemaQuery, includeViewName = true): boolean { if (!sq) return false; From 16682c2765189d0fd1ac5a2520c2de6df8eea62e Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 28 Jul 2026 09:55:02 -0700 Subject: [PATCH 2/3] GitHub Issue 1357 --- .../components/entities/actions.test.ts | 74 +++++++++++++++++-- .../internal/components/entities/actions.ts | 7 +- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/packages/components/src/internal/components/entities/actions.test.ts b/packages/components/src/internal/components/entities/actions.test.ts index 3aaed54d36..924f29de59 100644 --- a/packages/components/src/internal/components/entities/actions.test.ts +++ b/packages/components/src/internal/components/entities/actions.test.ts @@ -3,11 +3,34 @@ * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ import { Map } from 'immutable'; +import { Filter } from '@labkey/api'; -import { extractEntityTypeOptionFromRow, getChosenParentData, getFieldDisplayValue, sampleGenCellKey } from './actions'; +import { SchemaQuery } from '../../../public/SchemaQuery'; +import { Row, selectRows, SelectRowsOptions, SelectRowsResponse } from '../../query/selectRows'; + +import { + extractEntityTypeOptionFromRow, + getChosenParentData, + getFieldDisplayValue, + getSelectedParents, + sampleGenCellKey, +} from './actions'; import { EntityDataType, EntityIdCreationModel } from './models'; import { DataClassDataType, SampleTypeDataType } from './constants'; +jest.mock('../../query/selectRows', () => ({ + ...jest.requireActual('../../query/selectRows'), + selectRows: jest.fn().mockResolvedValue({ rows: [] }), +})); + +const mockSelectRows = selectRows as jest.MockedFunction; + +const getSelectRowsOptions = (): SelectRowsOptions => mockSelectRows.mock.calls[0][0]; + +const mockSelectRowsResponse = (rows: Row[]): void => { + mockSelectRows.mockResolvedValue({ rows } as SelectRowsResponse); +}; + describe('extractEntityTypeOptionFromRow', () => { const NAME = 'Test Name'; const ROW = { @@ -33,8 +56,8 @@ describe('getChosenParentData', () => { test('allowParents = false', async () => { const result = await getChosenParentData(new EntityIdCreationModel(), PARENT_ENTITY_DATA_TYPES, false); - expect(result.originalParents).toBe(undefined); - expect(result.selectionKey).toBe(undefined); + expect(result.originalParents).toBeUndefined(); + expect(result.selectionKey).toBeUndefined(); expect(result.entityParents.size).toBe(2); expect(result.entityParents.get(SampleTypeDataType.typeListingSchemaQuery.queryName).size).toBe(0); expect(result.entityParents.get(DataClassDataType.typeListingSchemaQuery.queryName).size).toBe(0); @@ -50,8 +73,8 @@ describe('getChosenParentData', () => { PARENT_ENTITY_DATA_TYPES, true ); - expect(result.originalParents).toBe(undefined); - expect(result.selectionKey).toBe(undefined); + expect(result.originalParents).toBeUndefined(); + expect(result.selectionKey).toBeUndefined(); expect(result.entityParents.size).toBe(2); expect(result.entityParents.get(SampleTypeDataType.typeListingSchemaQuery.queryName).size).toBe(1); expect(result.entityParents.get(DataClassDataType.typeListingSchemaQuery.queryName).size).toBe(0); @@ -67,8 +90,8 @@ describe('getChosenParentData', () => { PARENT_ENTITY_DATA_TYPES, true ); - expect(result.originalParents).toBe(undefined); - expect(result.selectionKey).toBe(undefined); + expect(result.originalParents).toBeUndefined(); + expect(result.selectionKey).toBeUndefined(); expect(result.entityParents.size).toBe(2); expect(result.entityParents.get(SampleTypeDataType.typeListingSchemaQuery.queryName).size).toBe(0); expect(result.entityParents.get(DataClassDataType.typeListingSchemaQuery.queryName).size).toBe(0); @@ -93,7 +116,9 @@ describe('sampleGenCellKey', () => { describe('getFieldDisplayValue', () => { test('returns formattedValue when available', () => { - expect(getFieldDisplayValue({ formattedValue: 'formatted', displayValue: 'display', value: 'raw' })).toBe('formatted'); + expect(getFieldDisplayValue({ formattedValue: 'formatted', displayValue: 'display', value: 'raw' })).toBe( + 'formatted' + ); }); test('falls back to displayValue when formattedValue is undefined', () => { @@ -120,3 +145,36 @@ describe('getFieldDisplayValue', () => { expect(getFieldDisplayValue({ value: ['only'] })).toBe('only'); }); }); + +describe('getSelectedParents', () => { + const SAMPLE_SQ = new SchemaQuery('samples', 'Blood'); + const DATA_CLASS_SQ = new SchemaQuery('exp.data', 'Ingredients'); + const FILTERS = [Filter.create('RowId', [1, 2], Filter.Types.IN)]; + + beforeEach(() => { + mockSelectRows.mockClear(); + mockSelectRowsResponse([]); + }); + + // GitHub Issue 1357: the selected parents must be resolved from the detail view so that filters applied to the + // default view (or to whichever view the selection came from) don't drop selected parents from the response. + test('queries the detail view for a sample parent', async () => { + await getSelectedParents(SAMPLE_SQ, FILTERS); + + expect(mockSelectRows).toHaveBeenCalledTimes(1); + const { columns, filterArray, schemaQuery } = getSelectRowsOptions(); + expect(SAMPLE_SQ.detailView.isEqual(schemaQuery)).toBe(true); + expect(columns).toEqual(['LSID', 'Name', 'RowId', 'SampleSet']); + expect(filterArray).toBe(FILTERS); + }); + + test('queries the detail view for a data class parent', async () => { + await getSelectedParents(DATA_CLASS_SQ, FILTERS); + + expect(mockSelectRows).toHaveBeenCalledTimes(1); + const { columns, filterArray, schemaQuery } = getSelectRowsOptions(); + expect(DATA_CLASS_SQ.detailView.isEqual(schemaQuery)).toBe(true); + expect(columns).toEqual(['LSID', 'Name', 'RowId', 'DataClass']); + expect(filterArray).toBe(FILTERS); + }); +}); diff --git a/packages/components/src/internal/components/entities/actions.ts b/packages/components/src/internal/components/entities/actions.ts index 81d50bdcfe..91410d74da 100644 --- a/packages/components/src/internal/components/entities/actions.ts +++ b/packages/components/src/internal/components/entities/actions.ts @@ -37,8 +37,6 @@ import { SCHEMAS } from '../../schemas'; import { Row, selectRows, SelectRowsResponse } from '../../query/selectRows'; -import { ViewInfo } from '../../ViewInfo'; - import { getAppHomeFolderPath, getFolderDataExclusion, hasModule } from '../../app/utils'; import { resolveErrorMessage } from '../../util/messaging'; @@ -259,7 +257,7 @@ export async function getOperationConfirmationDataForModel( return getOperationConfirmationData(dataType, model.getSelectedIds(), undefined, undefined, extraParams); } -async function getSelectedParents( +export async function getSelectedParents( schemaQuery: SchemaQuery, filterArray: Filter.IFilter[], isAliquotParent?: boolean, @@ -273,7 +271,8 @@ async function getSelectedParents( columns.push('DataClass'); } - const response = await selectRows({ columns, filterArray, schemaQuery }); + // GitHub Issue 1357: Resolve selected parents from details view to avoid filters applied to default view + const response = await selectRows({ columns, filterArray, schemaQuery: schemaQuery.detailView }); if (isSampleParent) { return resolveSampleParentTypes(response, isAliquotParent, orderedRowIds); From 11dfb6876d82d492b9cf80e8a050ba09fc49bf20 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 28 Jul 2026 09:55:18 -0700 Subject: [PATCH 3/3] 7.49.1-fb-sq-detail.0 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index f9f1bdd1a4..028febb3c8 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.49.0", + "version": "7.49.1-fb-sq-detail.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.49.0", + "version": "7.49.1-fb-sq-detail.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 1ac0a04509..1c895e493d 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.49.0", + "version": "7.49.1-fb-sq-detail.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [