From 41be46cf04554c750b28d91ef3bb39228564497c Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 28 Jan 2026 12:27:32 -0800 Subject: [PATCH 1/5] Replace hospscotch tooltip JS library for QC Plots with tippy --- .../targetedms/view/qcTrendPlotReport.jsp | 1 + webapp/TargetedMS/js/PlotTypeCheckCombo.js | 12 +- webapp/TargetedMS/js/QCPlotHoverPanel.js | 500 +++++++++++------- webapp/TargetedMS/js/QCSummaryPanel.js | 23 +- webapp/TargetedMS/js/QCTrendPlotPanel.js | 104 ++-- 5 files changed, 384 insertions(+), 256 deletions(-) diff --git a/src/org/labkey/targetedms/view/qcTrendPlotReport.jsp b/src/org/labkey/targetedms/view/qcTrendPlotReport.jsp index 63172b584..4c97ce3eb 100644 --- a/src/org/labkey/targetedms/view/qcTrendPlotReport.jsp +++ b/src/org/labkey/targetedms/view/qcTrendPlotReport.jsp @@ -30,6 +30,7 @@ dependencies.add("Ext4"); dependencies.add("Ext4ClientApi"); dependencies.add("vis/vis"); + dependencies.add("internal/tippy"); dependencies.add("hopscotch/css/hopscotch.min.css"); dependencies.add("hopscotch/js/hopscotch.min.js"); dependencies.add("targetedms/css/SVGExportIcon.css"); diff --git a/webapp/TargetedMS/js/PlotTypeCheckCombo.js b/webapp/TargetedMS/js/PlotTypeCheckCombo.js index 7624f95da..e66a4ce5b 100644 --- a/webapp/TargetedMS/js/PlotTypeCheckCombo.js +++ b/webapp/TargetedMS/js/PlotTypeCheckCombo.js @@ -84,10 +84,14 @@ Ext4.define('Ext.ux.CheckCombo', { boundList.getEl().on({ mouseover: function(event, target) { - createPlotTypeTooltip(event.currentTarget, target.textContent); - }, - mouseout: function() { - destroyPlotTypeTooltip(); + if (target) { + var text = target.textContent || target.innerText; + if (text) { + // Strip whitespace and leading   (which might be present from the template) + text = text.trim(); + createPlotTypeTooltip(target, text); + } + } }, delegate: "." + Ext4.baseCSSPrefix + 'boundlist-item', scope: this diff --git a/webapp/TargetedMS/js/QCPlotHoverPanel.js b/webapp/TargetedMS/js/QCPlotHoverPanel.js index 660f2fc57..b15e75cb9 100644 --- a/webapp/TargetedMS/js/QCPlotHoverPanel.js +++ b/webapp/TargetedMS/js/QCPlotHoverPanel.js @@ -4,59 +4,65 @@ * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 */ -Ext4.define('LABKEY.targetedms.QCPlotHoverPanel', { - extend: 'Ext.panel.Panel', - border: false, - - pointData: null, - valueName: null, - metricProps: null, - originalStatus: 0, - existingExclusions: null, - canEdit: false, - trailingRuns: null, - trailingStartDate: null, - trailingEndDate: null, - - STATE: { +LABKEY = LABKEY || {}; +LABKEY.targetedms = LABKEY.targetedms || {}; + +LABKEY.targetedms.QCPlotHoverPanel = function (config) { + this.pointData = config.pointData || {}; + this.valueName = config.valueName || null; + this.metricProps = config.metricProps || {}; + this.originalStatus = 0; + this.existingExclusions = null; + this.canEdit = config.canEdit || false; + this.trailingRuns = config.trailingRuns || null; + this.trailingStartDate = config.trailingStartDate || null; + this.trailingEndDate = config.trailingEndDate || null; + this.renderTo = config.renderTo; + this.onClose = config.onClose || function () { + }; + + this.STATE = { INCLUDE: 0, EXCLUDE_METRIC: 1, EXCLUDE_ALL: 2 - }, + }; - initComponent : function() { - if (this.pointData == null) { - this.pointData = {}; - } - if (this.metricProps == null) { - this.metricProps = {}; - } + this.containerEl = null; + this.exclusionPanel = null; + this.exclusionsSaveBtn = null; + this.exclusionRadioGroup = null; + this.viewDocumentURL = null; - this.items = []; - this.callParent(); + this.init(); +}; - if(!this.metricProps.precursorScoped) +LABKEY.targetedms.QCPlotHoverPanel.prototype = { + + init: function () { + if (!this.metricProps.precursorScoped) { this.getRunId(); - else + } else { this.getExistingReplicateExclusions(); + } }, - getExistingReplicateExclusions : function() { - if (Ext4.isNumber(this.pointData['ReplicateId'])) { + getExistingReplicateExclusions: function () { + if (typeof this.pointData['ReplicateId'] === 'number') { LABKEY.Query.selectRows({ schemaName: 'targetedms', queryName: 'QCMetricExclusion', filterArray: [LABKEY.Filter.create('ReplicateId', this.pointData['ReplicateId'])], scope: this, - success: function(data) { + success: function (data) { this.existingExclusions = data.rows; // set the initial status for this point based on the existing exclusions - var metricIds = Ext4.Array.pluck(this.existingExclusions, 'MetricId'); + var metricIds = this.existingExclusions.map(function (item) { + return item.MetricId; + }); if (metricIds.indexOf(null) > -1) { this.originalStatus = this.STATE.EXCLUDE_ALL; - } - else if (metricIds.indexOf(this.metricProps.id) > -1) { + } else if (metricIds.indexOf(this.metricProps.id) > -1) { this.originalStatus = this.STATE.EXCLUDE_METRIC; } @@ -66,7 +72,14 @@ Ext4.define('LABKEY.targetedms.QCPlotHoverPanel', { } }, - initializePanel : function() { + initializePanel: function () { + this.containerEl = document.createElement('div'); + this.containerEl.className = 'qc-plot-hover-panel'; + this.containerEl.style.backgroundColor = 'white'; + this.containerEl.style.border = '5px solid rgba(0, 0, 0, 0.5)'; + this.containerEl.style.padding = '10px'; + this.containerEl.style.minWidth = '600px'; + this.containerEl.style.color = 'black'; let hideExclusionAndPointClickLinks = false; if (this.valueName === "TrailingMean") { @@ -77,178 +90,206 @@ Ext4.define('LABKEY.targetedms.QCPlotHoverPanel', { } if (this.metricProps.name !== undefined) { - this.add(this.getPlotPointDetailField('Metric', this.metricProps.name)); + this.addElement(this.getPlotPointDetailField('Metric', this.metricProps.name)); } - this.add(this.getPlotPointDetailField(this.pointData['dataType'], this.pointData['fragment'], 'qc-hover-value-break')); + var fragmentValue = this.pointData['fragment']; + if (typeof fragmentValue === 'object' && fragmentValue !== null) { + fragmentValue = fragmentValue.name || fragmentValue.toString(); + } + this.addElement(this.getPlotPointDetailField(this.pointData['dataType'], fragmentValue, 'qc-hover-value-break')); if (this.valueName.indexOf('CUSUM') > -1) { - this.add(this.getPlotPointDetailField('Group', 'CUSUMmN' === this.valueName || 'CUSUMvN' === this.valueName ? 'CUSUM-' : 'CUSUM+')); + this.addElement(this.getPlotPointDetailField('Group', 'CUSUMmN' === this.valueName || 'CUSUMvN' === this.valueName ? 'CUSUM-' : 'CUSUM+')); } if (this.pointData.conversion && this.pointData.rawValue !== undefined && this.valueName.indexOf("CUSUM") === -1) { if (this.pointData.conversion === 'percentDeviation') { - this.add(this.getPlotPointDetailField('Value', LABKEY.targetedms.PlotSettingsUtil.formatNumeric(this.pointData.rawValue))); - this.add(this.getPlotPointDetailField('% of Mean', (this.valueName ? this.pointData[this.valueName] : this.pointData['value']) + '%')) - } - else if (this.pointData.conversion === 'standardDeviation') { - this.add(this.getPlotPointDetailField('Value', LABKEY.targetedms.PlotSettingsUtil.formatNumeric(this.pointData.rawValue))); - this.add(this.getPlotPointDetailField('Std Devs', this.valueName ? this.pointData[this.valueName] : this.pointData['value'])) - } - else if (this.pointData.conversion === 'deltaFromMean') { - this.add(this.getPlotPointDetailField('Value', LABKEY.targetedms.PlotSettingsUtil.formatNumeric(this.pointData.rawValue))); - this.add(this.getPlotPointDetailField('Delta From Mean', LABKEY.targetedms.PlotSettingsUtil.formatNumeric(this.valueName ? this.pointData[this.valueName] : this.pointData['value']))) + this.addElement(this.getPlotPointDetailField('Value', LABKEY.targetedms.PlotSettingsUtil.formatNumeric(this.pointData.rawValue))); + this.addElement(this.getPlotPointDetailField('% of Mean', (this.valueName ? this.pointData[this.valueName] : this.pointData['value']) + '%')) + } else if (this.pointData.conversion === 'standardDeviation') { + this.addElement(this.getPlotPointDetailField('Value', LABKEY.targetedms.PlotSettingsUtil.formatNumeric(this.pointData.rawValue))); + this.addElement(this.getPlotPointDetailField('Std Devs', this.valueName ? this.pointData[this.valueName] : this.pointData['value'])) + } else if (this.pointData.conversion === 'deltaFromMean') { + this.addElement(this.getPlotPointDetailField('Value', LABKEY.targetedms.PlotSettingsUtil.formatNumeric(this.pointData.rawValue))); + this.addElement(this.getPlotPointDetailField('Delta From Mean', LABKEY.targetedms.PlotSettingsUtil.formatNumeric(this.valueName ? this.pointData[this.valueName] : this.pointData['value']))) + } else { + this.addElement(this.getPlotPointDetailField('Value', LABKEY.targetedms.PlotSettingsUtil.formatNumeric(this.valueName ? this.pointData[this.valueName] : this.pointData['value']))); } - else { - this.add(this.getPlotPointDetailField('Value', LABKEY.targetedms.PlotSettingsUtil.formatNumeric(this.valueName ? this.pointData[this.valueName] : this.pointData['value']))); - } - } - else { - this.add(this.getPlotPointDetailField('Value', LABKEY.targetedms.PlotSettingsUtil.formatNumeric(this.valueName ? this.pointData[this.valueName] : this.pointData['value']))); + } else { + this.addElement(this.getPlotPointDetailField('Value', LABKEY.targetedms.PlotSettingsUtil.formatNumeric(this.valueName ? this.pointData[this.valueName] : this.pointData['value']))); } if (hideExclusionAndPointClickLinks) { let numOfRunsAverage = 0; // check if guide set is present - if (this.pointData['inGuideSetTrainingRange'] !== undefined) { - numOfRunsAverage = this.trailingRuns > this.pointData['TrainingSeqIdx'] ? this.pointData['TrainingSeqIdx'] : this.trailingRuns - } - else { - numOfRunsAverage = this.trailingRuns > this.pointData['seqValue'] + 1 ? this.pointData['seqValue'] + 1 : this.trailingRuns; - } - this.add(this.getPlotPointDetailField('Replicate', numOfRunsAverage + " runs average")); - this.add(this.getPlotPointDetailField('Acquired', this.trailingStartDate + " - " + this.trailingEndDate)); - } - else { - this.add(this.getPlotPointDetailField('Replicate', this.pointData['ReplicateName'])); - this.add(this.getPlotPointDetailField('Acquired', this.pointData['fullDate'])); + if (this.pointData['inGuideSetTrainingRange'] !== undefined) { + numOfRunsAverage = this.trailingRuns > this.pointData['TrainingSeqIdx'] ? this.pointData['TrainingSeqIdx'] : this.trailingRuns + } else { + numOfRunsAverage = this.trailingRuns > this.pointData['seqValue'] + 1 ? this.pointData['seqValue'] + 1 : this.trailingRuns; + } + this.addElement(this.getPlotPointDetailField('Replicate', numOfRunsAverage + " runs average")); + this.addElement(this.getPlotPointDetailField('Acquired', this.trailingStartDate + " - " + this.trailingEndDate)); + } else { + this.addElement(this.getPlotPointDetailField('Replicate', this.pointData['ReplicateName'])); + this.addElement(this.getPlotPointDetailField('Acquired', this.pointData['fullDate'])); } if (!hideExclusionAndPointClickLinks) { - this.add(this.getPlotPointDetailField('File Path', this.pointData['FilePath'].replace(/\\/g, '\\').replace(/\//g, '\/').replace(/_/g, '_'))); + this.addElement(this.getPlotPointDetailField('File Path', this.pointData['FilePath'].replace(/\\/g, '\\').replace(/\//g, '\/').replace(/_/g, '_'))); if (this.canEdit) { - this.add(this.getPlotPointExclusionPanel()); + this.addElement(this.getPlotPointExclusionPanel()); + } else { + this.addElement(this.getPlotPointDetailField('Status', this.pointData['IgnoreInQC'] ? 'Not included in QC' : 'Included in QC')); } - else { - this.add(this.getPlotPointDetailField('Status', this.pointData['IgnoreInQC'] ? 'Not included in QC' : 'Included in QC')); + + var linksDiv = document.createElement('div'); + linksDiv.innerHTML = this.getPlotPointClickLinks(); + this.addElement(linksDiv); + } + + if (this.renderTo) { + var targetEl = typeof this.renderTo === 'string' ? document.getElementById(this.renderTo) : this.renderTo; + if (targetEl) { + targetEl.appendChild(this.containerEl); } + } + }, - this.add(Ext4.create('Ext.Component', {html: this.getPlotPointClickLinks()})); + addElement: function (el) { + if (el && this.containerEl) { + this.containerEl.appendChild(el); } }, - getPlotPointDetailField : function(label, value, includeCls) { - return Ext4.create('Ext.form.field.Display', { - cls: 'qc-hover-field' + (Ext4.isString(includeCls) ? ' ' + includeCls : ''), - fieldLabel: label, - labelWidth: 75, - width: 450, - value: value - }); + getPlotPointDetailField: function (label, value, includeCls) { + var fieldDiv = document.createElement('div'); + fieldDiv.className = 'qc-hover-field' + (typeof includeCls === 'string' ? ' ' + includeCls : ''); + fieldDiv.style.width = '100%'; + fieldDiv.style.display = 'flex'; + fieldDiv.style.marginBottom = '5px'; + + var labelSpan = document.createElement('span'); + labelSpan.className = 'qc-hover-field-label'; + labelSpan.style.display = 'inline-block'; + labelSpan.style.width = '120px'; + labelSpan.style.fontWeight = 'bold'; + labelSpan.style.flexShrink = '0'; + labelSpan.textContent = label + ':'; + + var valueSpan = document.createElement('span'); + valueSpan.className = 'qc-hover-field-value'; + valueSpan.style.flex = '1'; + valueSpan.style.wordBreak = 'break-all'; + valueSpan.innerHTML = value; + + fieldDiv.appendChild(labelSpan); + fieldDiv.appendChild(valueSpan); + + return fieldDiv; }, - getPlotPointExclusionPanel : function() { + getPlotPointExclusionPanel: function () { if (!this.exclusionPanel) { - this.exclusionPanel = Ext4.create('Ext.form.Panel', { - border: false, - margin: '10px 0', - style: 'border-top: solid #eeeeee 1px; border-bottom: solid #eeeeee 1px;', - items: [this.getPlotPointExclusionRadioGroup()], - dockedItems: [{ - xtype: 'toolbar', - dock: 'right', - ui: 'footer', - padding: '0 0 10px 0', - items: ['->', this.getPlotPointExclusionSaveBtn()] - }] - }); + this.exclusionPanel = document.createElement('div'); + this.exclusionPanel.className = 'qc-hover-exclusion-panel'; + this.exclusionPanel.style.margin = '10px 0'; + this.exclusionPanel.style.borderTop = 'solid #eeeeee 1px'; + this.exclusionPanel.style.borderBottom = 'solid #eeeeee 1px'; + this.exclusionPanel.style.padding = '10px 0'; + + this.exclusionPanel.appendChild(this.getPlotPointExclusionRadioGroup()); + this.exclusionPanel.appendChild(this.getPlotPointExclusionSaveBtn()); } return this.exclusionPanel; }, - getPlotPointExclusionSaveBtn : function() { + getPlotPointExclusionSaveBtn: function () { if (!this.exclusionsSaveBtn) { - this.exclusionsSaveBtn = Ext4.create('Ext.button.Button', { - text: 'Save', - disabled: true, - scope: this, - handler: function() { - var newStatus = this.getPlotPointExclusionRadioGroup().getValue().status; - if (newStatus !== this.originalStatus) { - this.getEl().mask(); - - // Scenarios: - // 1 - from include to exclude metric - insert new row with MetricId - // 2 - from include to exclude all - delete all for replicate and then insert new row without MetricId - // 3 - from exclude metric to include - delete row for MetricId - // 4 - from exclude metric to exclude all - delete all for replicate and then insert new row without MetricId - // 5 - from exclude all to include - delete all for replicate - // 6 - from exclude all to exclude metric - delete all for replicate and then insert new row with MetricId - var s1 = this.originalStatus === this.STATE.INCLUDE && newStatus === this.STATE.EXCLUDE_METRIC; - var s2 = this.originalStatus === this.STATE.INCLUDE && newStatus === this.STATE.EXCLUDE_ALL; - var s3 = this.originalStatus === this.STATE.EXCLUDE_METRIC && newStatus === this.STATE.INCLUDE; - var s4 = this.originalStatus === this.STATE.EXCLUDE_METRIC && newStatus === this.STATE.EXCLUDE_ALL; - var s5 = this.originalStatus === this.STATE.EXCLUDE_ALL && newStatus === this.STATE.INCLUDE; - var s6 = this.originalStatus === this.STATE.EXCLUDE_ALL && newStatus === this.STATE.EXCLUDE_METRIC; - - var commands = []; - - if (this.existingExclusions.length > 0) { - // for scenarios s2, s4, s5, and s6 - delete all existing exclusions for this replicate - if (s2 || s4 || s5 || s6) { - commands.push({ - schemaName: 'targetedms', - queryName: 'QCMetricExclusion', - command: 'delete', - rows: this.existingExclusions - }); - } - - // for scenario s3 - delete the existing exclusion for this replicate/metric - if (s3) { - commands.push({ - schemaName: 'targetedms', - queryName: 'QCMetricExclusion', - command: 'delete', - rows: [this.existingExclusions[Ext4.Array.pluck(this.existingExclusions, 'MetricId').indexOf(this.metricProps.id)]] - }); - } - } - - // for scenarios s1 and s6 - insert a new exclusion for this replicate/metric - if (s1 || s6) { + this.exclusionsSaveBtn = document.createElement('button'); + this.exclusionsSaveBtn.textContent = 'Save'; + this.exclusionsSaveBtn.disabled = true; + this.exclusionsSaveBtn.className = 'labkey-button'; + + var self = this; + this.exclusionsSaveBtn.addEventListener('click', function () { + var newStatus = self.getRadioGroupValue(); + if (newStatus !== self.originalStatus) { + self.showMask(); + + // Scenarios: + // 1 - from include to exclude metric - insert new row with MetricId + // 2 - from include to exclude all - delete all for replicate and then insert new row without MetricId + // 3 - from exclude metric to include - delete row for MetricId + // 4 - from exclude metric to exclude all - delete all for replicate and then insert new row without MetricId + // 5 - from exclude all to include - delete all for replicate + // 6 - from exclude all to exclude metric - delete all for replicate and then insert new row with MetricId + var s1 = self.originalStatus === self.STATE.INCLUDE && newStatus === self.STATE.EXCLUDE_METRIC; + var s2 = self.originalStatus === self.STATE.INCLUDE && newStatus === self.STATE.EXCLUDE_ALL; + var s3 = self.originalStatus === self.STATE.EXCLUDE_METRIC && newStatus === self.STATE.INCLUDE; + var s4 = self.originalStatus === self.STATE.EXCLUDE_METRIC && newStatus === self.STATE.EXCLUDE_ALL; + var s5 = self.originalStatus === self.STATE.EXCLUDE_ALL && newStatus === self.STATE.INCLUDE; + var s6 = self.originalStatus === self.STATE.EXCLUDE_ALL && newStatus === self.STATE.EXCLUDE_METRIC; + + var commands = []; + + if (self.existingExclusions.length > 0) { + // for scenarios s2, s4, s5, and s6 - delete all existing exclusions for this replicate + if (s2 || s4 || s5 || s6) { commands.push({ schemaName: 'targetedms', queryName: 'QCMetricExclusion', - command: 'insert', - rows: [{ ReplicateId: this.pointData['ReplicateId'], MetricId: this.metricProps.id }] + command: 'delete', + rows: self.existingExclusions }); } - // for scenarios s2 and s4 - insert a new exclusion for this replicate without a metric value - if (s2 || s4) { + // for scenario s3 - delete the existing exclusion for this replicate/metric + if (s3) { + var metricIds = self.existingExclusions.map(function (item) { + return item.MetricId; + }); commands.push({ schemaName: 'targetedms', queryName: 'QCMetricExclusion', - command: 'insert', - rows: [{ ReplicateId: this.pointData['ReplicateId'] }] + command: 'delete', + rows: [self.existingExclusions[metricIds.indexOf(self.metricProps.id)]] }); } + } - LABKEY.Query.saveRows({ - commands: commands, - scope: this, - success: function(data) { - // Issue 30343: need to reload the full page because the QC Summary webpart might be - // present and need to be updated according to the updated exclusion state. - window.location.reload(); - } + // for scenarios s1 and s6 - insert a new exclusion for this replicate/metric + if (s1 || s6) { + commands.push({ + schemaName: 'targetedms', + queryName: 'QCMetricExclusion', + command: 'insert', + rows: [{ ReplicateId: self.pointData['ReplicateId'], MetricId: self.metricProps.id }] }); } - else { - this.fireEvent('close'); + + // for scenarios s2 and s4 - insert a new exclusion for this replicate without a metric value + if (s2 || s4) { + commands.push({ + schemaName: 'targetedms', + queryName: 'QCMetricExclusion', + command: 'insert', + rows: [{ ReplicateId: self.pointData['ReplicateId'] }] + }); } + + LABKEY.Query.saveRows({ + commands: commands, + scope: self, + success: function (data) { + // Issue 30343: need to reload the full page because the QC Summary webpart might be + // present and need to be updated according to the updated exclusion state. + window.location.reload(); + } + }); + } else { + self.onClose(); } }); } @@ -256,72 +297,135 @@ Ext4.define('LABKEY.targetedms.QCPlotHoverPanel', { return this.exclusionsSaveBtn; }, - getPlotPointExclusionRadioGroup : function() { + getPlotPointExclusionRadioGroup: function () { if (!this.exclusionRadioGroup) { - this.exclusionRadioGroup = Ext4.create('Ext.form.RadioGroup', { - cls: 'qc-hover-field', - fieldLabel: 'Status', - labelWidth: 75, - padding: '10px 0 0 0', - width: 450, - columns: 1, - items: [{ - boxLabel: 'Include', name: 'status', - inputValue: this.STATE.INCLUDE, - checked: this.originalStatus === this.STATE.INCLUDE - },{ - boxLabel: 'Exclude replicate for this metric', name: 'status', - inputValue: this.STATE.EXCLUDE_METRIC, + var radioGroupDiv = document.createElement('div'); + radioGroupDiv.className = 'qc-hover-field'; + radioGroupDiv.style.padding = '10px 0 0 0'; + radioGroupDiv.style.width = '100%'; + radioGroupDiv.style.display = 'flex'; + + var labelDiv = document.createElement('div'); + labelDiv.style.display = 'inline-block'; + labelDiv.style.width = '120px'; + labelDiv.style.fontWeight = 'bold'; + labelDiv.style.flexShrink = '0'; + labelDiv.style.verticalAlign = 'top'; + labelDiv.textContent = 'Status:'; + radioGroupDiv.appendChild(labelDiv); + + var optionsDiv = document.createElement('div'); + optionsDiv.style.flex = '1'; + + var self = this; + var options = [ + { label: 'Include', value: this.STATE.INCLUDE, checked: this.originalStatus === this.STATE.INCLUDE }, + { + label: 'Exclude replicate for this metric', + value: this.STATE.EXCLUDE_METRIC, checked: this.originalStatus === this.STATE.EXCLUDE_METRIC - },{ - boxLabel: 'Exclude replicate for all metrics', name: 'status', - inputValue: this.STATE.EXCLUDE_ALL, + }, + { + label: 'Exclude replicate for all metrics', + value: this.STATE.EXCLUDE_ALL, checked: this.originalStatus === this.STATE.EXCLUDE_ALL - }], - listeners: { - scope: this, - change: function(cmp, newVal, oldVal) { - this.getPlotPointExclusionSaveBtn().setDisabled(newVal.status === this.originalStatus); - } } + ]; + + options.forEach(function (option) { + var optionDiv = document.createElement('div'); + optionDiv.style.marginBottom = '5px'; + + var radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'exclusion-status'; + radio.value = option.value; + radio.checked = option.checked; + radio.addEventListener('change', function () { + var currentValue = self.getRadioGroupValue(); + self.exclusionsSaveBtn.disabled = (currentValue === self.originalStatus); + }); + + var label = document.createElement('label'); + label.style.marginLeft = '5px'; + label.textContent = option.label; + + optionDiv.appendChild(radio); + optionDiv.appendChild(label); + optionsDiv.appendChild(optionDiv); }); + + radioGroupDiv.appendChild(optionsDiv); + this.exclusionRadioGroup = radioGroupDiv; } return this.exclusionRadioGroup; }, - getPlotPointClickLinks : function() { + getRadioGroupValue: function () { + if (this.exclusionRadioGroup) { + var radios = this.exclusionRadioGroup.querySelectorAll('input[name="exclusion-status"]'); + for (var i = 0; i < radios.length; i++) { + if (radios[i].checked) { + return parseInt(radios[i].value); + } + } + } + return this.STATE.INCLUDE; + }, + + showMask: function () { + if (this.containerEl) { + var mask = document.createElement('div'); + mask.style.position = 'absolute'; + mask.style.top = '0'; + mask.style.left = '0'; + mask.style.width = '100%'; + mask.style.height = '100%'; + mask.style.backgroundColor = 'rgba(255, 255, 255, 0.7)'; + mask.style.zIndex = '1000'; + this.containerEl.style.position = 'relative'; + this.containerEl.appendChild(mask); + } + }, + + getPlotPointClickLinks: function () { //Choose action target based on precursor type var action = this.pointData['dataType'] === 'Peptide' ? "precursorAllChromatogramsChart" : "moleculePrecursorAllChromatogramsChart", - url = LABKEY.ActionURL.buildURL('targetedms', action, LABKEY.ActionURL.getContainer(), { - id: this.pointData['PrecursorId'], - chromInfoId: this.pointData['PrecursorChromInfoId'] - }); + url = LABKEY.ActionURL.buildURL('targetedms', action, LABKEY.ActionURL.getContainer(), { + id: this.pointData['PrecursorId'], + chromInfoId: this.pointData['PrecursorChromInfoId'] + }); return LABKEY.Utils.textLink({ - text: this.metricProps.precursorScoped ? 'View Chromatogram': 'View Document', - href: this.metricProps.precursorScoped ? url + '#ChromInfo' + this.pointData['PrecursorChromInfoId'] : this.viewDocumentURL - }) + ' ' + - LABKEY.Utils.textLink({ - text: 'View Replicate', - href: LABKEY.ActionURL.buildURL('targetedms', 'showSampleFile', LABKEY.ActionURL.getContainer(), {id: this.pointData['SampleFileId']}) - }); + text: this.metricProps.precursorScoped ? 'View Chromatogram' : 'View Document', + href: this.metricProps.precursorScoped ? url + '#ChromInfo' + this.pointData['PrecursorChromInfoId'] : this.viewDocumentURL + }) + ' ' + + LABKEY.Utils.textLink({ + text: 'View Replicate', + href: LABKEY.ActionURL.buildURL('targetedms', 'showSampleFile', LABKEY.ActionURL.getContainer(), { id: this.pointData['SampleFileId'] }) + }); }, getRunId: function () { - LABKEY.Query.executeSql({ schemaName: 'targetedms', sql: 'SELECT SampleFileId.ReplicateId.RunId.Id as runId from PrecursorChromInfo', scope: this, success: function (results) { var runId; - if(results && results.rows) + if (results && results.rows) runId = results.rows[0]["runId"]; - this.viewDocumentURL = LABKEY.ActionURL.buildURL('targetedms', 'showPrecursorList', null, {id: runId}); + this.viewDocumentURL = LABKEY.ActionURL.buildURL('targetedms', 'showPrecursorList', null, { id: runId }); this.getExistingReplicateExclusions(); } }); + }, + + destroy: function () { + if (this.containerEl && this.containerEl.parentNode) { + this.containerEl.parentNode.removeChild(this.containerEl); + } } -}); \ No newline at end of file +}; \ No newline at end of file diff --git a/webapp/TargetedMS/js/QCSummaryPanel.js b/webapp/TargetedMS/js/QCSummaryPanel.js index 99bf9d736..dd6e2de4e 100644 --- a/webapp/TargetedMS/js/QCSummaryPanel.js +++ b/webapp/TargetedMS/js/QCSummaryPanel.js @@ -483,16 +483,23 @@ Ext4.define('LABKEY.targetedms.QCSummary', { // add mouse listeners to the div element for when to show the hover details for this sample file divEl.on('mouseover', function() { task.delay(1000, function(el){ - var calloutMgr = hopscotch.getCalloutManager(); - calloutMgr.removeAllCallouts(); - calloutMgr.createCallout({ - id: Ext4.id(), - target: el.dom, - placement: 'bottom', - width: sampleFile.Metrics.length > 0 ? 800 : 300, + tippy(el.dom, { content: content, - onShow: this.attachHopscotchMouseClose + theme: 'light', + sticky: true, + followCursor: true, + maxWidth: 800 }); + // var calloutMgr = hopscotch.getCalloutManager(); + // calloutMgr.removeAllCallouts(); + // calloutMgr.createCallout({ + // id: Ext4.id(), + // target: el.dom, + // placement: 'bottom', + // width: sampleFile.Metrics.length > 0 ? 800 : 300, + // content: content, + // onShow: this.attachHopscotchMouseClose + // }); }, this, [divEl]); }, this); diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index 01d7ad580..6e94aa6a8 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -1169,7 +1169,9 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { if (!this.createGuideSetToggleButton) { this.createGuideSetToggleButton = Ext4.create('Ext.button.Button', { text: 'Create Guide Set', - tooltip: 'Enable/disable guide set creation mode. Supported for plots when ' + LABKEY.targetedms.QCPlotHelperBase.maxPointsPerSeries + ' or fewer samples are shown', + id: 'create-guide-set-button', + + // tooltip: 'Enable/disable guide set creation mode. Supported for plots when ' + LABKEY.targetedms.QCPlotHelperBase.maxPointsPerSeries + ' or fewer samples are shown', disabled: !this.canCreateGuideSetFromPlot(), enableToggle: true, handler: function(btn) { @@ -1177,6 +1179,12 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { }, scope: this }); + + tippy.delegate('body', { + target: '#create-guide-set-button', // The selector for elements that don't exist yet + content: 'I am a dynamic tooltip!', + // any other tippy props... + }); } return this.createGuideSetToggleButton; @@ -1400,8 +1408,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { }, plotPointMouseOver : function(event, row, layerSel, point, valueName, plotConfig) { - var showHoverTask = new Ext4.util.DelayedTask(), - metricProps = this.getMetricPropsById(row.MetricId), + let metricProps = this.getMetricPropsById(row.MetricId), me = this; let panelY = me.canUserEdit() ? -375 : -270; @@ -1413,52 +1420,57 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { let trailingStartDate = Ext4.util.Format.date(row['TrailingStartDate'], 'Y-m-d H:i'); let trailingEndDate = Ext4.util.Format.date(row['fullDate'], 'Y-m-d H:i'); - showHoverTask.delay(500, function() { - var calloutMgr = hopscotch.getCalloutManager(), - hopscotchId = Ext4.id(), - contentDivId = Ext4.id(), - shiftLeft = (event.clientX || event.x) > (Ext4.getBody().getWidth() / 2), - config = { - id: hopscotchId, - showCloseButton: true, - bubbleWidth: 450, - placement: 'top', - xOffset: shiftLeft ? -428 : -53, - arrowOffset: shiftLeft ? 410 : 35, - yOffset: panelY, - target: point, - content: '
', - onShow: function() { - me.attachHopscotchMouseClose(); - - Ext4.create('LABKEY.targetedms.QCPlotHoverPanel', { - renderTo: contentDivId, - pointData: row, - valueName: valueName, - trailingRuns: trailingRuns, - trailingStartDate: trailingStartDate, - trailingEndDate: trailingEndDate, - metricProps: metricProps, - canEdit: me.canUserEdit(), - listeners: { - scope: me, - close: function() { - calloutMgr.removeAllCallouts(); - } - } - }); - } - }; + // Hide any previously open tooltip + if (me.currentTippy && me.currentTippy !== point._tippy) { + me.currentTippy.hide(); + } + + if (!point._tippy) { + const container = document.createElement('div'); + container.id = Ext4.id(); + document.body.appendChild(container); + + new LABKEY.targetedms.QCPlotHoverPanel({ + pointData: row, + valueName: valueName, + trailingRuns: trailingRuns, + trailingStartDate: trailingStartDate, + trailingEndDate: trailingEndDate, + metricProps: metricProps, + canEdit: me.canUserEdit(), + renderTo: container + }); - calloutMgr.removeAllCallouts(); - calloutMgr.createCallout(config); + tippy(point, { + allowHTML: true, + interactive: true, + theme: 'light', + content: container, + hideOnClick: 'toggle', + arrow: true, + maxWidth: 500, + appendTo: document.body, + onShow(instance) { + const tippyBox = instance.popper.querySelector('.tippy-box'); + const tippyContent = instance.popper.querySelector('.tippy-content'); + if (tippyBox) { + tippyBox.style.backgroundColor = 'transparent'; + tippyBox.style.border = 'none'; + tippyBox.style.boxShadow = 'none'; + } + if (tippyContent) { + tippyContent.style.padding = '5px'; + tippyContent.style.wordWrap = 'break-word'; + tippyContent.style.overflowWrap = 'break-word'; + } + } }); + } else { + point._tippy.show(); + } - // cancel the hover details show event if the user was just - // passing over the point without stopping for X amount of time - Ext4.get(point).on('mouseout', function() { - showHoverTask.cancel(); - }, this); + // Track the currently open tooltip + me.currentTippy = point._tippy; // for the combined / single plot case, we want to have point hover highlight the given series // by using opacity to "push" the other points and lines to the background From e22facd7973738370fd5133cbe6f1627bdf3a26f Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Wed, 28 Jan 2026 12:31:00 -0800 Subject: [PATCH 2/5] put back other tooltips --- webapp/TargetedMS/js/PlotTypeCheckCombo.js | 12 ++++------- webapp/TargetedMS/js/QCSummaryPanel.js | 23 ++++++++-------------- webapp/TargetedMS/js/QCTrendPlotPanel.js | 10 +--------- 3 files changed, 13 insertions(+), 32 deletions(-) diff --git a/webapp/TargetedMS/js/PlotTypeCheckCombo.js b/webapp/TargetedMS/js/PlotTypeCheckCombo.js index e66a4ce5b..7624f95da 100644 --- a/webapp/TargetedMS/js/PlotTypeCheckCombo.js +++ b/webapp/TargetedMS/js/PlotTypeCheckCombo.js @@ -84,14 +84,10 @@ Ext4.define('Ext.ux.CheckCombo', { boundList.getEl().on({ mouseover: function(event, target) { - if (target) { - var text = target.textContent || target.innerText; - if (text) { - // Strip whitespace and leading   (which might be present from the template) - text = text.trim(); - createPlotTypeTooltip(target, text); - } - } + createPlotTypeTooltip(event.currentTarget, target.textContent); + }, + mouseout: function() { + destroyPlotTypeTooltip(); }, delegate: "." + Ext4.baseCSSPrefix + 'boundlist-item', scope: this diff --git a/webapp/TargetedMS/js/QCSummaryPanel.js b/webapp/TargetedMS/js/QCSummaryPanel.js index dd6e2de4e..99bf9d736 100644 --- a/webapp/TargetedMS/js/QCSummaryPanel.js +++ b/webapp/TargetedMS/js/QCSummaryPanel.js @@ -483,23 +483,16 @@ Ext4.define('LABKEY.targetedms.QCSummary', { // add mouse listeners to the div element for when to show the hover details for this sample file divEl.on('mouseover', function() { task.delay(1000, function(el){ - tippy(el.dom, { + var calloutMgr = hopscotch.getCalloutManager(); + calloutMgr.removeAllCallouts(); + calloutMgr.createCallout({ + id: Ext4.id(), + target: el.dom, + placement: 'bottom', + width: sampleFile.Metrics.length > 0 ? 800 : 300, content: content, - theme: 'light', - sticky: true, - followCursor: true, - maxWidth: 800 + onShow: this.attachHopscotchMouseClose }); - // var calloutMgr = hopscotch.getCalloutManager(); - // calloutMgr.removeAllCallouts(); - // calloutMgr.createCallout({ - // id: Ext4.id(), - // target: el.dom, - // placement: 'bottom', - // width: sampleFile.Metrics.length > 0 ? 800 : 300, - // content: content, - // onShow: this.attachHopscotchMouseClose - // }); }, this, [divEl]); }, this); diff --git a/webapp/TargetedMS/js/QCTrendPlotPanel.js b/webapp/TargetedMS/js/QCTrendPlotPanel.js index 6e94aa6a8..53a0fcb24 100644 --- a/webapp/TargetedMS/js/QCTrendPlotPanel.js +++ b/webapp/TargetedMS/js/QCTrendPlotPanel.js @@ -1169,9 +1169,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { if (!this.createGuideSetToggleButton) { this.createGuideSetToggleButton = Ext4.create('Ext.button.Button', { text: 'Create Guide Set', - id: 'create-guide-set-button', - - // tooltip: 'Enable/disable guide set creation mode. Supported for plots when ' + LABKEY.targetedms.QCPlotHelperBase.maxPointsPerSeries + ' or fewer samples are shown', + tooltip: 'Enable/disable guide set creation mode. Supported for plots when ' + LABKEY.targetedms.QCPlotHelperBase.maxPointsPerSeries + ' or fewer samples are shown', disabled: !this.canCreateGuideSetFromPlot(), enableToggle: true, handler: function(btn) { @@ -1179,12 +1177,6 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', { }, scope: this }); - - tippy.delegate('body', { - target: '#create-guide-set-button', // The selector for elements that don't exist yet - content: 'I am a dynamic tooltip!', - // any other tippy props... - }); } return this.createGuideSetToggleButton; From c1bc5592312aeb5811a3a46c2062752558a8c49a Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Thu, 29 Jan 2026 18:32:28 -0800 Subject: [PATCH 3/5] fix testClickingChromatogramPlotFromQCPlot --- .../labkey/test/components/targetedms/QCPlotsWebPart.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java b/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java index 20b4c2434..315496fd2 100644 --- a/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java +++ b/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java @@ -530,10 +530,11 @@ public WebElement openExclusionBubble(String acquiredDate) WebElement point = getPointByAcquiredDate(acquiredDate); ScrollUtils.scrollIntoView(point, center, center); getWrapper().mouseOverWithoutScrolling(point); - return getWrapper().isElementPresent(Locator.tagWithClass("div", "x4-form-display-field") - .containing(acquiredDate.substring(0, 16))); // drop seconds part (e.g. "2013-08-12 04:54") for trailing mean/CV + return getWrapper().isElementPresent(Locator.tagWithClass("div", "qc-plot-hover-panel") + .withDescendant(Locator.tagWithClass("div", "qc-hover-field") + .containing(acquiredDate.substring(0, 16)))); // drop seconds part (e.g. "2013-08-12 04:54") for trailing mean/CV }); - return elementCache().hopscotchBubble.findElement(getDriver()); + return elementCache().tippyBubble.findElement(getDriver()); } @LogMethod @@ -972,6 +973,7 @@ public class Elements extends BodyWebPart.ElementCache Locator.CssLocator svgBackgrounds = Locator.css("svg g.brush rect.background"); Locator.XPathLocator hopscotchBubble = Locator.byClass("hopscotch-bubble-container"); Locator.XPathLocator hopscotchBubbleClose = Locator.byClass("hopscotch-bubble-close"); + Locator.XPathLocator tippyBubble = Locator.tagWithClass("div", "qc-plot-hover-panel"); List findSeriesPanels() { From 2868e22dbbb68bf48468089646bebdb14967e92c Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Fri, 30 Jan 2026 12:16:09 -0800 Subject: [PATCH 4/5] update qc plot data points tooltip locators in tests --- .../labkey/test/components/targetedms/QCPlotsWebPart.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java b/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java index 315496fd2..3bb9a8cf0 100644 --- a/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java +++ b/test/src/org/labkey/test/components/targetedms/QCPlotsWebPart.java @@ -733,13 +733,12 @@ public void goToNextPage() public Locator.XPathLocator getBubble() { - return Locator.byClass("hopscotch-bubble-container"); + return Locator.tagWithClass("div", "qc-plot-hover-panel"); } public Locator.XPathLocator getBubbleContent() { - Locator.XPathLocator hopscotchBubble = Locator.byClass("hopscotch-bubble-container"); - return hopscotchBubble.append(Locator.byClass("hopscotch-bubble-content").append(Locator.byClass("hopscotch-content").withText())); + return Locator.tagWithClass("div", "qc-plot-hover-panel"); } public ConfigureMetricsUIPage clickConfigureQCMetrics() From ddd5240169c96a2ab6c7ad2d89ef0ca0d347ba1b Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Fri, 30 Jan 2026 16:09:01 -0800 Subject: [PATCH 5/5] update qc plot data points tooltip locators in tests --- .../tests/targetedms/TargetedMSQCTest.java | 21 +++++++++++++------ webapp/TargetedMS/js/QCPlotHoverPanel.js | 10 ++++++++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSQCTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSQCTest.java index f9016ad3d..33a1cb5e4 100644 --- a/test/src/org/labkey/test/tests/targetedms/TargetedMSQCTest.java +++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSQCTest.java @@ -1001,8 +1001,12 @@ private void verifyExclusionButtonSelection(String acquiredDate, QCPlotsWebPart. { QCPlotsWebPart qcPlotsWebPart = new PanoramaDashboard(this).getQcPlotsWebPart(); WebElement bubble = qcPlotsWebPart.openExclusionBubble(acquiredDate); - RadioButton radioButton = RadioButton.RadioButton().withLabel(state.getLabel()).find(bubble); - assertTrue("QC data point exclusion selection not as expected:" + state.getLabel(), radioButton.isChecked()); + // Updated for tippy implementation - uses standard HTML radio buttons with name="exclusion-status" + WebElement radioButton = Locator.tag("input").withAttribute("type", "radio") + .withAttribute("name", "exclusion-status") + .followingSibling("label").withText(state.getLabel()) + .precedingSibling("input").findElement(bubble); + assertTrue("QC data point exclusion selection not as expected:" + state.getLabel(), radioButton.isSelected()); qcPlotsWebPart.closeBubble(); } @@ -1010,11 +1014,16 @@ private void changePointExclusionState(String acquiredDate, QCPlotsWebPart.QCPlo { QCPlotsWebPart qcPlotsWebPart = new PanoramaDashboard(this).getQcPlotsWebPart(); WebElement bubble = qcPlotsWebPart.openExclusionBubble(acquiredDate); - RadioButton radioButton = RadioButton.RadioButton().withLabel(state.getLabel()).find(bubble); - if (!radioButton.isChecked()) + // Updated for tippy implementation - uses standard HTML radio buttons with name="exclusion-status" + WebElement radioButton = Locator.tag("input").withAttribute("type", "radio") + .withAttribute("name", "exclusion-status") + .followingSibling("label").withText(state.getLabel()) + .precedingSibling("input").findElement(bubble); + if (!radioButton.isSelected()) { - radioButton.check(); - clickAndWait(Ext4Helper.Locators.ext4Button("Save").findElement(bubble)); + radioButton.click(); + // Updated for tippy implementation - uses standard HTML button with class="labkey-button" + clickAndWait(Locator.tagWithClass("button", "labkey-button").withText("Save").findElement(bubble)); } else qcPlotsWebPart.closeBubble(); diff --git a/webapp/TargetedMS/js/QCPlotHoverPanel.js b/webapp/TargetedMS/js/QCPlotHoverPanel.js index b15e75cb9..fb07bff5b 100644 --- a/webapp/TargetedMS/js/QCPlotHoverPanel.js +++ b/webapp/TargetedMS/js/QCPlotHoverPanel.js @@ -207,11 +207,17 @@ LABKEY.targetedms.QCPlotHoverPanel.prototype = { getPlotPointExclusionSaveBtn: function () { if (!this.exclusionsSaveBtn) { + var btnContainer = document.createElement('div'); + btnContainer.style.marginLeft = '120px'; + btnContainer.style.marginTop = '10px'; + this.exclusionsSaveBtn = document.createElement('button'); this.exclusionsSaveBtn.textContent = 'Save'; this.exclusionsSaveBtn.disabled = true; this.exclusionsSaveBtn.className = 'labkey-button'; + btnContainer.appendChild(this.exclusionsSaveBtn); + var self = this; this.exclusionsSaveBtn.addEventListener('click', function () { var newStatus = self.getRadioGroupValue(); @@ -292,9 +298,11 @@ LABKEY.targetedms.QCPlotHoverPanel.prototype = { self.onClose(); } }); + + return btnContainer; } - return this.exclusionsSaveBtn; + return this.exclusionsSaveBtn.parentNode; }, getPlotPointExclusionRadioGroup: function () {