diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 8ef7bc3fe5..b26d577517 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.42.2", + "version": "7.42.3-lineageStatusFilter.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.42.2", + "version": "7.42.3-lineageStatusFilter.4", "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 6d1c0f35a0..3cb32729ae 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.42.2", + "version": "7.42.3-lineageStatusFilter.4", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index e8562f1d56..1f405556c1 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,13 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version TBD +*Released*: TBD +- Support for filtering the sample lineage graph by sample status + - Add metric constant + - Add `sampleStatus` field to `LineageNode` + - Add `rowId` field to `SampleStatus` model + ### version 7.42.2 *Released*: 22 June 2026 - Styling update for user comment on large storage modal diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 40ce92de9d..f3fe8ea07e 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -395,6 +395,7 @@ import { getSampleStatus, getSampleStatusColor, getSampleStatusContainerFilter, + getSampleStatusFromSampleRow, getSampleStatusType, isAllSamplesSchema, isSampleOperationPermitted, @@ -422,7 +423,7 @@ import { TestLineageAPIWrapper, } from './internal/components/lineage/actions'; import { withLineage } from './internal/components/lineage/withLineage'; -import { DEFAULT_LINEAGE_DISTANCE } from './internal/components/lineage/constants'; +import { DEFAULT_LINEAGE_DISTANCE, LINEAGE_GRAPH_FILTER_METRIC} from './internal/components/lineage/constants'; import { LINEAGE_DIRECTIONS, LINEAGE_GROUPING_GENERATIONS, @@ -527,6 +528,7 @@ import { EntityCreationType, INDEPENDENT_SAMPLE_CREATION, POOLED_SAMPLE_CREATION, + SampleState, SampleStateType, } from './internal/components/samples/models'; import { DEFAULT_ALIQUOT_NAMING_PATTERN, SampleTypeModel } from './internal/components/domainproperties/samples/models'; @@ -1407,6 +1409,7 @@ export { getSampleStatus, getSampleStatusColor, getSampleStatusContainerFilter, + getSampleStatusFromSampleRow, getSampleStatusType, getSamplesTestAPIWrapper, getSampleTypeDetails, @@ -1513,6 +1516,7 @@ export { LabelHelpTip, LabelOverlay, LINEAGE_DIRECTIONS, + LINEAGE_GRAPH_FILTER_METRIC, LINEAGE_GROUPING_GENERATIONS, LineageDepthLimitMessage, LineageFilter, @@ -1657,6 +1661,7 @@ export { SampleParentDataType, SamplePropertyDataType, SamplesEditButtonSections, + SampleState, SampleStateType, SampleStatusLegend, SampleStatusRenderer, diff --git a/packages/components/src/internal/components/lineage/constants.tsx b/packages/components/src/internal/components/lineage/constants.tsx index 90a5fdf559..1742f70ffe 100644 --- a/packages/components/src/internal/components/lineage/constants.tsx +++ b/packages/components/src/internal/components/lineage/constants.tsx @@ -17,7 +17,7 @@ import { LineageURLResolvers, } from './types'; import { SAMPLE_STATE_COLOR_COLUMN_NAME, SAMPLE_STATE_TYPE_COLUMN_NAME } from '../samples/constants'; - +export const LINEAGE_GRAPH_FILTER_METRIC = "LineageGraphFilter"; // Default depth to fetch with the lineage API export const DEFAULT_LINEAGE_DISTANCE = 5; export const DEFAULT_LINEAGE_DIRECTION = LINEAGE_DIRECTIONS.Children; diff --git a/packages/components/src/internal/components/lineage/models.test.ts b/packages/components/src/internal/components/lineage/models.test.ts index 81d4f4e08d..adc0cc7f75 100644 --- a/packages/components/src/internal/components/lineage/models.test.ts +++ b/packages/components/src/internal/components/lineage/models.test.ts @@ -2,9 +2,9 @@ * 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 { applyLineageOptions, generateNodesAndEdges, LineageIO, LineageNode, LineageResult } from './models'; +import { applyLineageOptions, generateNodesAndEdges, Lineage, LineageIO, LineageNode, LineageResult } from './models'; import { DEFAULT_LINEAGE_OPTIONS } from './constants'; -import { LineageFilter } from './types'; +import { LineageFilter, LineageOptions } from './types'; describe('lineage model', () => { describe('applyLineageOptions', () => { @@ -33,6 +33,7 @@ describe('lineage model', () => { describe('LineageIO.applyConfig', () => { const lineageObj = { container: 'container', + containerPath: '/container', created: '2022-01-20', createdBy: 'me', modified: '2022-01-21', @@ -235,13 +236,13 @@ describe('lineage model', () => { const parentLsid = 'parent-lsid'; const childNode = LineageNode.create(childLsid, { - parents: [{ lsid: parentLsid, name: '&4[0' }], - name: '&4[0_1001' + parents: [{ lsid: parentLsid }], + name: '&4[0_1001', }); const parentNode = LineageNode.create(parentLsid, { - children: [{ lsid: childLsid, name: '&4[0_1001' }], - name: '&4[0' + children: [{ lsid: childLsid }], + name: '&4[0', }); const result = LineageResult.create({ @@ -262,5 +263,87 @@ describe('lineage model', () => { expect(nodes[childNode.lsid].level).toEqual(0); expect(nodes[childNode.lsid].label).toEqual('&4[0_1001'); }); + + it('GH Issue #1256: Lineage graph can become disconnected with single aliquot and multiple derivatives', () => { + // Parent sample with 2 derived samples and 1 aliquot including derivation runs + const result = LineageResult.create({ + nodes: { + 'aliquot-lsid': { + cpasType: 'sample-type-lsid', + materialLineageType: 'Aliquot', + type: 'Sample', + lsid: 'aliquot-lsid', + name: 'Aliquot-1', + expType: 'Material', + parents: [{ lsid: 'aliquot-run-lsid' }], + }, + 'aliquot-run-lsid': { + cpasType: 'urn:lsid:labkey.org:Protocol:SampleAliquotProtocol', + type: 'Run', + lsid: 'aliquot-run-lsid', + children: [{ lsid: 'aliquot-lsid' }], + name: 'Create aliquot from Parent-1', + expType: 'ExperimentRun', + parents: [{ lsid: 'parent-lsid' }], + }, + 'derived-one-lsid': { + cpasType: 'sample-type-lsid', + materialLineageType: 'Derivative', + type: 'Sample', + lsid: 'derived-one-lsid', + name: 'Derived-1', + expType: 'Material', + parents: [{ lsid: 'derivation-run-lsid' }], + }, + 'derived-two-lsid': { + cpasType: 'sample-type-lsid', + materialLineageType: 'Derivative', + type: 'Sample', + lsid: 'derived-two-lsid', + name: 'Derived-2', + expType: 'Material', + parents: [{ lsid: 'derivation-run-lsid' }], + }, + 'derivation-run-lsid': { + cpasType: 'urn:lsid:labkey.org:Protocol:SampleDerivationProtocol', + type: 'Run', + lsid: 'derivation-run-lsid', + children: [{ lsid: 'derived-two-lsid' }, { lsid: 'derived-one-lsid' }], + name: 'Derive 2 samples from Parent-1', + expType: 'ExperimentRun', + parents: [{ lsid: 'parent-lsid' }], + }, + 'parent-lsid': { + cpasType: 'sample-type-lsid', + materialLineageType: 'RootMaterial', + type: 'Sample', + lsid: 'parent-lsid', + children: [{ lsid: 'aliquot-run-lsid' }, { lsid: 'derivation-run-lsid' }], + name: 'Parent-1', + expType: 'Material', + }, + }, + seed: 'parent-lsid', + }); + + const options: LineageOptions = { + filters: [new LineageFilter('type', ['Sample'])], + }; + + const lineage = new Lineage({ result }); + const nodesAndEdges = generateNodesAndEdges(lineage.filterResult(options), options); + + // Runs have been filtered out + const expectedNodes = new Set(['aliquot-lsid', 'derived-one-lsid', 'derived-two-lsid', 'parent-lsid']); + expect(new Set(Object.keys(nodesAndEdges.nodes))).toEqual(expectedNodes); + + // Previously, this would have only two edges where the edge between the parent and aliquot was missing + const expectedEdges = new Set([ + 'parent-lsid||aliquot-lsid', + 'parent-lsid||derived-one-lsid', + 'parent-lsid||derived-two-lsid', + ]); + expect(new Set(Object.keys(nodesAndEdges.edges))).toEqual(expectedEdges); + }); }); }); diff --git a/packages/components/src/internal/components/lineage/models.ts b/packages/components/src/internal/components/lineage/models.ts index 7abe9cf0e6..5295c2c06f 100644 --- a/packages/components/src/internal/components/lineage/models.ts +++ b/packages/components/src/internal/components/lineage/models.ts @@ -291,6 +291,7 @@ export class LineageNode steps: undefined, type: undefined, materialLineageType: undefined, + sampleStatus: undefined, url: undefined, // computed properties @@ -332,6 +333,7 @@ export class LineageNode declare steps: List; declare type: string; declare materialLineageType: string; + declare sampleStatus: number; declare url: string; // computed properties @@ -400,11 +402,11 @@ export class LineageResult extends ImmutableRecord({ }); } - filterIn(field: string, value: string | string[] | undefined): LineageResult { + filterIn(field: string, value: string | string[] | number[] | number | undefined): LineageResult { return LineageResult._filter(this, field, value, true); } - filterOut(field: string, value: string | string[] | undefined): LineageResult { + filterOut(field: string, value: string | string[] | number[] | number | undefined): LineageResult { return LineageResult._filter(this, field, value, false); } @@ -418,7 +420,7 @@ export class LineageResult extends ImmutableRecord({ private static _filter( result: LineageResult, field: string, - value: string | string[] | undefined, + value: number | number[] | string | string[] | undefined, filterIn: boolean ): LineageResult { if (field === undefined) throw new Error('field must not be undefined'); @@ -457,15 +459,15 @@ export class LineageResult extends ImmutableRecord({ /** * When 'filterIn' is true, returns true if the node[field] is equal to the value or any of the array item values. - * When value is undefined, it is treated as a wildcard -- any value is allowed as long as the + * When value is undefined, it is treated as a wildcard -- any value is allowed as long as the field exists * * When 'filterIn' is false, returns true if the node[field] is not equal to the value or any of the array item values. - * When value is undefined, the node must not have contain a value for the field. + * When value is undefined, the node must not contain a value for the field. */ private static _matches( node: LineageNode, field: string, - value: string | string[] | undefined, + value: number | number[] | string | string[] | undefined, filterIn: boolean ): boolean { if (filterIn) { @@ -473,6 +475,7 @@ export class LineageResult extends ImmutableRecord({ // true if the field exists on node return node.has(field); } else if (Array.isArray(value)) { + // @ts-expect-error number or string possible return value.indexOf(node[field]) > -1; } else { return node[field] === value; @@ -482,6 +485,7 @@ export class LineageResult extends ImmutableRecord({ // true if the field does not exist on node return !node.has(field); } else if (Array.isArray(value)) { + // @ts-expect-error number or string possible return value.indexOf(node[field]) === -1; } else { return node[field] !== value; @@ -1137,6 +1141,9 @@ function applyCombineSize( combinedLineageNodes.push( combineNodes(lsid, aliquotEdges, nodes, options, dir, visEdges, visNodes, nodesInCombinedNode, depth) ); + } else { + // GH Issue #1256 + addEdges(lsid, null, visEdges, List(aliquotEdges), nodesInCombinedNode, dir); } if (nonAliquotEdges.length >= options.grouping.combineSize) { diff --git a/packages/components/src/internal/components/lineage/types.ts b/packages/components/src/internal/components/lineage/types.ts index 58fc4edf19..9171e154a6 100644 --- a/packages/components/src/internal/components/lineage/types.ts +++ b/packages/components/src/internal/components/lineage/types.ts @@ -42,9 +42,9 @@ export interface LineageGroupingOptions { export class LineageFilter { field: string; - value: string[]; + value: number[] | string[]; - constructor(field: string, value: string[]) { + constructor(field: string, value: number[] | string[]) { this.field = field; this.value = value; } diff --git a/packages/components/src/internal/components/samples/models.ts b/packages/components/src/internal/components/samples/models.ts index 49920e67d8..06db383707 100644 --- a/packages/components/src/internal/components/samples/models.ts +++ b/packages/components/src/internal/components/samples/models.ts @@ -86,6 +86,7 @@ export interface SampleStatus { color: string; description?: string; label: string; + rowId?: number; statusType: SampleStateType; } @@ -136,6 +137,7 @@ export class SampleState { description: this.description, label: this.label, color: this.color, + rowId: this.rowId ?? undefined, statusType: SampleStateType[this.stateType], }; } diff --git a/packages/components/src/internal/components/samples/utils.test.ts b/packages/components/src/internal/components/samples/utils.test.ts index 3c6cb7a628..5f4d56cb8f 100644 --- a/packages/components/src/internal/components/samples/utils.test.ts +++ b/packages/components/src/internal/components/samples/utils.test.ts @@ -28,6 +28,7 @@ import { getOperationNotPermittedMessage, getSampleStatus, getSampleStatusColor, + getSampleStatusFromSampleRow, getSampleStatusLockedMessage, getSampleStatusType, isSampleOperationPermitted, @@ -330,23 +331,59 @@ describe('isSamplesSchema', () => { }); }); -describe('getSampleStatus', () => { - test('getSampleStatusType', () => { +describe('getSampleStatusType', () => { + test('no data', () => { + expect(getSampleStatusType(undefined)).toBeUndefined(); expect(getSampleStatusType({})).toBeUndefined(); + }); + + test('sample row', () => { expect(getSampleStatusType({ 'SampleState/StatusType': { value: undefined } })).toBeUndefined(); expect(getSampleStatusType({ 'SampleState/StatusType': { value: 'Available' } })).toBe('Available'); + }); + + test('sample lookup', () => { expect(getSampleStatusType({ 'SampleID/SampleState/StatusType': { value: undefined } })).toBeUndefined(); expect(getSampleStatusType({ 'SampleID/SampleState/StatusType': { value: 'Consumed' } })).toBe('Consumed'); + }); + + test('status row', () => { expect(getSampleStatusType({ StatusType: { value: undefined } })).toBeUndefined(); expect(getSampleStatusType({ StatusType: { value: 'Locked' } })).toBe('Locked'); }); +}); + +describe('getSampleStatusFromSampleRow', () => { + test('label', () => { + expect(getSampleStatusFromSampleRow({}).label).toBeUndefined(); + expect(getSampleStatusFromSampleRow({ SampleState: { displayValue: undefined } }).label).toBeUndefined(); + expect(getSampleStatusFromSampleRow({ SampleState: { displayValue: 'Label1' } }).label).toBe('Label1'); + expect( + getSampleStatusFromSampleRow({ 'SampleID/SampleState': { displayValue: undefined } }).label + ).toBeUndefined(); + expect(getSampleStatusFromSampleRow({ 'SampleID/SampleState': { displayValue: 'Label2' } }).label).toBe( + 'Label2' + ); + }); + test('description', () => { + expect(getSampleStatusFromSampleRow({}).description).toBeUndefined(); + expect( + getSampleStatusFromSampleRow({ 'SampleState/Description': { value: undefined } }).description + ).toBeUndefined(); + expect(getSampleStatusFromSampleRow({ 'SampleState/Description': { value: 'Desc1' } }).description).toBe('Desc1'); + expect( + getSampleStatusFromSampleRow({ 'SampleID/SampleState/Description': { value: undefined } }).description + ).toBeUndefined(); + expect( + getSampleStatusFromSampleRow({ 'SampleID/SampleState/Description': { value: 'Desc2' } }).description + ).toBe('Desc2'); + }); +}); + +describe('getSampleStatus', () => { test('label', () => { expect(getSampleStatus({}).label).toBeUndefined(); - expect(getSampleStatus({ SampleState: { displayValue: undefined } }).label).toBeUndefined(); - expect(getSampleStatus({ SampleState: { displayValue: 'Label1' } }).label).toBe('Label1'); - expect(getSampleStatus({ 'SampleID/SampleState': { displayValue: undefined } }).label).toBeUndefined(); - expect(getSampleStatus({ 'SampleID/SampleState': { displayValue: 'Label2' } }).label).toBe('Label2'); expect(getSampleStatus({ Label: { displayValue: undefined } }).label).toBeUndefined(); expect(getSampleStatus({ Label: { displayValue: 'Label3' } }).label).toBeUndefined(); expect(getSampleStatus({ Label: { value: 'Label3' } }).label).toBe('Label3'); @@ -354,12 +391,6 @@ describe('getSampleStatus', () => { test('description', () => { expect(getSampleStatus({}).description).toBeUndefined(); - expect(getSampleStatus({ 'SampleState/Description': { value: undefined } }).description).toBeUndefined(); - expect(getSampleStatus({ 'SampleState/Description': { value: 'Desc1' } }).description).toBe('Desc1'); - expect( - getSampleStatus({ 'SampleID/SampleState/Description': { value: undefined } }).description - ).toBeUndefined(); - expect(getSampleStatus({ 'SampleID/SampleState/Description': { value: 'Desc2' } }).description).toBe('Desc2'); expect(getSampleStatus({ Description: { value: undefined } }).description).toBeUndefined(); expect(getSampleStatus({ Description: { value: 'Desc3' } }).description).toBe('Desc3'); }); diff --git a/packages/components/src/internal/components/samples/utils.tsx b/packages/components/src/internal/components/samples/utils.tsx index 8a76c36e56..04e7811526 100644 --- a/packages/components/src/internal/components/samples/utils.tsx +++ b/packages/components/src/internal/components/samples/utils.tsx @@ -88,18 +88,19 @@ export function getSampleStatusColor(color: string, stateType: SampleStateType | } } -export function getSampleStatus(row: any): SampleStatus { +export function getSampleStatusFromSampleRow(row: any): SampleStatus { let label; + let rowId; // Issue 45269. If the state columns are present, don't look at a column named 'Label' let field = caseInsensitive(row, SAMPLE_STATE_COLUMN_NAME); if (field) { + rowId = field.value; label = field.displayValue; } else { field = caseInsensitive(row, 'SampleID/' + SAMPLE_STATE_COLUMN_NAME); if (field) { + rowId = field.value; label = field.displayValue; - } else { - label = caseInsensitive(row, 'Label')?.value; } } let color; @@ -110,8 +111,6 @@ export function getSampleStatus(row: any): SampleStatus { col = caseInsensitive(row, 'SampleID/' + SAMPLE_STATE_COLOR_COLUMN_NAME); if (col) { color = col.value; - } else { - color = caseInsensitive(row, 'Color')?.value; } } let description; @@ -122,18 +121,27 @@ export function getSampleStatus(row: any): SampleStatus { col = caseInsensitive(row, 'SampleID/' + SAMPLE_STATE_DESCRIPTION_COLUMN_NAME); if (col) { description = col.value; - } else { - description = caseInsensitive(row, 'Description')?.value; } } return { label, + rowId, statusType: getSampleStatusType(row), color, description, }; } +export function getSampleStatus(row: any): SampleStatus { + return { + label: caseInsensitive(row, 'Label')?.value, + rowId: caseInsensitive(row, 'RowId')?.value, + statusType: getSampleStatusType(row), + color: caseInsensitive(row, 'Color')?.value, + description: caseInsensitive(row, 'Description')?.value, + }; +} + export function getFilterForSampleOperation( operation: SampleOperation, allowed = true, diff --git a/packages/components/src/internal/renderers/SampleStatusRenderer.tsx b/packages/components/src/internal/renderers/SampleStatusRenderer.tsx index 9ad3716263..24b540d969 100644 --- a/packages/components/src/internal/renderers/SampleStatusRenderer.tsx +++ b/packages/components/src/internal/renderers/SampleStatusRenderer.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { Map } from 'immutable'; import { SampleStatusTag } from '../components/samples/SampleStatusTag'; -import { getSampleStatus } from '../components/samples/utils'; +import { getSampleStatusFromSampleRow } from '../components/samples/utils'; interface SampleStatusProps { row: Map; @@ -14,6 +14,6 @@ interface SampleStatusProps { export class SampleStatusRenderer extends React.PureComponent { render() { const { row } = this.props; - return + return } } diff --git a/packages/components/src/theme/lineage.scss b/packages/components/src/theme/lineage.scss index 1ee6de497c..9be407a473 100644 --- a/packages/components/src/theme/lineage.scss +++ b/packages/components/src/theme/lineage.scss @@ -220,3 +220,13 @@ margin-left: 0; padding-left: 0; } + +.lineage-filter__dot { + height: 10px; + width: 10px; + border-radius: 50%; + background-color: $badge-color; + border: 1px solid $white; + display: inline-block; + margin-top: 6px; +}