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($('
' +
- '' +
- '' +
- '' +
- '' +
- '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
'' +
+ '
' +
'
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)