From 0f8edc78ec273f227ed7d722668242168b8ac1aa Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Thu, 14 May 2026 15:36:15 -0700 Subject: [PATCH 1/9] Easy metrics based on replicate and precursor annotation values --- .../model/QCMetricConfiguration.java | 14 + .../queries/targetedms/qcMetricsConfig.sql | 3 +- resources/schemas/targetedms.xml | 1 + resources/views/configureQCMetric.html | 27 +- resources/views/configureQCMetric.view.xml | 1 + .../window/AddNewAnnotationMetricWindow.js | 386 ++++++++++++++++++ .../labkey/targetedms/TargetedMSModule.java | 2 +- .../targetedms/outliers/OutlierGenerator.java | 24 ++ 8 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js diff --git a/api-src/org/labkey/api/targetedms/model/QCMetricConfiguration.java b/api-src/org/labkey/api/targetedms/model/QCMetricConfiguration.java index 73d10893e..3a9000ac8 100644 --- a/api-src/org/labkey/api/targetedms/model/QCMetricConfiguration.java +++ b/api-src/org/labkey/api/targetedms/model/QCMetricConfiguration.java @@ -33,6 +33,7 @@ public class QCMetricConfiguration implements Comparable private String _yAxisLabel; private Double _upperBound; private Double _lowerBound; + private String _annotationName; public int getId() { @@ -164,6 +165,16 @@ public void setLowerBound(Double lowerBound) _lowerBound = lowerBound; } + public String getAnnotationName() + { + return _annotationName; + } + + public void setAnnotationName(String annotationName) + { + _annotationName = annotationName; + } + public JSONObject toJSON(){ JSONObject jsonObject = new JSONObject(); jsonObject.put("id", _id); @@ -195,6 +206,9 @@ public JSONObject toJSON(){ if (_upperBound != null) { jsonObject.put("upperBound", _upperBound); } + if (_annotationName != null) { + jsonObject.put("annotationName", _annotationName); + } return jsonObject; } diff --git a/resources/queries/targetedms/qcMetricsConfig.sql b/resources/queries/targetedms/qcMetricsConfig.sql index d1f1248ef..bb5d5082d 100644 --- a/resources/queries/targetedms/qcMetricsConfig.sql +++ b/resources/queries/targetedms/qcMetricsConfig.sql @@ -30,7 +30,8 @@ SELECT qmc.MaxTimeValue, qmc.TimeValueOption, qmc.TraceName, - qmc.YAxisLabel + qmc.YAxisLabel, + qmc.AnnotationName FROM qcmetricconfiguration qmc FULL JOIN qcenabledmetrics qem diff --git a/resources/schemas/targetedms.xml b/resources/schemas/targetedms.xml index 8fddaf6bf..0676c5526 100644 --- a/resources/schemas/targetedms.xml +++ b/resources/schemas/targetedms.xml @@ -1358,6 +1358,7 @@ + diff --git a/resources/views/configureQCMetric.html b/resources/views/configureQCMetric.html index 9a608a2c5..626710cdb 100644 --- a/resources/views/configureQCMetric.html +++ b/resources/views/configureQCMetric.html @@ -63,6 +63,7 @@ '' + '' + '' + + '' + '' + '
Edits to queries backing existing custom metrics require a manual cache clearing to display the updated results.

'; @@ -80,7 +81,10 @@ LABKEY.internal.ConfigureQCMetrics.addNewMetric('custom'); }); jQuery('#createNewTraceMetricButton').click(function() { - LABKEY.internal.ConfigureQCMetrics.addNewMetric('trace') + LABKEY.internal.ConfigureQCMetrics.addNewMetric('trace'); + }); + jQuery('#createNewAnnotationMetricButton').click(function() { + LABKEY.internal.ConfigureQCMetrics.addNewMetric('annotation'); }); jQuery('#clearCacheButton').click(function() { jQuery('#qcMetricsError').text('Clearing cached metrics...'); @@ -169,7 +173,10 @@ const op = 'update'; if (clickedQcMetricConfig.TraceName) { - LABKEY.internal.ConfigureQCMetrics.showTraceMetricWindow(op, clickedQcMetricConfig) + LABKEY.internal.ConfigureQCMetrics.showTraceMetricWindow(op, clickedQcMetricConfig); + } + else if (clickedQcMetricConfig.AnnotationName) { + LABKEY.internal.ConfigureQCMetrics.showAnnotationMetricWindow(op, clickedQcMetricConfig); } else { LABKEY.internal.ConfigureQCMetrics.showCustomMetricWindow(op, clickedQcMetricConfig); @@ -242,13 +249,27 @@ }); }, + showAnnotationMetricWindow: function (op, clickedMetric) { + const windowConfig = { + parent: this, + operation: op + }; + if (clickedMetric) { + windowConfig.metric = clickedMetric; + } + Ext4.create('Panorama.Window.AddAnnotationMetricWindow', windowConfig).show(); + }, + addNewMetric: function (metricType) { const op = 'insert'; if (metricType === 'custom') { this.showCustomMetricWindow(op); } else if (metricType === 'trace') { - this.showTraceMetricWindow(op) + this.showTraceMetricWindow(op); + } + else if (metricType === 'annotation') { + this.showAnnotationMetricWindow(op); } }, diff --git a/resources/views/configureQCMetric.view.xml b/resources/views/configureQCMetric.view.xml index d10619e34..69c26d2dd 100644 --- a/resources/views/configureQCMetric.view.xml +++ b/resources/views/configureQCMetric.view.xml @@ -7,6 +7,7 @@ + \ No newline at end of file diff --git a/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js b/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js new file mode 100644 index 000000000..e09e23ace --- /dev/null +++ b/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js @@ -0,0 +1,386 @@ +/* + * Copyright (c) 2025 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. + */ + +Ext4.define('Panorama.Window.AddAnnotationMetricWindow', { + extend: 'Ext.window.Window', + + modal: true, + closeAction: 'destroy', + bodyStyle: 'padding: 10px;', + autoScroll: true, + border: false, + update: 'update', + insert: 'insert', + + initComponent: function() { + var title = this.operation === this.insert ? 'Add Annotation-Backed Metric' : 'Edit Annotation-Backed Metric'; + this.setTitle(title); + this.height = Ext4.max([Ext4.getBody().getHeight() * 0.3, 300]); + this.width = Ext4.max([Ext4.getBody().getWidth() * 0.25, 500]); + this._allAnnotations = []; + this.items = this.getItems(); + this.dockedItems = [{ + xtype: 'toolbar', + dock: 'bottom', + ui: 'footer', + items: this.getButtons() + }]; + + this.callParent(); + this.loadAnnotations(); + }, + + loadAnnotations: function() { + LABKEY.Query.selectRows({ + schemaName: 'targetedms', + queryName: 'AnnotationSettings', + columns: ['Name', 'Targets', 'Type'], + filterArray: [LABKEY.Filter.create('Type', 'number', LABKEY.Filter.Types.EQUAL)], + scope: this, + success: function(data) { + this._allAnnotations = data.rows || []; + this.refreshAnnotationsCombo(); + } + }); + }, + + getAnnotationTarget: function() { + var val = this.annotationTypeGroup.down('radiogroup').getValue(); + return val && val['annotationType'] === 'precursor' ? 'precursor_result' : 'replicates'; + }, + + getFilteredAnnotations: function() { + var target = this.getAnnotationTarget(); + var seen = {}; + var result = []; + this._allAnnotations.forEach(function(row) { + var targets = (row['Targets'] || '').split(',').map(function(s) { return s.trim(); }); + if (targets.indexOf(target) >= 0 && !seen[row['Name']]) { + seen[row['Name']] = true; + result.push({ Name: row['Name'] }); + } + }); + return result; + }, + + refreshAnnotationsCombo: function() { + var annotations = this.getFilteredAnnotations(); + var store = Ext4.create('Ext.data.Store', { + fields: ['Name'], + sorters: [{property: 'Name'}], + data: annotations + }); + this.annotationsCombo.bindStore(store); + if (this.operation === this.update && this.metric && this.metric.AnnotationName) { + this.annotationsCombo.setValue(this.metric.AnnotationName); + } else { + this.annotationsCombo.clearValue(); + } + }, + + getItems: function() { + return [ + this.getMetricNameField(), + this.getYAxisLabelField(), + this.getAnnotationTypeRadioGroup(), + this.getAnnotationsCombo(), + this.getQueryError() + ]; + }, + + getButtons: function() { + var buttons = []; + buttons.push(this.getCancelButton()); + buttons.push('->'); + if (this.operation === this.update) { + buttons.push(this.getDeleteButton()); + } + buttons.push(this.getSaveButton()); + return buttons; + }, + + getMetricNameField: function() { + if (!this.metricNameField) { + this.metricNameField = Ext4.create('Ext.form.field.Text', { + fieldLabel: 'Metric Name', + labelWidth: 150, + width: 450, + name: 'metricName' + }); + if (this.operation === this.update) { + this.metricNameField.setValue(this.metric.name); + } + } + return this.metricNameField; + }, + + getYAxisLabelField: function() { + if (!this.yAxisLabelField) { + this.yAxisLabelField = Ext4.create('Ext.form.field.Text', { + fieldLabel: 'Y-Axis Label', + labelWidth: 150, + width: 450, + name: 'yAxisLabel' + }); + if (this.operation === this.update) { + this.yAxisLabelField.setValue(this.metric.YAxisLabel); + } + } + return this.yAxisLabelField; + }, + + getAnnotationTypeRadioGroup: function() { + if (!this.annotationTypeGroup) { + var isPrecursor = this.operation === this.update ? this.metric.PrecursorScoped : false; + this.annotationTypeGroup = Ext4.create('Ext.form.Panel', { + border: false, + width: 450, + items: [{ + xtype: 'radiogroup', + fieldLabel: 'Annotation Type', + labelWidth: 150, + columns: 2, + items: [ + { + xtype: 'radio', + name: 'annotationType', + inputValue: 'replicate', + boxLabel: 'Replicate', + checked: !isPrecursor, + listeners: { + change: { + fn: function(cmp, newVal) { + if (newVal) { + this.refreshAnnotationsCombo(); + } + }, + scope: this + } + } + }, + { + xtype: 'radio', + name: 'annotationType', + inputValue: 'precursor', + boxLabel: 'Precursor', + checked: isPrecursor, + listeners: { + change: { + fn: function(cmp, newVal) { + if (newVal) { + this.refreshAnnotationsCombo(); + } + }, + scope: this + } + } + } + ] + }] + }); + } + return this.annotationTypeGroup; + }, + + getAnnotationsCombo: function() { + if (!this.annotationsCombo) { + this.annotationsCombo = Ext4.create('Ext.form.field.ComboBox', { + fieldLabel: 'Annotation', + labelWidth: 150, + width: 450, + name: 'annotationName', + displayField: 'Name', + valueField: 'Name', + store: Ext4.create('Ext.data.Store', { fields: ['Name'] }), + emptyText: 'Loading annotations...', + forceSelection: true, + queryMode: 'local' + }); + } + return this.annotationsCombo; + }, + + getQueryError: function() { + if (!this.queryError) { + this.queryError = Ext4.create('Ext.form.Label', { + name: 'errorMsg', + hidden: true, + cls: 'labkey-error', + text: '' + }); + } + return this.queryError; + }, + + getSaveButton: function() { + if (!this.saveButton) { + this.saveButton = Ext4.create('Ext.button.Button', { + text: 'Save', + scope: this, + handler: this.saveMetric + }); + } + return this.saveButton; + }, + + getDeleteButton: function() { + if (!this.deleteButton) { + this.deleteButton = Ext4.create('Ext.button.Button', { + text: 'Delete', + scope: this, + handler: this.deleteMetric + }); + } + return this.deleteButton; + }, + + getCancelButton: function() { + if (!this.cancelButton) { + this.cancelButton = Ext4.create('Ext.button.Button', { + text: 'Cancel', + scope: this, + handler: function(btn) { + btn.up('window').close(); + } + }); + } + return this.cancelButton; + }, + + validateValues: function() { + var isValid = true; + var errorText = 'Required'; + + if (!(this.metricNameField.getValue().length > 0)) { + this.metricNameField.setActiveError(errorText); + isValid = false; + } + + if (!(this.yAxisLabelField.getValue().length > 0)) { + this.yAxisLabelField.setActiveError(errorText); + isValid = false; + } + + if (!this.annotationsCombo.getValue()) { + this.annotationsCombo.setActiveError(errorText); + isValid = false; + } + + return isValid; + }, + + checkMetricNameExists: function(metricName, callback) { + var filterArray = [LABKEY.Filter.create('Name', metricName, LABKEY.Filter.Types.EQUAL)]; + + if (this.operation === this.update && this.metric) { + filterArray.push(LABKEY.Filter.create('id', this.metric.id, LABKEY.Filter.Types.NOT_EQUAL)); + } + + LABKEY.Query.selectRows({ + containerPath: LABKEY.container.id, + schemaName: 'targetedms', + queryName: 'qcmetricconfiguration', + filterArray: filterArray, + scope: this, + success: function(data) { + callback.call(this, data.rows.length > 0); + }, + failure: function() { + callback.call(this, false); + } + }); + }, + + saveMetric: function() { + if (!this.validateValues()) { + return; + } + + var metricName = this.metricNameField.getValue(); + + this.checkMetricNameExists(metricName, function(exists) { + if (exists) { + this.queryError.setText('A metric with the name "' + metricName + '" already exists. Please choose a different name.'); + this.queryError.setVisible(true); + this.metricNameField.setActiveError('Metric name already exists'); + return; + } + + var typeVal = this.annotationTypeGroup.down('radiogroup').getValue(); + var isPrecursor = typeVal && typeVal['annotationType'] === 'precursor'; + + var newMetric = { + Name: metricName, + QueryName: 'QCAnnotationMetric', + YAxisLabel: this.yAxisLabelField.getValue(), + PrecursorScoped: isPrecursor, + AnnotationName: this.annotationsCombo.getValue() + }; + + if (this.operation === this.update) { + newMetric.id = this.metric.id; + } + + LABKEY.Query.saveRows({ + containerPath: LABKEY.container.id, + commands: [{ + schemaName: 'targetedms', + queryName: 'qcmetricconfiguration', + command: this.operation, + rows: [newMetric] + }], + scope: this, + method: 'POST', + success: function() { + window.location.reload(); + }, + failure: function(response) { + var errorMessage = 'Error saving metric'; + if (response && response.exception) { + errorMessage = response.exception; + } else if (response && response.message) { + errorMessage = response.message; + } + this.queryError.setText(errorMessage); + this.queryError.setVisible(true); + } + }); + }); + }, + + deleteMetric: function() { + Ext4.Msg.confirm('Delete Annotation-Backed Metric', 'This will delete ' + LABKEY.Utils.encodeHtml(this.metric.name) + ' metric. Are you sure?', function(val) { + if (val === 'yes') { + LABKEY.Query.saveRows({ + containerPath: LABKEY.container.id, + commands: [{ + schemaName: 'targetedms', + queryName: 'qcenabledmetrics', + command: 'delete', + rows: [{metric: this.metric.id}] + }, { + schemaName: 'targetedms', + queryName: 'qcmetricconfiguration', + command: 'delete', + rows: [{id: this.metric.id}] + }], + scope: this, + method: 'POST', + success: function() { + window.location.reload(); + }, + failure: function(response) { + var errorMessage = 'Error deleting metric'; + if (response && response.exception) { + errorMessage = response.exception; + } + this.queryError.setText(errorMessage); + this.queryError.setVisible(true); + } + }); + } + }, this); + } +}); diff --git a/src/org/labkey/targetedms/TargetedMSModule.java b/src/org/labkey/targetedms/TargetedMSModule.java index 581f67f52..b3bf9a4a7 100644 --- a/src/org/labkey/targetedms/TargetedMSModule.java +++ b/src/org/labkey/targetedms/TargetedMSModule.java @@ -231,7 +231,7 @@ public String getName() @Override public Double getSchemaVersion() { - return 26.006; + return 26.007; } @Override diff --git a/src/org/labkey/targetedms/outliers/OutlierGenerator.java b/src/org/labkey/targetedms/outliers/OutlierGenerator.java index f2c42bbdb..f0e8c55be 100644 --- a/src/org/labkey/targetedms/outliers/OutlierGenerator.java +++ b/src/org/labkey/targetedms/outliers/OutlierGenerator.java @@ -100,6 +100,30 @@ private String getEachSeriesTypePlotDataSql(QCMetricConfiguration configuration) sql.append(" WHERE metric = ").append(configuration.getId()); sql.append(")"); } + else if (configuration.getAnnotationName() != null) + { + // annotation-backed metrics: escape the annotation name for SQL string literal + String escapedName = configuration.getAnnotationName().replace("'", "''"); + if (configuration.isPrecursorScoped()) + { + sql.append("(SELECT pcia.PrecursorChromInfoId, pci.SampleFileId,"); + sql.append(" pcia.Name AS SeriesLabel,"); + sql.append(" CAST(pcia.Value AS REAL) AS MetricValue, ").append(configuration.getId()).append(" AS MetricId"); + sql.append(" FROM ").append(schemaName).append(".PrecursorChromInfoAnnotation pcia"); + sql.append(" INNER JOIN ").append(schemaName).append(".PrecursorChromInfo pci ON pcia.PrecursorChromInfoId = pci.Id"); + sql.append(" WHERE pcia.Name = '").append(escapedName).append("')"); + } + else + { + sql.append("(SELECT 0 AS PrecursorChromInfoId, sf.Id AS SampleFileId,"); + sql.append(" ra.Name AS SeriesLabel,"); + sql.append(" CAST(ra.Value AS REAL) AS MetricValue, ").append(configuration.getId()).append(" AS MetricId"); + sql.append(" FROM ").append(schemaName).append(".ReplicateAnnotation ra"); + sql.append(" INNER JOIN ").append(schemaName).append(".Replicate r ON ra.ReplicateId = r.Id"); + sql.append(" INNER JOIN ").append(schemaName).append(".SampleFile sf ON sf.ReplicateId = r.Id"); + sql.append(" WHERE ra.Name = '").append(escapedName).append("')"); + } + } else { sql.append("(SELECT PrecursorChromInfoId, SampleFileId, "); From 68ac5258ab6ccca79028c4a6aec555c87d4008e5 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Thu, 14 May 2026 17:24:09 -0700 Subject: [PATCH 2/9] add missing sql script --- .../schemas/dbscripts/postgresql/targetedms-26.006-26.007.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 resources/schemas/dbscripts/postgresql/targetedms-26.006-26.007.sql diff --git a/resources/schemas/dbscripts/postgresql/targetedms-26.006-26.007.sql b/resources/schemas/dbscripts/postgresql/targetedms-26.006-26.007.sql new file mode 100644 index 000000000..c00cd2a66 --- /dev/null +++ b/resources/schemas/dbscripts/postgresql/targetedms-26.006-26.007.sql @@ -0,0 +1 @@ +ALTER TABLE targetedms.QCMetricConfiguration ADD COLUMN AnnotationName VARCHAR(200); From ebfb48828b5ebd0fc43727f55584800a20c0018d Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Sun, 17 May 2026 09:26:48 -0700 Subject: [PATCH 3/9] fix name --- .../web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js b/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js index e09e23ace..1b00c52ca 100644 --- a/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js +++ b/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js @@ -48,7 +48,7 @@ Ext4.define('Panorama.Window.AddAnnotationMetricWindow', { getAnnotationTarget: function() { var val = this.annotationTypeGroup.down('radiogroup').getValue(); - return val && val['annotationType'] === 'precursor' ? 'precursor_result' : 'replicates'; + return val && val['annotationType'] === 'precursor' ? 'precursor_result' : 'replicate'; }, getFilteredAnnotations: function() { From 1ccb5fd52eb31eab42b72b51b19844c3a10beefa Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Sun, 17 May 2026 10:15:49 -0700 Subject: [PATCH 4/9] transform away from extjs --- resources/views/configureQCMetric.html | 2 +- .../window/AddNewAnnotationMetricWindow.js | 510 ++++++------------ 2 files changed, 176 insertions(+), 336 deletions(-) diff --git a/resources/views/configureQCMetric.html b/resources/views/configureQCMetric.html index 626710cdb..df09835bc 100644 --- a/resources/views/configureQCMetric.html +++ b/resources/views/configureQCMetric.html @@ -257,7 +257,7 @@ if (clickedMetric) { windowConfig.metric = clickedMetric; } - Ext4.create('Panorama.Window.AddAnnotationMetricWindow', windowConfig).show(); + Panorama.Window.AddAnnotationMetricWindow.show(windowConfig); }, addNewMetric: function (metricType) { diff --git a/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js b/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js index 1b00c52ca..fc762eed0 100644 --- a/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js +++ b/resources/web/PanoramaPremium/window/AddNewAnnotationMetricWindow.js @@ -3,384 +3,224 @@ * any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ -Ext4.define('Panorama.Window.AddAnnotationMetricWindow', { - extend: 'Ext.window.Window', +(function($) { + window.Panorama = window.Panorama || {}; + window.Panorama.Window = window.Panorama.Window || {}; - modal: true, - closeAction: 'destroy', - bodyStyle: 'padding: 10px;', - autoScroll: true, - border: false, - update: 'update', - insert: 'insert', + const DIALOG_ID = 'lk-annotation-metric-dialog'; + let _config = null; + let _allAnnotations = []; - initComponent: function() { - var title = this.operation === this.insert ? 'Add Annotation-Backed Metric' : 'Edit Annotation-Backed Metric'; - this.setTitle(title); - this.height = Ext4.max([Ext4.getBody().getHeight() * 0.3, 300]); - this.width = Ext4.max([Ext4.getBody().getWidth() * 0.25, 500]); - this._allAnnotations = []; - this.items = this.getItems(); - this.dockedItems = [{ - xtype: 'toolbar', - dock: 'bottom', - ui: 'footer', - items: this.getButtons() - }]; - - this.callParent(); - this.loadAnnotations(); - }, - - loadAnnotations: function() { - LABKEY.Query.selectRows({ - schemaName: 'targetedms', - queryName: 'AnnotationSettings', - columns: ['Name', 'Targets', 'Type'], - filterArray: [LABKEY.Filter.create('Type', 'number', LABKEY.Filter.Types.EQUAL)], - scope: this, - success: function(data) { - this._allAnnotations = data.rows || []; - this.refreshAnnotationsCombo(); - } - }); - }, - - getAnnotationTarget: function() { - var val = this.annotationTypeGroup.down('radiogroup').getValue(); - return val && val['annotationType'] === 'precursor' ? 'precursor_result' : 'replicate'; - }, - - getFilteredAnnotations: function() { - var target = this.getAnnotationTarget(); - var seen = {}; - var result = []; - this._allAnnotations.forEach(function(row) { - var targets = (row['Targets'] || '').split(',').map(function(s) { return s.trim(); }); - if (targets.indexOf(target) >= 0 && !seen[row['Name']]) { - seen[row['Name']] = true; - result.push({ Name: row['Name'] }); - } - }); - return result; - }, - - refreshAnnotationsCombo: function() { - var annotations = this.getFilteredAnnotations(); - var store = Ext4.create('Ext.data.Store', { - fields: ['Name'], - sorters: [{property: 'Name'}], - data: annotations - }); - this.annotationsCombo.bindStore(store); - if (this.operation === this.update && this.metric && this.metric.AnnotationName) { - this.annotationsCombo.setValue(this.metric.AnnotationName); - } else { - this.annotationsCombo.clearValue(); - } - }, - - getItems: function() { - return [ - this.getMetricNameField(), - this.getYAxisLabelField(), - this.getAnnotationTypeRadioGroup(), - this.getAnnotationsCombo(), - this.getQueryError() - ]; - }, - - getButtons: function() { - var buttons = []; - buttons.push(this.getCancelButton()); - buttons.push('->'); - if (this.operation === this.update) { - buttons.push(this.getDeleteButton()); - } - buttons.push(this.getSaveButton()); - return buttons; - }, - - getMetricNameField: function() { - if (!this.metricNameField) { - this.metricNameField = Ext4.create('Ext.form.field.Text', { - fieldLabel: 'Metric Name', - labelWidth: 150, - width: 450, - name: 'metricName' - }); - if (this.operation === this.update) { - this.metricNameField.setValue(this.metric.name); - } - } - return this.metricNameField; - }, - - getYAxisLabelField: function() { - if (!this.yAxisLabelField) { - this.yAxisLabelField = Ext4.create('Ext.form.field.Text', { - fieldLabel: 'Y-Axis Label', - labelWidth: 150, - width: 450, - name: 'yAxisLabel' - }); - if (this.operation === this.update) { - this.yAxisLabelField.setValue(this.metric.YAxisLabel); - } - } - return this.yAxisLabelField; - }, - - getAnnotationTypeRadioGroup: function() { - if (!this.annotationTypeGroup) { - var isPrecursor = this.operation === this.update ? this.metric.PrecursorScoped : false; - this.annotationTypeGroup = Ext4.create('Ext.form.Panel', { - border: false, - width: 450, - items: [{ - xtype: 'radiogroup', - fieldLabel: 'Annotation Type', - labelWidth: 150, - columns: 2, - items: [ - { - xtype: 'radio', - name: 'annotationType', - inputValue: 'replicate', - boxLabel: 'Replicate', - checked: !isPrecursor, - listeners: { - change: { - fn: function(cmp, newVal) { - if (newVal) { - this.refreshAnnotationsCombo(); - } - }, - scope: this - } - } - }, - { - xtype: 'radio', - name: 'annotationType', - inputValue: 'precursor', - boxLabel: 'Precursor', - checked: isPrecursor, - listeners: { - change: { - fn: function(cmp, newVal) { - if (newVal) { - this.refreshAnnotationsCombo(); - } - }, - scope: this - } - } - } - ] - }] - }); - } - return this.annotationTypeGroup; - }, - - getAnnotationsCombo: function() { - if (!this.annotationsCombo) { - this.annotationsCombo = Ext4.create('Ext.form.field.ComboBox', { - fieldLabel: 'Annotation', - labelWidth: 150, - width: 450, - name: 'annotationName', - displayField: 'Name', - valueField: 'Name', - store: Ext4.create('Ext.data.Store', { fields: ['Name'] }), - emptyText: 'Loading annotations...', - forceSelection: true, - queryMode: 'local' - }); - } - return this.annotationsCombo; - }, - - getQueryError: function() { - if (!this.queryError) { - this.queryError = Ext4.create('Ext.form.Label', { - name: 'errorMsg', - hidden: true, - cls: 'labkey-error', - text: '' - }); - } - return this.queryError; - }, + function closeDialog() { + $('#' + DIALOG_ID).remove(); + } - getSaveButton: function() { - if (!this.saveButton) { - this.saveButton = Ext4.create('Ext.button.Button', { - text: 'Save', - scope: this, - handler: this.saveMetric - }); - } - return this.saveButton; - }, + function showError(msg) { + $('#lk-annotation-metric-error').text(msg).show(); + } - getDeleteButton: function() { - if (!this.deleteButton) { - this.deleteButton = Ext4.create('Ext.button.Button', { - text: 'Delete', - scope: this, - handler: this.deleteMetric - }); - } - return this.deleteButton; - }, + function clearErrors() { + $('#lk-annotation-metric-error').hide().text(''); + $('#lk-annotation-metric-name, #lk-annotation-metric-ylabel, #lk-annotation-name-select') + .css('border-color', ''); + } - getCancelButton: function() { - if (!this.cancelButton) { - this.cancelButton = Ext4.create('Ext.button.Button', { - text: 'Cancel', - scope: this, - handler: function(btn) { - btn.up('window').close(); - } - }); - } - return this.cancelButton; - }, + function markInvalid($field) { + $field.css('border-color', 'red'); + } - validateValues: function() { - var isValid = true; - var errorText = 'Required'; + function validate() { + clearErrors(); + let isValid = true; - if (!(this.metricNameField.getValue().length > 0)) { - this.metricNameField.setActiveError(errorText); + if (!$('#lk-annotation-metric-name').val().trim()) { + markInvalid($('#lk-annotation-metric-name')); isValid = false; } - - if (!(this.yAxisLabelField.getValue().length > 0)) { - this.yAxisLabelField.setActiveError(errorText); + if (!$('#lk-annotation-metric-ylabel').val().trim()) { + markInvalid($('#lk-annotation-metric-ylabel')); isValid = false; } - - if (!this.annotationsCombo.getValue()) { - this.annotationsCombo.setActiveError(errorText); + if (!$('#lk-annotation-name-select').val()) { + markInvalid($('#lk-annotation-name-select')); isValid = false; } + if (!isValid) { + showError('Please fill in all required fields.'); + } return isValid; - }, + } - checkMetricNameExists: function(metricName, callback) { - var filterArray = [LABKEY.Filter.create('Name', metricName, LABKEY.Filter.Types.EQUAL)]; + function getAnnotationTarget() { + return $('input[name="annotationType"]:checked').val() === 'precursor' + ? 'precursor_result' + : 'replicate'; + } - if (this.operation === this.update && this.metric) { - filterArray.push(LABKEY.Filter.create('id', this.metric.id, LABKEY.Filter.Types.NOT_EQUAL)); + function getFilteredAnnotations() { + const target = getAnnotationTarget(); + const seen = {}; + const result = []; + _allAnnotations.forEach(function(row) { + const targets = (row['Targets'] || '').split(',').map(function(s) { return s.trim(); }); + if (targets.indexOf(target) >= 0 && !seen[row['Name']]) { + seen[row['Name']] = true; + result.push(row['Name']); + } + }); + result.sort(); + return result; + } + + function refreshAnnotationsSelect() { + const $select = $('#lk-annotation-name-select'); + const currentVal = $select.val(); + $select.empty().append($('
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
Annotation Type' + + '' + + '' + + '
' + + '' + + '
' + + '' + + (op === 'update' ? ' ' : '') + + ' ' + + '
' + + '' + + '' + + ''; } -}); + + window.Panorama.Window.AddAnnotationMetricWindow = { + show: function(config) { + _config = config; + _allAnnotations = []; + + $('#' + DIALOG_ID).remove(); + $('body').append(buildDialogHtml()); + + $('#lk-annotation-metric-cancel').on('click', closeDialog); + $('#lk-annotation-metric-save').on('click', save); + if (config.operation === 'update') { + $('#lk-annotation-metric-delete').on('click', deleteMetric); + } + $('input[name="annotationType"]').on('change', refreshAnnotationsSelect); + + // close on overlay click + $('#' + DIALOG_ID).on('click', function(e) { + if (e.target === this) closeDialog(); + }); + + LABKEY.Query.selectRows({ + schemaName: 'targetedms', + queryName: 'AnnotationSettings', + columns: ['Name', 'Targets', 'Type'], + filterArray: [LABKEY.Filter.create('Type', 'number', LABKEY.Filter.Types.EQUAL)], + success: function(data) { + _allAnnotations = data.rows || []; + refreshAnnotationsSelect(); + } + }); + } + }; +})(jQuery); From bfccdfc30e0d939228bb0150b7fa5c0686ffaa56 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Sun, 17 May 2026 10:19:04 -0700 Subject: [PATCH 5/9] fix configureQCMetric alignment and other namespace warnings --- resources/views/configureQCMetric.html | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/resources/views/configureQCMetric.html b/resources/views/configureQCMetric.html index df09835bc..d8dbc8b0c 100644 --- a/resources/views/configureQCMetric.html +++ b/resources/views/configureQCMetric.html @@ -59,12 +59,14 @@ }); qcMetricsTable += '
' + - '' + - '' + - '' + - '' + - '' + + '
' + + '' + + '' + + '' + + '' + + '' + '' + + '
' + '
Edits to queries backing existing custom metrics require a manual cache clearing to display the updated results.

'; jQuery('#qcMetricsTable').html(qcMetricsTable); From e5aea27c1bf13ab576e69af37bcce002521aba22 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Sun, 17 May 2026 10:55:00 -0700 Subject: [PATCH 6/9] test anno backed metric --- .../ConfigureMetricsUIPage.java | 66 +++++++++++++++++++ .../TargetedMSQCConfigureMetricTest.java | 49 +++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/test/src/org/labkey/test/pages/panoramapremium/ConfigureMetricsUIPage.java b/test/src/org/labkey/test/pages/panoramapremium/ConfigureMetricsUIPage.java index b87419916..cad5f1102 100644 --- a/test/src/org/labkey/test/pages/panoramapremium/ConfigureMetricsUIPage.java +++ b/test/src/org/labkey/test/pages/panoramapremium/ConfigureMetricsUIPage.java @@ -16,6 +16,7 @@ public class ConfigureMetricsUIPage extends PortalBodyPanel { public static final String ADD_NEW_CUSTOM_METRIC = "Add New Custom Metric"; + public static final String ADD_NEW_ANNOTATION_METRIC = "Add Annotation-Backed Metric"; public ConfigureMetricsUIPage(WebDriver driver) { @@ -134,6 +135,41 @@ public void addNewTraceMetric(Map traceProperties editTraceMetricValues(metricWindow, traceProperties, duplicateNameErrorExpected); } + public void addNewAnnotationMetric(Map metricProperties, boolean duplicateNameErrorExpected) + { + click(Locator.tagWithText("button", ADD_NEW_ANNOTATION_METRIC)); + waitForAnnotationDialog(); + fillAnnotationForm(metricProperties); + if (duplicateNameErrorExpected) + { + click(Locator.id("lk-annotation-metric-save")); + String metricName = metricProperties.get(AnnotationMetricProperties.metricName); + assertTextPresent("A metric with the name \"" + metricName + "\" already exists. Please choose a different name."); + click(Locator.id("lk-annotation-metric-cancel")); + } + else + { + clickAndWait(Locator.id("lk-annotation-metric-save")); + } + } + + public void editAnnotationMetric(String metric, Map metricProperties) + { + waitAndClick(Locator.linkWithText(metric)); + waitForAnnotationDialog(); + fillAnnotationForm(metricProperties); + clickAndWait(Locator.id("lk-annotation-metric-save")); + } + + public void deleteAnnotationMetric(String metric) + { + waitAndClick(Locator.linkWithText(metric)); + waitForAnnotationDialog(); + click(Locator.id("lk-annotation-metric-delete")); + acceptAlert(); + waitForPage(); + } + public void editMetric(String metric, Map metricProperties) { Window metricWindow = openForEdit(metric); @@ -215,6 +251,28 @@ else if (prop.formLabel != null) } } + private void waitForAnnotationDialog() + { + waitForElement(Locator.id("lk-annotation-metric-dialog")); + waitForElement(Locator.tagWithText("option", "-- Select annotation --")); + } + + private void fillAnnotationForm(Map props) + { + if (props.containsKey(AnnotationMetricProperties.metricName)) + setFormElement(Locator.id("lk-annotation-metric-name"), props.get(AnnotationMetricProperties.metricName)); + if (props.containsKey(AnnotationMetricProperties.yAxisLabel)) + setFormElement(Locator.id("lk-annotation-metric-ylabel"), props.get(AnnotationMetricProperties.yAxisLabel)); + if (props.containsKey(AnnotationMetricProperties.annotationType)) + click(Locator.css("input[name='annotationType'][value='" + props.get(AnnotationMetricProperties.annotationType) + "']")); + if (props.containsKey(AnnotationMetricProperties.annotationName)) + { + String annotationName = props.get(AnnotationMetricProperties.annotationName); + waitForElement(Locator.tagWithText("option", annotationName)); + selectOptionByText(Locator.id("lk-annotation-name-select"), annotationName); + } + } + private void duplicateNameErrorExpected(String metricName) { click(Ext4Helper.Locators.ext4Button("Save")); @@ -251,6 +309,14 @@ public enum CustomMetricProperties } } + public enum AnnotationMetricProperties + { + metricName, + yAxisLabel, + annotationType, // "replicate" or "precursor" + annotationName + } + public enum TraceMetricProperties { metricName(null, false), diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSQCConfigureMetricTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSQCConfigureMetricTest.java index e34b17b0f..eb6d34938 100644 --- a/test/src/org/labkey/test/tests/targetedms/TargetedMSQCConfigureMetricTest.java +++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSQCConfigureMetricTest.java @@ -23,7 +23,7 @@ * Tests the QC metric configuration UI and verifies settings propagate consistently across folder hierarchies. */ @Category({}) -@BaseWebDriverTest.ClassTimeout(minutes = 5) +@BaseWebDriverTest.ClassTimeout(minutes = 15) public class TargetedMSQCConfigureMetricTest extends TargetedMSPremiumTest { private final static String SUBFOLDER_1 = "QC Subfolder 1"; @@ -206,6 +206,53 @@ public void testPlotOnlyOption() verifyOutlierCount(replicate, metric, "N/A"); } + @Test + public void testAnnotationBackedMetric() + { + String subfolderName = "AnnotationMetricsFolder"; + String metricName = "Test Annotation-Backed Metric"; + String yAxisLabel = "R Squared"; + String updatedYAxisLabel = "Updated R Squared"; + + log("Create subfolder and import data with numeric precursor_result annotations"); + setupSubfolder(getProjectName(), subfolderName, FolderType.QC); + importData(ISOTOPOLOGUE_FILE_ANNOTATED); + navigateToFolder(getProjectName(), subfolderName); + + log("Add annotation-backed metric backed by the RSquared precursor annotation"); + ConfigureMetricsUIPage configureQCMetrics = goToDashboard().getQcSummaryWebPart().clickConfigureQCMetrics(); + configureQCMetrics.addNewAnnotationMetric(Map.of( + ConfigureMetricsUIPage.AnnotationMetricProperties.metricName, metricName, + ConfigureMetricsUIPage.AnnotationMetricProperties.yAxisLabel, yAxisLabel, + ConfigureMetricsUIPage.AnnotationMetricProperties.annotationType, "precursor", + ConfigureMetricsUIPage.AnnotationMetricProperties.annotationName, "RSquared"), false); + + log("Verify metric appears in configure QC metrics table"); + waitForElement(Locator.linkWithText(metricName)); + + log("Verify metric appears in QC plots dropdown"); + QCPlotsWebPart qcPlotsWebPart = new PanoramaDashboard(this).getQcPlotsWebPart(); + Assert.assertTrue("Annotation-backed metric should appear in QC plots dropdown", + verifyMetricIsPresent(qcPlotsWebPart, metricName)); + + log("Test that a duplicate metric name is rejected"); + configureQCMetrics = goToDashboard().getQcSummaryWebPart().clickConfigureQCMetrics(); + configureQCMetrics.addNewAnnotationMetric(Map.of( + ConfigureMetricsUIPage.AnnotationMetricProperties.metricName, metricName, + ConfigureMetricsUIPage.AnnotationMetricProperties.yAxisLabel, "Duplicate Label", + ConfigureMetricsUIPage.AnnotationMetricProperties.annotationType, "precursor", + ConfigureMetricsUIPage.AnnotationMetricProperties.annotationName, "RSquared"), true); + + log("Edit the annotation-backed metric's y-axis label (still on same page after cancel)"); + configureQCMetrics.editAnnotationMetric(metricName, Map.of( + ConfigureMetricsUIPage.AnnotationMetricProperties.yAxisLabel, updatedYAxisLabel)); + + log("Delete the annotation-backed metric"); + configureQCMetrics = goToDashboard().getQcSummaryWebPart().clickConfigureQCMetrics(); + configureQCMetrics.deleteAnnotationMetric(metricName); + assertTextNotPresent(metricName); + } + private void verifyOutlierCount(String replicate, QCPlotsWebPart.MetricType metricType, String count) { QCSummaryWebPart qcSummaryWebPart = new PanoramaDashboard(this).getQcSummaryWebPart(); From 664597c75edb4cde848af4d6dab6c44b68a93920 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Sun, 17 May 2026 13:24:49 -0700 Subject: [PATCH 7/9] fix test --- .../test/tests/targetedms/TargetedMSQCConfigureMetricTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSQCConfigureMetricTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSQCConfigureMetricTest.java index eb6d34938..ce67d02be 100644 --- a/test/src/org/labkey/test/tests/targetedms/TargetedMSQCConfigureMetricTest.java +++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSQCConfigureMetricTest.java @@ -230,6 +230,7 @@ public void testAnnotationBackedMetric() log("Verify metric appears in configure QC metrics table"); waitForElement(Locator.linkWithText(metricName)); + goToDashboard(); log("Verify metric appears in QC plots dropdown"); QCPlotsWebPart qcPlotsWebPart = new PanoramaDashboard(this).getQcPlotsWebPart(); Assert.assertTrue("Annotation-backed metric should appear in QC plots dropdown", From 6effe18e875b263ceab7f47ef32c21e457ec196f Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Sun, 17 May 2026 19:46:11 -0700 Subject: [PATCH 8/9] update metric name --- .../test/tests/targetedms/TargetedMSQCConfigureMetricTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSQCConfigureMetricTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSQCConfigureMetricTest.java index ce67d02be..6095cfd79 100644 --- a/test/src/org/labkey/test/tests/targetedms/TargetedMSQCConfigureMetricTest.java +++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSQCConfigureMetricTest.java @@ -210,7 +210,7 @@ public void testPlotOnlyOption() public void testAnnotationBackedMetric() { String subfolderName = "AnnotationMetricsFolder"; - String metricName = "Test Annotation-Backed Metric"; + String metricName = "Annotation Metric R Squared"; String yAxisLabel = "R Squared"; String updatedYAxisLabel = "Updated R Squared"; From 37aff179031b8dcf068db31124763a14a17c2423 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Mon, 18 May 2026 12:19:10 -0700 Subject: [PATCH 9/9] fix test and update sql script version --- ...argetedms-26.006-26.007.sql => targetedms-26.007-26.008.sql} | 0 src/org/labkey/targetedms/TargetedMSModule.java | 2 +- .../test/pages/panoramapremium/ConfigureMetricsUIPage.java | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) rename resources/schemas/dbscripts/postgresql/{targetedms-26.006-26.007.sql => targetedms-26.007-26.008.sql} (100%) diff --git a/resources/schemas/dbscripts/postgresql/targetedms-26.006-26.007.sql b/resources/schemas/dbscripts/postgresql/targetedms-26.007-26.008.sql similarity index 100% rename from resources/schemas/dbscripts/postgresql/targetedms-26.006-26.007.sql rename to resources/schemas/dbscripts/postgresql/targetedms-26.007-26.008.sql diff --git a/src/org/labkey/targetedms/TargetedMSModule.java b/src/org/labkey/targetedms/TargetedMSModule.java index b3bf9a4a7..d4739e31e 100644 --- a/src/org/labkey/targetedms/TargetedMSModule.java +++ b/src/org/labkey/targetedms/TargetedMSModule.java @@ -231,7 +231,7 @@ public String getName() @Override public Double getSchemaVersion() { - return 26.007; + return 26.008; } @Override diff --git a/test/src/org/labkey/test/pages/panoramapremium/ConfigureMetricsUIPage.java b/test/src/org/labkey/test/pages/panoramapremium/ConfigureMetricsUIPage.java index cad5f1102..358df607d 100644 --- a/test/src/org/labkey/test/pages/panoramapremium/ConfigureMetricsUIPage.java +++ b/test/src/org/labkey/test/pages/panoramapremium/ConfigureMetricsUIPage.java @@ -168,6 +168,7 @@ public void deleteAnnotationMetric(String metric) click(Locator.id("lk-annotation-metric-delete")); acceptAlert(); waitForPage(); + waitForElementToDisappear(Locator.linkWithText(metric)); } public void editMetric(String metric, Map metricProperties)