From b7c3c7c68225d934d27cf1f773a2e8dd4eec89db Mon Sep 17 00:00:00 2001 From: oleg-odysseus Date: Fri, 10 Oct 2025 18:14:06 +0200 Subject: [PATCH 1/3] Concept Sets Compare Feature [compare] Added dropdown lists for vocabulary selection [compare] implemented selection of sources and vocabulary versions [compare] added api operation, updated error message to support comparing the same concept set over different vocabularies [compare] Support diff vocab comparison, propagation of expected payloads to the backend [compare] implemented diff-vocab compare for non-csv [compare] Added new column Vocabulary Source to comparison results [compare] Added facet filters for diff vocab results [compare] Fixing no effect on compare error [compare-batch] Initial skeleton for the batch compare [compare] Extended response with more flags related to CS / Vocabs diffs; Added batch compare modal showing [compare] Extended compare results to contain names from both vocabs [compare] batch compare modal [compare] removed unnecessary Vocabulary Source column [compare-batch] Added Tag selector [compare] vocabulary sources as prefix when names are different [compare] Updated facets and Match column to align with multi-vocab compare results [batch-compare] Downloading Batch Compare Job Artifacts [compare] comparing more concept fields [compare] comparing source codes [compare] source codes and included concepts in different tabs [batch-compare] reused atlas root styles, improved form [batch-compare] author as a dropdown [batch-compare] compare source codes in batch request [batch-compare] double scroll fix [batch-compare] fixed buttons style in modal [batch-compare] moved button to jobs [batch-compare][compare] clean up concepset-compare, review match facets [batch-compare] checking for artifact presence in getList [batch-compare] add filter check counts [compare] use only new backend compare operations [batch-compare] allow multiple tags in group [batch-compare] authors multi select [compare] Venn diagram for source codes [compare] Added new column Vocabulary Bundle not to duplicate the same info on multiple fields diff. Two exclamation mark signs in both rows for diffs [batch-compare] Allow batch compare job start / artifact download for admins only [batch-compare] Added performance warning [batch-compare] updated author and tag selector, changed buttons width [batch-compare] search authors by login [batch-compare] Added concept set Id filter parameter with selector (first version) [batch-compare] Added concept set Id filter parameter with selector (version 2) [batch-compare] Improve concept set id selector [batch-compare] Improve concept set id selector [batch-compare] Updated run button enabled criteria, increased size of dropdowns [batch-compare] Escaping names to allow special chars (author name issue) [batch-compare] Compare source codes by default, added handling of the wrapper type for compare results with stats per concept set --- js/components/charts/chartVenn.html | 12 +- js/components/charts/venn.js | 120 ++- js/const.js | 1 + .../components/tabs/conceptset-compare.html | 97 +-- .../components/tabs/conceptset-compare.js | 760 ++++++++--------- .../components/tabs/conceptset-compare.less | 64 +- .../compare-results-included-concepts.html | 63 ++ .../compare-results-included-concepts.js | 382 +++++++++ .../compare-results-included-concepts.less | 44 + .../compare-results-included-sourcecodes.html | 63 ++ .../compare-results-included-sourcecodes.js | 382 +++++++++ .../compare-results-included-sourcecodes.less | 44 + .../modal/conceptset-batch-compare-modal.html | 429 ++++++++++ .../modal/conceptset-batch-compare-modal.js | 780 ++++++++++++++++++ .../modal/conceptset-batch-compare-modal.less | 489 +++++++++++ js/pages/configuration/configuration.html | 3 +- js/pages/configuration/configuration.js | 1 + js/pages/jobs/job-manager.html | 53 +- js/pages/jobs/job-manager.js | 213 ++++- js/pages/jobs/job-manager.less | 20 + js/services/AuthAPI.js | 6 +- js/services/ConceptSet.js | 17 +- js/services/Jobs.js | 22 +- js/services/Vocabulary.js | 65 +- js/services/file.js | 66 +- 25 files changed, 3645 insertions(+), 551 deletions(-) create mode 100644 js/pages/concept-sets/components/tabs/subtabs/compare-results-included-concepts.html create mode 100644 js/pages/concept-sets/components/tabs/subtabs/compare-results-included-concepts.js create mode 100644 js/pages/concept-sets/components/tabs/subtabs/compare-results-included-concepts.less create mode 100644 js/pages/concept-sets/components/tabs/subtabs/compare-results-included-sourcecodes.html create mode 100644 js/pages/concept-sets/components/tabs/subtabs/compare-results-included-sourcecodes.js create mode 100644 js/pages/concept-sets/components/tabs/subtabs/compare-results-included-sourcecodes.less create mode 100644 js/pages/configuration/components/modal/conceptset-batch-compare-modal.html create mode 100644 js/pages/configuration/components/modal/conceptset-batch-compare-modal.js create mode 100644 js/pages/configuration/components/modal/conceptset-batch-compare-modal.less create mode 100644 js/pages/jobs/job-manager.less diff --git a/js/components/charts/chartVenn.html b/js/components/charts/chartVenn.html index 8d8c1561e..b9d3bb8a8 100644 --- a/js/components/charts/chartVenn.html +++ b/js/components/charts/chartVenn.html @@ -1,7 +1,11 @@
- - -
+
+ +
-
\ No newline at end of file + \ No newline at end of file diff --git a/js/components/charts/venn.js b/js/components/charts/venn.js index e96ae62d2..a0427a599 100644 --- a/js/components/charts/venn.js +++ b/js/components/charts/venn.js @@ -19,30 +19,58 @@ define([ class Venn extends Component { - constructor(params,container){ + constructor(params, container){ super(params); this.firstConceptSet = params.firstConceptSet(); this.secondConceptSet = params.secondConceptSet(); this.data = params.data(); this.container = container; + + // Use provided diagramId or generate a unique one + this.diagramId = params.diagramId || `venn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + this.chartName = ko.computed(() => { - return `${this.firstConceptSet}_${this.secondConceptSet}_venn`.replaceAll(' ', '_') }); + this.conceptInBothConceptSets = []; this.conceptInFirstConceptSetOnly = []; this.conceptInSecondConceptSetOnly = []; this.selectOutsideConceptSet = params.lastSelectedMatchFilter.extend({notify: 'always'}); + this.updateOutsideFilters = params.updateOutsideFilters; + this.sets = ko.computed(() => { + // Clear arrays for recomputation + this.conceptInBothConceptSets = []; + this.conceptInFirstConceptSetOnly = []; + this.conceptInSecondConceptSetOnly = []; + + // Helper function to get concept name, preferring vocab-specific names + const getConceptName = (concept) => { + // For cross-vocabulary comparisons, prefer vocab-specific names + if (concept.vocab1ConceptName && concept.vocab2ConceptName) { + // Both vocabularies have the concept, use vocab1 name (or could combine) + return concept.vocab1ConceptName; + } else if (concept.vocab1ConceptName) { + return concept.vocab1ConceptName; + } else if (concept.vocab2ConceptName) { + return concept.vocab2ConceptName; + } + // Fallback - should not happen in normal cases + return 'Unknown'; + }; + this.data.forEach(concept => { - if (concept.conceptIn1Only === 1) { - this.conceptInFirstConceptSetOnly.push(concept.conceptName); + const conceptName = getConceptName(concept); + + if (concept.conceptInCS1Only === 1) { + this.conceptInFirstConceptSetOnly.push(conceptName); } - if (concept.conceptIn2Only === 1) { - this.conceptInSecondConceptSetOnly.push(concept.conceptName); + if (concept.conceptInCS2Only === 1) { + this.conceptInSecondConceptSetOnly.push(conceptName); } - if (concept.conceptIn1And2 === 1) { - this.conceptInBothConceptSets.push(concept.conceptName); + if (concept.conceptInCS1AndCS2 === 1) { + this.conceptInBothConceptSets.push(conceptName); } }); @@ -65,8 +93,8 @@ define([ } const conceptSets = [ - {sets: ['CS1'], size: lengthFirstConceptSets, tooltipText: this.conceptInFirstConceptSetOnly, amountOnly:this.conceptInFirstConceptSetOnly.length, count: this.conceptInFirstConceptSetOnly.length + this.conceptInBothConceptSets.length, name: this.firstConceptSet, key: '1 Only' }, - {sets: ['CS2'], size: lengthSecondConceptSets, tooltipText: this.conceptInSecondConceptSetOnly,amountOnly:this.conceptInSecondConceptSetOnly.length,count: this.conceptInSecondConceptSetOnly.length + this.conceptInBothConceptSets.length, name: this.secondConceptSet, key: '2 Only'}, + {sets: ['CS1'], size: lengthFirstConceptSets, tooltipText: this.conceptInFirstConceptSetOnly, amountOnly:this.conceptInFirstConceptSetOnly.length, count: this.conceptInFirstConceptSetOnly.length + this.conceptInBothConceptSets.length, name: this.firstConceptSet, key: 'CS1 Only' }, + {sets: ['CS2'], size: lengthSecondConceptSets, tooltipText: this.conceptInSecondConceptSetOnly,amountOnly:this.conceptInSecondConceptSetOnly.length,count: this.conceptInSecondConceptSetOnly.length + this.conceptInBothConceptSets.length, name: this.secondConceptSet, key: 'CS2 Only'}, ]; if (this.conceptInBothConceptSets.length > 0) { conceptSets.push({ @@ -82,6 +110,26 @@ define([ return conceptSets.sort((a,b) => b.size - a.size); }); + // Defer rendering until after DOM is ready + this.subscriptions = []; + + // Use setTimeout to ensure DOM element exists + setTimeout(() => { + this.renderDiagram(); + }, 100); + } + + renderDiagram() { + const diagramElement = document.getElementById(this.diagramId); + + if (!diagramElement) { + console.error(`Venn diagram container #${this.diagramId} not found`); + return; + } + + // Clear any existing content + d3.select(`#${this.diagramId}`).selectAll("*").remove(); + let chart = venn.VennDiagram(); chart.wrap(false) .height(450); @@ -89,8 +137,14 @@ define([ const textY = [0,0,30]; const colors = ['#1f77b4','#17becf', '#d62728']; const defaultColors = ['#d9edf7','#bdf9ff','#f2dede']; - let div = d3.select("#venn").datum(this.sets()).call(chart); - div.selectAll("text").attr("y", function(d,i) { return textY[i] + (+d3.select(this).attr("y")); }).style("font-size", '12px').style("fill", 'black').style('visibility', function(d) { return d.amountOnly ? 'visible' : 'hidden'}); + + let div = d3.select(`#${this.diagramId}`).datum(this.sets()).call(chart); + + div.selectAll("text") + .attr("y", function(d,i) { return textY[i] + (+d3.select(this).attr("y")); }) + .style("font-size", '12px') + .style("fill", 'black') + .style('visibility', function(d) { return d.amountOnly ? 'visible' : 'hidden'}); div.selectAll("path") .style("stroke", function(d,i) { return colors[i]; }) @@ -111,12 +165,15 @@ define([ // add a tooltip let tooltip = d3.select("body").append("div") - .attr("class", "venntooltip"); + .attr("class", "venntooltip") + .attr("data-diagram-id", this.diagramId); // Track which diagram this tooltip belongs to + + const self = this; // add listeners to all the groups to display tooltip on mouseover div.selectAll("g") .on("click", function(d) { - params.updateOutsideFilters(d.key); + self.updateOutsideFilters(d.key); }) .on("mouseover", function(d) { @@ -147,15 +204,13 @@ define([ .style("stroke-width", 2); }); - const subscriptions = []; - subscriptions.push( - this.selectOutsideConceptSet.subscribe(function (newValue) { + this.subscriptions.push( + this.selectOutsideConceptSet.subscribe((newValue) => { if (this.selectOutsideConceptSet !== "") { div.selectAll("path") .filter(function(d) { return d.key === newValue;}) .classed("selected", function() { return !d3.select(this).classed("selected"); }) .style('fill', function() { - if (d3.select(this).classed("selected")) { return d3.select(this).attr('color'); } else { @@ -209,15 +264,34 @@ define([ return [csLeftCircle,csRightCircle,csCommonCircle]; } - export() { - const svg = this.container.element.querySelector('svg'); - ChartUtils.downloadSvgAsPng(svg, this.chartName() || "untitled.png"); + exportPng() { + const svg = document.querySelector(`#${this.diagramId} svg`); + if (svg) { + ChartUtils.downloadSvgAsPng(svg, this.chartName() || "untitled.png"); + } } + exportSvg() { - const svg = this.container.element.querySelector('svg'); - ChartUtils.downloadSvg(svg, this.chartName() + ".svg" || "untitled.svg"); + const svg = document.querySelector(`#${this.diagramId} svg`); + if (svg) { + ChartUtils.downloadSvg(svg, this.chartName() + ".svg" || "untitled.svg"); + } } + dispose() { + // Clean up subscriptions + if (this.subscriptions) { + this.subscriptions.forEach(sub => sub.dispose()); + } + + // Remove tooltip associated with this diagram + d3.selectAll(`.venntooltip[data-diagram-id="${this.diagramId}"]`).remove(); + + // Clear the diagram + d3.select(`#${this.diagramId}`).selectAll("*").remove(); + + super.dispose(); + } } return commonUtils.build('venn-diagram', Venn, view); diff --git a/js/const.js b/js/const.js index b8a4ded79..41e05dcd4 100644 --- a/js/const.js +++ b/js/const.js @@ -209,6 +209,7 @@ define([ jobs: () => `${config.api.url}job/execution?comprehensivePage=true`, job: (id) => `${config.api.url}job/${id}`, jobByName: (name, type) => `${config.api.url}job/type/${type}/name/${name}`, + jobArtifact: (executionId) => `${config.api.url}job/${executionId}/artifact`, }; const applicationStatuses = { diff --git a/js/pages/concept-sets/components/tabs/conceptset-compare.html b/js/pages/concept-sets/components/tabs/conceptset-compare.html index 21fab5f18..fd47950c6 100644 --- a/js/pages/concept-sets/components/tabs/conceptset-compare.html +++ b/js/pages/concept-sets/components/tabs/conceptset-compare.html @@ -13,6 +13,8 @@ DPC + + @@ -26,6 +28,17 @@
+
+ + +
@@ -50,6 +63,17 @@ value: $component.compareCS2Caption " /> +
+ + +
@@ -85,65 +109,28 @@
- +
-
-
- -
-
-
- -
-
- - - -
-
-
-
- -
-
- -
- - - - -
+
+
+
-
- - -
+ +
+
+
+ +
+ + + +
diff --git a/js/pages/concept-sets/components/tabs/conceptset-compare.js b/js/pages/concept-sets/components/tabs/conceptset-compare.js index ad5ad6580..d59344ed1 100644 --- a/js/pages/concept-sets/components/tabs/conceptset-compare.js +++ b/js/pages/concept-sets/components/tabs/conceptset-compare.js @@ -2,6 +2,7 @@ define([ 'knockout', 'text!./conceptset-compare.html', 'services/AuthAPI', + 'services/SourceAPI', 'components/Component', 'utils/AutoBind', 'utils/CommonUtils', @@ -16,12 +17,15 @@ define([ './coneptset-compare-const', 'components/modal', 'components/charts/venn', - 'less!./conceptset-compare.less' + 'less!./conceptset-compare.less', + './subtabs/compare-results-included-concepts', + './subtabs/compare-results-included-sourcecodes', ], function ( - ko, - view, - authApi, - Component, + ko, + view, + authApi, + sourceApi, + Component, AutoBind, commonUtils, CsvUtils, @@ -34,230 +38,185 @@ define([ ConceptSet, Const ) { -class ConceptsetCompare extends AutoBind(Component) { + class ConceptsetCompare extends AutoBind(Component) { constructor(params) { - super(params); - this.isModalShown = ko.observable(false); - this.saveConceptSetFn = params.saveConceptSetFn; - this.saveConceptSetShow = params.saveConceptSetShow; - this.currentConceptSet = ConceptSetStore.repository().current; - this.selectedConcepts = ko.pureComputed(() => this.currentConceptSet() && this.currentConceptSet().expression.items()); - this.currentConceptSetDirtyFlag = sharedState.RepositoryConceptSet.dirtyFlag; - this.compareCS1Id = ko.observable(this.currentConceptSet().id); // Init to the currently loaded cs - this.compareCS1Caption = ko.observable(this.currentConceptSet().name()); - this.compareCS1ConceptSet = ko.observable(sharedState.selectedConcepts()); - this.compareCS1ConceptSetExpression = ko.pureComputed(() => { + super(params); + this.isModalShown = ko.observable(false); + this.saveConceptSetFn = params.saveConceptSetFn; + this.saveConceptSetShow = params.saveConceptSetShow; + this.currentConceptSet = ConceptSetStore.repository().current; + this.selectedConcepts = ko.pureComputed(() => this.currentConceptSet() && this.currentConceptSet().expression.items()); + this.currentConceptSetDirtyFlag = sharedState.RepositoryConceptSet.dirtyFlag; + + // CS1 setup + this.compareCS1Id = ko.observable(this.currentConceptSet().id); + this.compareCS1Caption = ko.observable(this.currentConceptSet().name()); + this.compareCS1ConceptSet = ko.observable(sharedState.selectedConcepts()); + this.compareCS1ConceptSetExpression = ko.pureComputed(() => { if (this.currentConceptSet() && this.compareCS1Id() === this.currentConceptSet().id) { return ko.toJS(this.selectedConcepts()); } else { return ko.toJS(this.compareCS1ConceptSet()); } + }); + this.compareCS1TypeFile = ko.observable(null); + + // CS2 setup + this.compareCS2Id = ko.observable(null); + this.compareCS2Caption = ko.observable(); + this.compareCS2ConceptSet = ko.observable(null); + this.compareCS2ConceptSetExpression = ko.pureComputed(() => { + if (this.currentConceptSet() && this.compareCS2Id() === this.currentConceptSet().id) { + return ko.toJS(this.selectedConcepts()); + } else { + return ko.toJS(this.compareCS2ConceptSet()); + } + }); + this.compareCS2TypeFile = ko.observable(null); + + // Vocabulary sources + this.vocabularySources = ko.computed(() => { + const vocabularySources = []; + sharedState.sources().forEach((source) => { + if (source.hasVocabulary && authApi.isPermittedAccessSource(source.sourceKey)) { + vocabularySources.push({ + sourceKey: source.sourceKey, + sourceName: source.sourceName, + version: source.version() || '' + }); + } }); - this.compareCS1TypeFile = ko.observable(null); - this.compareCS2Id = ko.observable(null); - this.compareCS2Caption = ko.observable(); - this.compareCS2ConceptSet = ko.observable(null); - this.compareCS2ConceptSetExpression = ko.pureComputed(() => { - if (this.currentConceptSet() && this.compareCS2Id() === this.currentConceptSet().id) { - return ko.toJS(this.selectedConcepts()); + return vocabularySources; + }); + + this.selectedVocabularyCS1 = ko.observable(null); + this.selectedVocabularyCS2 = ko.observable(null); + + this.selectedVocabularyCS1Display = ko.pureComputed(() => { + const selected = this.selectedVocabularyCS1(); + return selected ? `[${selected.sourceName}] ${selected.version}` : 'Select Vocabulary'; + }); + + this.selectedVocabularyCS2Display = ko.pureComputed(() => { + const selected = this.selectedVocabularyCS2(); + return selected ? `[${selected.sourceName}] ${selected.version}` : 'Select Vocabulary'; + }); + + // Initialize vocabulary selections + this.vocabularySources.subscribe((sources) => { + if (sources.length > 0 && !this.selectedVocabularyCS1() && !this.selectedVocabularyCS2()) { + const currentSourceKey = sharedState.sourceKeyOfVocabUrl(); + + if (currentSourceKey) { + const currentSource = sources.find(s => s.sourceKey === currentSourceKey); + + if (currentSource) { + this.selectedVocabularyCS1(currentSource); + this.selectedVocabularyCS2(currentSource); + } else { + this.selectedVocabularyCS1(sources[0]); + this.selectedVocabularyCS2(sources[0]); + } } else { - return ko.toJS(this.compareCS2ConceptSet()); + this.selectedVocabularyCS1(sources[0]); + this.selectedVocabularyCS2(sources[0]); } - }); + } + }); + + if (this.vocabularySources().length > 0) { + const sources = this.vocabularySources(); + const currentSourceKey = sharedState.sourceKeyOfVocabUrl(); + + if (currentSourceKey) { + const currentSource = sources.find(s => s.sourceKey === currentSourceKey); - this.compareCS2TypeFile = ko.observable(null); - this.compareResults = ko.observable(); - this.compareResultsSame = ko.observable(); + if (currentSource) { + this.selectedVocabularyCS1(currentSource); + this.selectedVocabularyCS2(currentSource); + } else { + this.selectedVocabularyCS1(sources[0]); + this.selectedVocabularyCS2(sources[0]); + } + } else { + this.selectedVocabularyCS1(sources[0]); + this.selectedVocabularyCS2(sources[0]); + } + } - this.outsideFilters = ko.observable(""); - this.lastSelectedMatchFilter = ko.observable(""); + this.comparisonTargets = ko.observable(null); - this.comparisonTargets = ko.observable(null); - this.compareError = ko.pureComputed(() => { + // Comparison state + this.compareError = ko.pureComputed(() => { return ( this.compareCS1Id() && this.compareCS2Id() && - (this.compareCS1Id() === this.compareCS2Id()) + this.selectedVocabularyCS1() && this.selectedVocabularyCS1().sourceKey && + this.selectedVocabularyCS2() && this.selectedVocabularyCS2().sourceKey && + ( + this.compareCS1Id() === this.compareCS2Id() && + this.selectedVocabularyCS1().sourceKey === this.selectedVocabularyCS2().sourceKey + ) ) - }); - this.compareReady = ko.pureComputed(() => { - // both are specified & not the same - const conceptSetsSpecifiedAndDifferent = ( - (this.compareCS1Id() && this.compareCS2Id()) && - (this.compareCS1Id() !== this.compareCS2Id()) + }); + + this.compareReady = ko.pureComputed(() => { + const conceptSetsSpecifiedAndDifferentVocabOrConceptSet = ( + this.compareCS1Id() && + this.compareCS2Id() && + this.selectedVocabularyCS1() && this.selectedVocabularyCS1().sourceKey && + this.selectedVocabularyCS2() && this.selectedVocabularyCS2().sourceKey && + ( + (this.compareCS1Id() !== this.compareCS2Id()) || + (this.selectedVocabularyCS1().sourceKey !== this.selectedVocabularyCS2().sourceKey) + ) ); - // Check to see if one of the concept sets is the one - // that is currently open. If so, check to see if it is - // "dirty" and if so, we are not ready to compare. let currentConceptSetClean = true; - if (conceptSetsSpecifiedAndDifferent && this.currentConceptSet()) { - // If we passed the check above, then we'll enforce this condition - // which also ensures that we have 2 valid concept sets specified + if (conceptSetsSpecifiedAndDifferentVocabOrConceptSet && this.currentConceptSet()) { if (this.compareCS1Id() === this.currentConceptSet().id || - this.compareCS2Id() === this.currentConceptSet().id) { - // One of the concept sets that is involved in the comparison - // is the one that is currently loaded; check to see if it is dirty + this.compareCS2Id() === this.currentConceptSet().id) { currentConceptSetClean = !this.currentConceptSetDirtyFlag().isDirty(); } } + return (conceptSetsSpecifiedAndDifferentVocabOrConceptSet && currentConceptSetClean); + }); - return (conceptSetsSpecifiedAndDifferent && currentConceptSetClean); - }); - this.compareUnchanged = ko.pureComputed(() => { - // both are specified & not the same - const conceptSetsSpecifiedAndDifferent = ( - (this.compareCS1Id() && this.compareCS2Id()) && - (this.compareCS1Id() !== this.compareCS2Id()) + this.compareFailed = ko.observable(false); + this.compareFailedMessage = ko.observable(''); + + this.compareUnchanged = ko.pureComputed(() => { + const conceptSetsSpecifiedAndDifferentVocabOrConceptSet = ( + this.compareCS1Id() && + this.compareCS2Id() && + this.selectedVocabularyCS1() && this.selectedVocabularyCS1().sourceKey && + this.selectedVocabularyCS2() && this.selectedVocabularyCS2().sourceKey && + ( + (this.compareCS1Id() !== this.compareCS2Id()) || + (this.selectedVocabularyCS1().sourceKey !== this.selectedVocabularyCS2().sourceKey) + ) ); - // Next, determine if one of the concept sets that was used to show - // results was changed. In that case, we do not want to show the - // current results let currentComparisonCriteriaUnchanged = true; - if (conceptSetsSpecifiedAndDifferent && this.comparisonTargets()) { - // Check to see if the comparison crtieria has changed + if (conceptSetsSpecifiedAndDifferentVocabOrConceptSet && this.comparisonTargets()) { currentComparisonCriteriaUnchanged = (ko.toJSON(this.comparisonTargets()) === ko.toJSON(this.getCompareTargets())); } - return (conceptSetsSpecifiedAndDifferent && currentComparisonCriteriaUnchanged); - }); - this.compareLoading = ko.observable(false); - this.compareLoadingClass = ko.pureComputed(() => { + return (conceptSetsSpecifiedAndDifferentVocabOrConceptSet && currentComparisonCriteriaUnchanged); + }); + + this.compareLoading = ko.observable(false); + this.compareLoadingClass = ko.pureComputed(() => { return this.compareLoading() ? "fa fa-circle-notch fa-spin fa-lg" : "fa fa-question-circle fa-lg" - }); - this.compareNewConceptSetName = ko.observable(this.currentConceptSet().name() + ko.i18n('cs.browser.compare.saveFromComparisonNameTail', ' - From Comparison')()); - this.compareResultsColumns = [{ - data: d => { - if (d.conceptIn1Only == 1) { - return ko.i18n('facets.match.only1', 'CS1 Only')(); - } else if (d.conceptIn2Only == 1) { - return ko.i18n('facets.match.only2', 'CS2 Only')(); - } else { - return ko.i18n('facets.match.both', 'Both')(); - } - }, - }, - { - render: (s,p,d) => { - return d.conceptId ? d.conceptId : - ` - - - `; - } - }, - { - data: d => d.conceptCode, - }, - { - render: (s,p,d) => { - const concept = { - CONCEPT_ID: d.conceptId, - CONCEPT_NAME: d.conceptName, - INVALID_REASON_CAPTION: d.invalidReason, - STANDARD_CONCEPT: d.standardConcept, - }; - const link = commonUtils.renderLink(s, p, concept); - return d.nameMismatch - ? ` ${link}` - : link; - } - }, - { - data: d => d.conceptClassId, - }, - { - render: (s, type, d) => type === "sort" ? +d.validStartDate : - MomentApi.formatDateTimeWithFormat(d.validStartDate, MomentApi.DATE_FORMAT), - visible: false - }, - { - render: (s, type, d) => type === "sort" ? +d.validEndDate : - MomentApi.formatDateTimeWithFormat(d.validEndDate, MomentApi.DATE_FORMAT), - visible: false - }, - { - data: d => d.recordCount, - }, - { - data: d => d.descendantRecordCount, - }, - { - data: d => d.personCount, - }, - { - data: d => d.descendantPersonCount, - }, - { - data: d => d.domainId, - }, - { - data: d => d.vocabularyId, - }, - ]; - - this.compareResultsOptions = { - ...commonUtils.getTableOptions('L'), - order: [ - [1, 'asc'], - [2, 'desc'] - ], - Facets: [{ - 'caption': ko.i18n('facets.caption.match', 'Match'), - 'binding': d => { - if (d.conceptIn1Only == 1) { - return ko.i18n('facets.match.only1', 'CS1 Only'); - } else if (d.conceptIn2Only == 1) { - return ko.i18n('facets.match.only2', 'CS2 Only'); - } else { - return ko.i18n('facets.match.both', 'Both'); - } - }, - }, - { - 'caption': ko.i18n('facets.caption.class', 'Class'), - 'binding': d => d.conceptClassId, - }, - { - 'caption': ko.i18n('facets.caption.domain', 'Domain'), - 'binding': d => d.domainId, - }, - { - 'caption': ko.i18n('facets.caption.vocabulary', 'Vocabulary'), - 'binding': d => d.vocabularyId, - }, - { - 'caption': ko.i18n('facets.caption.hasRecords', 'Has Records'), - 'binding': d => { - var val = d.recordCount; - if (val.replace) - val = parseInt(val.replace(/\,/g, '')); // Remove comma formatting and treat as int - if (val > 0) { - return 'true' - } else { - return 'false' - } - }, - }, - { - 'caption': ko.i18n('facets.caption.hasDescendantRecords', 'Has Descendant Records'), - 'binding': d => { - var val = d.descendantRecordCount; - if (val.replace) - val = parseInt(val.replace(/\,/g, '')); // Remove comma formatting and treat as int - if (val > 0) { - return 'true' - } else { - return 'false' - } - }, - }, - ] - }; - this.currentResultSource = ko.observable(); - this.resultSources = ko.computed(() => { + }); + + this.compareNewConceptSetName = ko.observable(this.currentConceptSet().name() + ko.i18n('cs.browser.compare.saveFromComparisonNameTail', ' - From Comparison')()); + this.conceptSetLoading = ko.observable(false); + + // Results data + this.currentResultSource = ko.observable(); + this.resultSources = ko.computed(() => { const resultSources = []; sharedState.sources().forEach((source) => { if (source.hasResults && authApi.isPermittedAccessSource(source.sourceKey)) { @@ -267,216 +226,279 @@ class ConceptsetCompare extends AutoBind(Component) { } } }) - return resultSources; - }); - this.recordCountsRefreshing = ko.observable(false); - this.recordCountClass = ko.pureComputed(() => { - return this.recordCountsRefreshing() ? "fa fa-circle-notch fa-spin fa-lg" : "fa fa-database fa-lg"; - }); - this.conceptSetLoading = ko.observable(false); - this.showDiagram = ko.observable(false); + }); + + this.allCompareResults = ko.observable(null); + this.includedConceptsResults = ko.pureComputed(() => (this.allCompareResults() || []).filter(r => !r.isSourceCode)); + this.sourceCodesResults = ko.pureComputed(() => (this.allCompareResults() || []).filter(r => r.isSourceCode)); + + this.selectedResultsTab = ko.observable(0); + this.resultsTabs = [ + { + title: ko.i18n('cs.manager.tabs.includedConcepts', 'Included Concepts'), + key: 'included-concepts', + componentName: 'compare-results-included-concepts', + componentParams: { + ...params, + results: this.includedConceptsResults, + compareNewConceptSetName: this.compareNewConceptSetName, + compareCS1Caption: this.compareCS1Caption, + compareCS2Caption: this.compareCS2Caption, + currentResultSource: this.currentResultSource, + resultSources: this.resultSources, + selectedVocabularyCS1: this.selectedVocabularyCS1, + selectedVocabularyCS2: this.selectedVocabularyCS2, + }, + }, + { + title: ko.i18n('cs.manager.tabs.includedSourceCodes', 'Included Source Codes'), + key: 'included-sourcecodes', + componentName: 'compare-results-included-sourcecodes', + componentParams: { + ...params, + results: this.sourceCodesResults, + compareNewConceptSetName: this.compareNewConceptSetName, + compareCS1Caption: this.compareCS1Caption, + compareCS2Caption: this.compareCS2Caption, + currentResultSource: this.currentResultSource, + resultSources: this.resultSources, + selectedVocabularyCS1: this.selectedVocabularyCS1, + selectedVocabularyCS2: this.selectedVocabularyCS2, + }, + } + ]; } chooseCS1() { - this.isModalShown(true); - this.targetId = this.compareCS1Id; - this.targetCaption = this.compareCS1Caption; - this.targetExpression = this.compareCS1ConceptSet; - this.targetTypeFile = this.compareCS1TypeFile; + this.isModalShown(true); + this.targetId = this.compareCS1Id; + this.targetCaption = this.compareCS1Caption; + this.targetExpression = this.compareCS1ConceptSet; + this.targetTypeFile = this.compareCS1TypeFile; } clearCS1() { - this.compareCS1Id(null); - this.compareCS1Caption(null); - this.compareCS1ConceptSet(null); - this.compareResults(null); - this.compareCS1TypeFile(null); + this.compareCS1Id(null); + this.compareCS1Caption(null); + this.compareCS1ConceptSet(null); + this.allCompareResults(null); + this.compareCS1TypeFile(null); } chooseCS2() { - this.isModalShown(true); - this.targetId = this.compareCS2Id; - this.targetCaption = this.compareCS2Caption; - this.targetExpression = this.compareCS2ConceptSet; - this.targetTypeFile = this.compareCS2TypeFile; - } + this.isModalShown(true); + this.targetId = this.compareCS2Id; + this.targetCaption = this.compareCS2Caption; + this.targetExpression = this.compareCS2ConceptSet; + this.targetTypeFile = this.compareCS2TypeFile; + } clearCS2() { - this.compareCS2Id(null); - this.compareCS2Caption(null); - this.compareCS2ConceptSet(null); - this.compareResults(null); - this.compareCS2TypeFile(null); + this.compareCS2Id(null); + this.compareCS2Caption(null); + this.compareCS2ConceptSet(null); + this.allCompareResults(null); + this.compareCS2TypeFile(null); } prepareDataAfterUploadFile(csvParse) { - return csvParse.map(item => { - const {concept_name, concept_code, vocabulary_id} = item; - return { - concept: {VOCABULARY_ID: vocabulary_id, CONCEPT_NAME: concept_name, CONCEPT_CODE: concept_code}, - includeDescendants: false, - includeMapped: false, - isExcluded: false - }; - }); + return csvParse.map(item => { + const { concept_name, concept_code, vocabulary_id } = item; + return { + concept: { VOCABULARY_ID: vocabulary_id, CONCEPT_NAME: concept_name, CONCEPT_CODE: concept_code }, + includeDescendants: false, + includeMapped: false, + isExcluded: false + }; + }); } async uploadCS1(e) { - const file = e.target.files[0]; - try { - const csvParse = await CsvUtils.csvToJson(file, Const.requiredHeader); - const data = this.prepareDataAfterUploadFile(csvParse); - this.compareCS1Caption(file.name); - this.compareCS1ConceptSet(data); - this.compareCS1Id(file.name); - this.compareCS1TypeFile(Const.expressionType.BRIEF); - } catch (e) { - throw new Error(e); - } finally { - e.target.value = ''; - } + const file = e.target.files[0]; + try { + const csvParse = await CsvUtils.csvToJson(file, Const.requiredHeader); + const data = this.prepareDataAfterUploadFile(csvParse); + this.compareCS1Caption(file.name); + this.compareCS1ConceptSet(data); + this.compareCS1Id(file.name); + this.compareCS1TypeFile(Const.expressionType.BRIEF); + } catch (e) { + throw new Error(e); + } finally { + e.target.value = ''; + } } async uploadCS2(e) { - const file = e.target.files[0]; - try { - const csvParse = await CsvUtils.csvToJson(file, Const.requiredHeader); - const data = this.prepareDataAfterUploadFile(csvParse); - this.compareCS2Caption(file.name); - this.compareCS2ConceptSet(data); - this.compareCS2Id(file.name); - this.compareCS2TypeFile(Const.expressionType.BRIEF); - } catch (e) { - throw new Error(e); - } finally { - e.target.value = ''; - } + const file = e.target.files[0]; + try { + const csvParse = await CsvUtils.csvToJson(file, Const.requiredHeader); + const data = this.prepareDataAfterUploadFile(csvParse); + this.compareCS2Caption(file.name); + this.compareCS2ConceptSet(data); + this.compareCS2Id(file.name); + this.compareCS2TypeFile(Const.expressionType.BRIEF); + } catch (e) { + throw new Error(e); + } finally { + e.target.value = ''; + } } getCompareTargets() { return [{ - items: this.compareCS1ConceptSetExpression() - }, { - items: this.compareCS2ConceptSetExpression() - }]; + items: this.compareCS1ConceptSetExpression() + }, { + items: this.compareCS2ConceptSetExpression() + }]; } compareConceptSets() { - this.compareLoading(true); - const compareTargets = this.getCompareTargets(); - const csTypes = [this.compareCS1TypeFile(), this.compareCS2TypeFile()]; - const apiMethod = csTypes[0] === Const.expressionType.BRIEF || csTypes[1] === Const.expressionType.BRIEF - ? vocabularyProvider.compareConceptSetCsv(compareTargets, csTypes) - : vocabularyProvider.compareConceptSet(compareTargets); - - apiMethod.then((compareResults) => { - const conceptIds = compareResults.map((o, n) => { - return o.conceptId; - }).filter((id) => id !== null); - const sameConcepts = compareResults.find(concept => concept.conceptIn1And2 === 0); - cdmResultsAPI.getConceptRecordCount(this.currentResultSource().sourceKey, conceptIds, compareResults).then(() => { - this.compareResults(compareResults); - this.compareResultsSame(sameConcepts); - this.comparisonTargets(compareTargets); // Stash the currently selected concept sets so we can use this to determine when to show/hide results - this.compareLoading(false); - }); - }); + this.compareLoading(true); + this.compareFailed(false); + this.allCompareResults(null); + + const source1Key = this.selectedVocabularyCS1().sourceKey; + const source2Key = this.selectedVocabularyCS2().sourceKey; + const expression1 = { items: this.compareCS1ConceptSetExpression() }; + const expression2 = { items: this.compareCS2ConceptSetExpression() }; + + const csTypes = [this.compareCS1TypeFile(), this.compareCS2TypeFile()]; + const hasCsvFile = csTypes[0] === Const.expressionType.BRIEF || csTypes[1] === Const.expressionType.BRIEF; + + let apiMethod; + + try { + if (hasCsvFile) { + // Use CSV comparison for arbitrary diff vocab + apiMethod = vocabularyProvider.compareConceptSetsCsvOverDiffVocabularies( + source1Key, + source2Key, + expression1, + expression2, + csTypes[0], + csTypes[1] + ); + } else { + // Use regular comparison over diff vocabularies + apiMethod = vocabularyProvider.compareConceptSetsOverDiffVocabularies( + source1Key, + source2Key, + expression1, + expression2, + true // compareSourceCodes + ); + } + + Promise.resolve(apiMethod) + .then((response) => { + // Extract comparisons array from the response object + let resolvedCompareResults; + + if (response && response.comparisons) { + // New response format with nested object + resolvedCompareResults = response.comparisons; + + // Optionally log the counts for debugging + console.log('Comparison counts:', { + cs1Concepts: response.cs1IncludedConceptsCount, + cs1SourceCodes: response.cs1IncludedSourceCodesCount, + cs2Concepts: response.cs2IncludedConceptsCount, + cs2SourceCodes: response.cs2IncludedSourceCodesCount + }); + } else if (Array.isArray(response)) { + // Old response format (direct array) - backward compatibility + resolvedCompareResults = response; + } else { + console.error('Unexpected response format:', response); + this.allCompareResults(null); + this.compareFailed(true); + this.compareFailedMessage('Comparison failed: Invalid response format received.'); + this.compareLoading(false); + return; + } + + if (!Array.isArray(resolvedCompareResults)) { + console.error('Compare results is not an array:', resolvedCompareResults); + this.allCompareResults(null); + this.compareFailed(true); + this.compareFailedMessage('Comparison failed: Unable to resolve source or invalid response received.'); + this.compareLoading(false); + return; + } + + this.allCompareResults(resolvedCompareResults); + this.comparisonTargets(this.getCompareTargets()); + this.compareLoading(false); + this.compareFailed(false); + }) + .catch((error) => { + console.error('Error comparing concept sets:', error); + this.allCompareResults(null); + this.compareFailed(true); + this.compareFailedMessage('Comparison failed: ' + error.message); + this.compareLoading(false); + }); + } catch (error) { + console.error('Error setting up comparison:', error); + this.compareLoading(false); + } } compareCreateNewConceptSet() { - const dtItems = $('#compareResults table') - .DataTable() - .data() - .toArray(); - const conceptSetItems = dtItems.map(item => ({ - concept: { - CONCEPT_CLASS_ID: item.conceptClassId, - CONCEPT_CODE: item.conceptCode, - CONCEPT_ID: item.conceptId, - CONCEPT_NAME: item.conceptName, - DOMAIN_ID: item.domainId, - INVALID_REASON: null, - INVALID_REASON_CAPTION: null, - STANDARD_CONCEPT: null, - STANDARD_CONCEPT_CAPTION: null, - VOCABULARY_ID: null, - } - })); + const dtItems = $('#compareResultsIncludedConcepts table') + .DataTable() + .data() + .toArray(); + const conceptSetItems = dtItems.map(item => { + const conceptName = item.vocab1ConceptName || item.vocab2ConceptName; - const conceptSet = new ConceptSet({ - id: 0, - name: this.compareNewConceptSetName(), - expression: { - items: conceptSetItems - } - }); - this.saveConceptSetFn(conceptSet, "#txtNewConceptSetName"); - this.saveConceptSetShow(false); - } + return { + concept: { + CONCEPT_CLASS_ID: item.conceptClassId, + CONCEPT_CODE: item.conceptCode, + CONCEPT_ID: item.conceptId, + CONCEPT_NAME: conceptName, + DOMAIN_ID: item.domainId, + INVALID_REASON: null, + INVALID_REASON_CAPTION: null, + STANDARD_CONCEPT: null, + STANDARD_CONCEPT_CAPTION: null, + VOCABULARY_ID: null, + } + }; + }); - async conceptsetSelected(d) { - this.isModalShown(false); - this.conceptSetLoading(true); - try { - const csExpression = await vocabularyProvider.getConceptSetExpression(d.id); - this.targetId(d.id); - this.targetCaption(d.name); - this.targetExpression(csExpression.items); - this.targetTypeFile(Const.expressionType.FULL); - } finally { - this.conceptSetLoading(false); + const conceptSet = new ConceptSet({ + id: 0, + name: this.compareNewConceptSetName(), + expression: { + items: conceptSetItems } + }); + this.saveConceptSetFn(conceptSet, "#txtNewConceptSetName"); + this.saveConceptSetShow(false); } - showSaveNewModal() { - this.saveConceptSetShow(true); - } - - refreshRecordCounts(obj, event) { - if (event.originalEvent) { - // User changed event - this.recordCountsRefreshing(true); - $("#dtConeptManagerRC") - .removeClass("fa-database") - .addClass("fa-circle-notch") - .addClass("fa-spin"); - $("#dtConeptManagerDRC") - .removeClass("fa-database") - .addClass("fa-circle-notch") - .addClass("fa-spin"); - var compareResults = this.compareResults(); - var conceptIds = $.map(compareResults, function (o, n) { - return o.conceptId; - }); - cdmResultsAPI.getConceptRecordCount(this.currentResultSource().sourceKey, conceptIds, compareResults) - .then((rowcounts) => { - this.compareResults(compareResults); - this.recordCountsRefreshing(false); - $("#dtConeptManagerRC") - .addClass("fa-database") - .removeClass("fa-circle-notch") - .removeClass("fa-spin"); - $("#dtConeptManagerDRC") - .addClass("fa-database") - .removeClass("fa-circle-notch") - .removeClass("fa-spin"); - }); - } - } - - toggleShowDiagram() { - this.showDiagram(!this.showDiagram()); - } - - updateOutsideFilters(key) { - this.outsideFilters(key); + async conceptsetSelected(d) { + this.isModalShown(false); + this.conceptSetLoading(true); + try { + const csExpression = await vocabularyProvider.getConceptSetExpression(d.id); + this.targetId(d.id); + this.targetCaption(d.name); + this.targetExpression(csExpression.items); + this.targetTypeFile(Const.expressionType.FULL); + } finally { + this.conceptSetLoading(false); + } } - updateLastSelectedMatchFilter(key) { - this.lastSelectedMatchFilter(key) + showSaveNewModal() { + this.saveConceptSetShow(true); } -} + } - return commonUtils.build('conceptset-compare', ConceptsetCompare, view); + return commonUtils.build('conceptset-compare', ConceptsetCompare, view); }); \ No newline at end of file diff --git a/js/pages/concept-sets/components/tabs/conceptset-compare.less b/js/pages/concept-sets/components/tabs/conceptset-compare.less index 5180256a5..cb1abbcc9 100644 --- a/js/pages/concept-sets/components/tabs/conceptset-compare.less +++ b/js/pages/concept-sets/components/tabs/conceptset-compare.less @@ -1,5 +1,5 @@ .panel-default, .panel-primary { - margin: 10px 0; + margin: 10px 0; .panel-heading { line-height: 30px; font-size: 14px; @@ -58,4 +58,66 @@ input[type=file] { &.name-mismatch { color: #dd9900; } + &.field-mismatch { + color: #dd9900; + } +} + +// Styling for multi-line vocabulary concept names +.vocab-concept-names { + display: inline-block; + + .vocab-name-row { + line-height: 1.4; + padding: 2px 0; + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } + } +} + +// Styling for multi-line vocabulary field values (generic for all fields) +.vocab-field-values { + display: inline-block; + + .vocab-field-row { + line-height: 1.4; + padding: 2px 0; + white-space: nowrap; + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } + + strong { + font-weight: 600; + margin-right: 4px; + } + } +} + +// Styling for multi-line vocabulary sources +.vocab-source-column { + .vocab-source-row { + line-height: 1.4; + padding: 2px 0; + white-space: nowrap; + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } + } } \ No newline at end of file diff --git a/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-concepts.html b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-concepts.html new file mode 100644 index 000000000..7b221734d --- /dev/null +++ b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-concepts.html @@ -0,0 +1,63 @@ +
+
+ +
+
+ + + +
+
+ +
+
+ +
+
+ +
+
+ + + + + +
+
+ +
+
+ +
+ +
\ No newline at end of file diff --git a/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-concepts.js b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-concepts.js new file mode 100644 index 000000000..e1db233da --- /dev/null +++ b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-concepts.js @@ -0,0 +1,382 @@ +define([ + 'knockout', + 'text!./compare-results-included-concepts.html', + 'services/AuthAPI', + 'services/CDMResultsAPI', + 'components/Component', + 'utils/AutoBind', + 'utils/CommonUtils', + 'services/MomentAPI', + 'jquery', + 'atlas-state', + 'components/charts/venn', + 'less!./compare-results-included-concepts.less' +], function ( + ko, + view, + authApi, + cdmResultsAPI, + Component, + AutoBind, + commonUtils, + MomentApi, + $, + sharedState +) { + class CompareResultsIncludedConcepts extends AutoBind(Component) { + constructor(params) { + super(params); + this.compareResults = params.results; + this.compareResultsSame = ko.pureComputed(() => { + const results = this.compareResults(); + if (!results || results.length === 0) { + return true; + } + return results.find(concept => concept.conceptInCS1AndCS2 === 0); + }); + + this.saveConceptSetFn = params.saveConceptSetFn; + this.saveConceptSetShow = params.saveConceptSetShow; + this.compareNewConceptSetName = params.compareNewConceptSetName; + this.compareCS1Caption = params.compareCS1Caption; + this.compareCS2Caption = params.compareCS2Caption; + this.currentResultSource = params.currentResultSource; + this.resultSources = params.resultSources; + this.selectedVocabularyCS1 = params.selectedVocabularyCS1; + this.selectedVocabularyCS2 = params.selectedVocabularyCS2; + + this.outsideFilters = ko.observable(""); + this.lastSelectedMatchFilter = ko.observable(""); + this.showDiagram = ko.observable(false); + this.recordCountsRefreshing = ko.observable(false); + this.recordCountClass = ko.pureComputed(() => { + return this.recordCountsRefreshing() ? "fa fa-circle-notch fa-spin fa-lg" : "fa fa-database fa-lg"; + }); + + this.getMatchType = this.getMatchType.bind(this); + this.getMatchDisplayText = this.getMatchDisplayText.bind(this); + + this.compareResultsColumns = this.getColumns(); + this.compareResultsOptions = this.getOptions(); + + this.hasMultipleSets = ko.pureComputed(() => { + const results = this.compareResults(); + if (!results || results.length === 0) { + return false; + } + + let hasCS1Only = false; + let hasCS2Only = false; + let hasBoth = false; + + results.forEach(c => { + if (c.conceptInCS1Only === 1) hasCS1Only = true; + if (c.conceptInCS2Only === 1) hasCS2Only = true; + if (c.conceptInCS1AndCS2 === 1) hasBoth = true; + }); + + const categoriesCount = (hasCS1Only ? 1 : 0) + (hasCS2Only ? 1 : 0) + (hasBoth ? 1 : 0); + return categoriesCount >= 2; + }); + } + + getColumns() { + return [ + { + title: ko.i18n('columns.match', 'Match'), + data: d => { + const matchType = this.getMatchType(d); + return this.getMatchDisplayText(matchType); + }, + }, + { + title: ko.i18n('columns.vocabularyBundle', 'Vocabulary Bundle'), + render: (s, p, d) => { + // Only show if there are any mismatches + const hasMismatches = + d.nameMismatch || + d.conceptCodeMismatch || + d.domainIdMismatch || + d.vocabularyIdMismatch || + d.conceptClassIdMismatch || + d.validStartDateMismatch || + d.validEndDateMismatch || + d.standardConceptMismatch || + d.invalidReasonMismatch; + + if (!hasMismatches) { + return ''; + } + + const vocab1Label = this.selectedVocabularyCS1() + ? `[${this.selectedVocabularyCS1().sourceName}] ${this.selectedVocabularyCS1().version}` + : 'Vocab 1'; + const vocab2Label = this.selectedVocabularyCS2() + ? `[${this.selectedVocabularyCS2().sourceName}] ${this.selectedVocabularyCS2().version}` + : 'Vocab 2'; + + let html = '
'; + html += `
${this.escapeHtml(vocab1Label)}
`; + html += `
${this.escapeHtml(vocab2Label)}
`; + html += '
'; + return html; + }, + visible: true + }, + { + title: ko.i18n('columns.id', 'Id'), + render: (s, p, d) => { + return d.conceptId ? d.conceptId : + ` + + ${ko.i18n('cs.browser.compare.idNotFound', 'Not found')()} + `; + } + }, + { + title: ko.i18n('columns.code', 'Code'), + render: (s, p, d) => { + if (d.vocab1ConceptCode !== null || d.vocab2ConceptCode !== null) { + return this.renderFieldComparison(d.vocab1ConceptCode, d.vocab2ConceptCode, d.conceptCodeMismatch); + } else { + return this.escapeHtml(d.conceptCode || ''); + } + }, + }, + { + title: ko.i18n('columns.name', 'Name'), + render: (s, p, d) => { + const buildConceptForLink = (conceptName) => ({ + CONCEPT_ID: d.conceptId, + CONCEPT_NAME: conceptName, + INVALID_REASON_CAPTION: d.invalidReason, + STANDARD_CONCEPT: d.standardConcept, + }); + + const isCrossVocab = d.vocab1ConceptName !== null && d.vocab2ConceptName !== null; + + if (isCrossVocab) { + if (d.nameMismatch) { + const link1 = commonUtils.renderLink(d.vocab1ConceptName, p, buildConceptForLink(d.vocab1ConceptName)); + const link2 = commonUtils.renderLink(d.vocab2ConceptName, p, buildConceptForLink(d.vocab2ConceptName)); + + let html = '
'; + html += `
${link1}
`; + html += `
${link2}
`; + html += '
'; + return html; + } else { + return commonUtils.renderLink(d.vocab1ConceptName, p, buildConceptForLink(d.vocab1ConceptName)); + } + } else { + const conceptName = d.vocab1ConceptName || d.vocab2ConceptName || d.conceptName; + if (!conceptName) return ''; + return commonUtils.renderLink(conceptName, p, buildConceptForLink(conceptName)); + } + }, + }, + { + title: ko.i18n('columns.class', 'Class'), + render: (s, p, d) => this.renderFieldComparison(d.vocab1ConceptClassId, d.vocab2ConceptClassId, d.conceptClassIdMismatch, d.conceptClassId), + }, + { + title: ko.i18n('columns.validStartDate', 'Valid Start Date'), + render: (s, type, d) => this.renderDateFieldComparison(d.vocab1ValidStartDate, d.vocab2ValidStartDate, d.validStartDateMismatch, d.validStartDate, type), + visible: false + }, + { + title: ko.i18n('columns.validEndDate', 'Valid End Date'), + render: (s, type, d) => this.renderDateFieldComparison(d.vocab1ValidEndDate, d.vocab2ValidEndDate, d.validEndDateMismatch, d.validEndDate, type), + visible: false + }, + { + title: ` ${ko.i18n('columns.rc', 'RC')()}`, + data: d => d.recordCount, + }, + { + title: ` ${ko.i18n('columns.drc', 'DRC')()}`, + data: d => d.descendantRecordCount, + }, + { + title: ` ${ko.i18n('columns.pc', 'PC')()}`, + data: d => d.personCount, + }, + { + title: ` ${ko.i18n('columns.dpc', 'DPC')()}`, + data: d => d.descendantPersonCount, + }, + { + title: ko.i18n('columns.domain', 'Domain'), + render: (s, p, d) => this.renderFieldComparison(d.vocab1DomainId, d.vocab2DomainId, d.domainIdMismatch, d.domainId), + }, + { + title: ko.i18n('columns.vocabulary', 'Vocabulary'), + render: (s, p, d) => this.renderFieldComparison(d.vocab1VocabularyId, d.vocab2VocabularyId, d.vocabularyIdMismatch, d.vocabularyId), + }, + { + title: ko.i18n('columns.standardConcept', 'Standard Concept'), + render: (s, p, d) => this.renderFieldComparison(d.vocab1StandardConcept, d.vocab2StandardConcept, d.standardConceptMismatch, d.standardConcept), + visible: false + }, + { + title: ko.i18n('columns.invalidReason', 'Invalid Reason'), + render: (s, p, d) => this.renderFieldComparison(d.vocab1InvalidReason, d.vocab2InvalidReason, d.invalidReasonMismatch, d.invalidReason), + visible: false + }, + ]; + } + + getOptions() { + return { + ...commonUtils.getTableOptions('L'), + order: [[1, 'asc'], [2, 'desc']], + Facets: [ + { 'caption': ko.i18n('facets.caption.match', 'Match'), 'binding': d => this.getMatchDisplayText(this.getMatchType(d)) }, + { 'caption': ko.i18n('facets.caption.class', 'Class'), 'binding': d => d.conceptClassId }, + { 'caption': ko.i18n('facets.caption.domain', 'Domain'), 'binding': d => d.domainId }, + { 'caption': ko.i18n('facets.caption.vocabulary', 'Vocabulary'), 'binding': d => d.vocabularyId }, + { 'caption': ko.i18n('facets.caption.hasRecords', 'Has Records'), 'binding': d => (parseInt((d.recordCount || '-1').replace(/,/g, '')) > 0) ? 'true' : 'false' }, + { 'caption': ko.i18n('facets.caption.hasDescendantRecords', 'Has Descendant Records'), 'binding': d => (parseInt((d.descendantRecordCount || '-1').replace(/,/g, '')) > 0) ? 'true' : 'false' }, + ] + }; + } + + getMatchType(d) { + const inBothCS = d.conceptInCS1AndCS2 === 1; + + if (inBothCS) { + // Check for any mismatches + const hasMismatches = + d.nameMismatch || + d.conceptCodeMismatch || + d.domainIdMismatch || + d.vocabularyIdMismatch || + d.conceptClassIdMismatch || + d.validStartDateMismatch || + d.validEndDateMismatch || + d.standardConceptMismatch || + d.invalidReasonMismatch; + + if (hasMismatches) { + return 'BOTH_WITH_DIFF'; + } else { + return 'BOTH_SAME'; + } + } else if (d.conceptInCS1Only === 1) { + return 'CS1_ONLY'; + } else if (d.conceptInCS2Only === 1) { + return 'CS2_ONLY'; + } + + return 'UNKNOWN'; + } + + getMatchDisplayText(matchType) { + switch(matchType) { + case 'BOTH_SAME': + return ko.i18n('facets.match.bothSame', 'Both (Same)')(); + case 'BOTH_WITH_DIFF': + return ko.i18n('facets.match.bothWithDiff', 'Both (Has Diffs)')(); + case 'CS1_ONLY': + return ko.i18n('facets.match.only1', 'CS1 Only')(); + case 'CS2_ONLY': + return ko.i18n('facets.match.only2', 'CS2 Only')(); + default: + return ko.i18n('facets.match.unknown', 'Unknown')(); + } + } + + showSaveNewModal() { + this.saveConceptSetShow(true); + } + + refreshRecordCounts(obj, event) { + if (event.originalEvent) { + this.recordCountsRefreshing(true); + const compareResults = this.compareResults(); + const conceptIds = compareResults.map(o => o.conceptId).filter(id => id != null); + cdmResultsAPI.getConceptRecordCount(this.currentResultSource().sourceKey, conceptIds, compareResults) + .then(() => { + this.compareResults(compareResults); + }) + .finally(() => { + this.recordCountsRefreshing(false); + }); + } + } + + toggleShowDiagram() { + this.showDiagram(!this.showDiagram()); + } + + updateOutsideFilters(key) { + this.outsideFilters(key); + } + + updateLastSelectedMatchFilter(key) { + this.lastSelectedMatchFilter(key); + } + + renderFieldComparison(value1, value2, hasMismatch, fallbackValue) { + const isCrossVocab = value1 !== null && value2 !== null; + + if (isCrossVocab) { + if (hasMismatch) { + let html = '
'; + html += `
${this.escapeHtml(value1 || '')}
`; + html += `
${this.escapeHtml(value2 || '')}
`; + html += '
'; + return html; + } else { + return this.escapeHtml(value1 || value2 || ''); + } + } else { + return this.escapeHtml(fallbackValue || ''); + } + } + + renderDateFieldComparison(date1, date2, hasMismatch, fallbackDate, type) { + if (type === "sort") { + return fallbackDate ? +fallbackDate : 0; + } + + const formatDate = (date) => { + return date ? MomentApi.formatDateTimeWithFormat(date, MomentApi.DATE_FORMAT) : ''; + }; + + const formattedDate1 = formatDate(date1); + const formattedDate2 = formatDate(date2); + const formattedFallbackDate = formatDate(fallbackDate); + + const isCrossVocab = date1 !== null && date2 !== null; + + if (isCrossVocab) { + if (hasMismatch) { + let html = '
'; + html += `
${formattedDate1}
`; + html += `
${formattedDate2}
`; + html += '
'; + return html; + } else { + return formattedDate1 || formattedDate2; + } + } else { + return formattedFallbackDate; + } + } + + escapeHtml(text) { + if (!text) return ''; + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, m => map[m]); + } + } + + return commonUtils.build('compare-results-included-concepts', CompareResultsIncludedConcepts, view); +}); \ No newline at end of file diff --git a/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-concepts.less b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-concepts.less new file mode 100644 index 000000000..5a75c41bc --- /dev/null +++ b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-concepts.less @@ -0,0 +1,44 @@ +.id-not-found { + color: #ff9900; +} +.name-mismatch, .field-mismatch { + color: #ff9900; + margin-right: 4px; +} +.vocab-concept-names, .vocab-field-values { + font-size: 0.9em; + + .vocab-name-row, .vocab-field-row { + line-height: 1.6; + padding: 2px 0; + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } + + i.field-mismatch { + vertical-align: middle; + } + } +} + +.vocab-source-column { + .vocab-source-row { + line-height: 1.6; + padding: 2px 0; + white-space: nowrap; + font-size: 0.85em; + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } + } +} \ No newline at end of file diff --git a/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-sourcecodes.html b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-sourcecodes.html new file mode 100644 index 000000000..ecd8d30dd --- /dev/null +++ b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-sourcecodes.html @@ -0,0 +1,63 @@ +
+
+ +
+
+ + + +
+
+ +
+
+ +
+
+ +
+
+ + + + + +
+
+ +
+
+ +
+ +
\ No newline at end of file diff --git a/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-sourcecodes.js b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-sourcecodes.js new file mode 100644 index 000000000..f1e2ae4b3 --- /dev/null +++ b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-sourcecodes.js @@ -0,0 +1,382 @@ +define([ + 'knockout', + 'text!./compare-results-included-sourcecodes.html', + 'services/AuthAPI', + 'services/CDMResultsAPI', + 'components/Component', + 'utils/AutoBind', + 'utils/CommonUtils', + 'services/MomentAPI', + 'jquery', + 'atlas-state', + 'components/charts/venn', + 'less!./compare-results-included-sourcecodes.less' +], function ( + ko, + view, + authApi, + cdmResultsAPI, + Component, + AutoBind, + commonUtils, + MomentApi, + $, + sharedState +) { + class CompareResultsIncludedSourceCodes extends AutoBind(Component) { + constructor(params) { + super(params); + this.compareResults = params.results; + this.compareResultsSame = ko.pureComputed(() => { + const results = this.compareResults(); + if (!results || results.length === 0) { + return true; + } + return results.find(concept => concept.conceptInCS1AndCS2 === 0); + }); + + this.saveConceptSetFn = params.saveConceptSetFn; + this.saveConceptSetShow = params.saveConceptSetShow; + this.compareNewConceptSetName = params.compareNewConceptSetName; + this.compareCS1Caption = params.compareCS1Caption; + this.compareCS2Caption = params.compareCS2Caption; + this.currentResultSource = params.currentResultSource; + this.resultSources = params.resultSources; + this.selectedVocabularyCS1 = params.selectedVocabularyCS1; + this.selectedVocabularyCS2 = params.selectedVocabularyCS2; + + this.outsideFilters = ko.observable(""); + this.lastSelectedMatchFilter = ko.observable(""); + this.showDiagram = ko.observable(false); + this.recordCountsRefreshing = ko.observable(false); + this.recordCountClass = ko.pureComputed(() => { + return this.recordCountsRefreshing() ? "fa fa-circle-notch fa-spin fa-lg" : "fa fa-database fa-lg"; + }); + + this.getMatchType = this.getMatchType.bind(this); + this.getMatchDisplayText = this.getMatchDisplayText.bind(this); + + this.compareResultsColumns = this.getColumns(); + this.compareResultsOptions = this.getOptions(); + + this.hasMultipleSets = ko.pureComputed(() => { + const results = this.compareResults(); + if (!results || results.length === 0) { + return false; + } + + let hasCS1Only = false; + let hasCS2Only = false; + let hasBoth = false; + + results.forEach(c => { + if (c.conceptInCS1Only === 1) hasCS1Only = true; + if (c.conceptInCS2Only === 1) hasCS2Only = true; + if (c.conceptInCS1AndCS2 === 1) hasBoth = true; + }); + + const categoriesCount = (hasCS1Only ? 1 : 0) + (hasCS2Only ? 1 : 0) + (hasBoth ? 1 : 0); + return categoriesCount >= 2; + }); + } + + getColumns() { + return [ + { + title: ko.i18n('columns.match', 'Match'), + data: d => { + const matchType = this.getMatchType(d); + return this.getMatchDisplayText(matchType); + }, + }, + { + title: ko.i18n('columns.vocabularyBundle', 'Vocabulary Bundle'), + render: (s, p, d) => { + // Only show if there are any mismatches + const hasMismatches = + d.nameMismatch || + d.conceptCodeMismatch || + d.domainIdMismatch || + d.vocabularyIdMismatch || + d.conceptClassIdMismatch || + d.validStartDateMismatch || + d.validEndDateMismatch || + d.standardConceptMismatch || + d.invalidReasonMismatch; + + if (!hasMismatches) { + return ''; + } + + const vocab1Label = this.selectedVocabularyCS1() + ? `[${this.selectedVocabularyCS1().sourceName}] ${this.selectedVocabularyCS1().version}` + : 'Vocab 1'; + const vocab2Label = this.selectedVocabularyCS2() + ? `[${this.selectedVocabularyCS2().sourceName}] ${this.selectedVocabularyCS2().version}` + : 'Vocab 2'; + + let html = '
'; + html += `
${this.escapeHtml(vocab1Label)}
`; + html += `
${this.escapeHtml(vocab2Label)}
`; + html += '
'; + return html; + }, + visible: true + }, + { + title: ko.i18n('columns.id', 'Id'), + render: (s, p, d) => { + return d.conceptId ? d.conceptId : + ` + + ${ko.i18n('cs.browser.compare.idNotFound', 'Not found')()} + `; + } + }, + { + title: ko.i18n('columns.code', 'Code'), + render: (s, p, d) => { + if (d.vocab1ConceptCode !== null || d.vocab2ConceptCode !== null) { + return this.renderFieldComparison(d.vocab1ConceptCode, d.vocab2ConceptCode, d.conceptCodeMismatch); + } else { + return this.escapeHtml(d.conceptCode || ''); + } + }, + }, + { + title: ko.i18n('columns.name', 'Name'), + render: (s, p, d) => { + const buildConceptForLink = (conceptName) => ({ + CONCEPT_ID: d.conceptId, + CONCEPT_NAME: conceptName, + INVALID_REASON_CAPTION: d.invalidReason, + STANDARD_CONCEPT: d.standardConcept, + }); + + const isCrossVocab = d.vocab1ConceptName !== null && d.vocab2ConceptName !== null; + + if (isCrossVocab) { + if (d.nameMismatch) { + const link1 = commonUtils.renderLink(d.vocab1ConceptName, p, buildConceptForLink(d.vocab1ConceptName)); + const link2 = commonUtils.renderLink(d.vocab2ConceptName, p, buildConceptForLink(d.vocab2ConceptName)); + + let html = '
'; + html += `
${link1}
`; + html += `
${link2}
`; + html += '
'; + return html; + } else { + return commonUtils.renderLink(d.vocab1ConceptName, p, buildConceptForLink(d.vocab1ConceptName)); + } + } else { + const conceptName = d.vocab1ConceptName || d.vocab2ConceptName || d.conceptName; + if (!conceptName) return ''; + return commonUtils.renderLink(conceptName, p, buildConceptForLink(conceptName)); + } + }, + }, + { + title: ko.i18n('columns.class', 'Class'), + render: (s, p, d) => this.renderFieldComparison(d.vocab1ConceptClassId, d.vocab2ConceptClassId, d.conceptClassIdMismatch, d.conceptClassId), + }, + { + title: ko.i18n('columns.validStartDate', 'Valid Start Date'), + render: (s, type, d) => this.renderDateFieldComparison(d.vocab1ValidStartDate, d.vocab2ValidStartDate, d.validStartDateMismatch, d.validStartDate, type), + visible: false + }, + { + title: ko.i18n('columns.validEndDate', 'Valid End Date'), + render: (s, type, d) => this.renderDateFieldComparison(d.vocab1ValidEndDate, d.vocab2ValidEndDate, d.validEndDateMismatch, d.validEndDate, type), + visible: false + }, + { + title: ` ${ko.i18n('columns.rc', 'RC')()}`, + data: d => d.recordCount, + }, + { + title: ` ${ko.i18n('columns.drc', 'DRC')()}`, + data: d => d.descendantRecordCount, + }, + { + title: ` ${ko.i18n('columns.pc', 'PC')()}`, + data: d => d.personCount, + }, + { + title: ` ${ko.i18n('columns.dpc', 'DPC')()}`, + data: d => d.descendantPersonCount, + }, + { + title: ko.i18n('columns.domain', 'Domain'), + render: (s, p, d) => this.renderFieldComparison(d.vocab1DomainId, d.vocab2DomainId, d.domainIdMismatch, d.domainId), + }, + { + title: ko.i18n('columns.vocabulary', 'Vocabulary'), + render: (s, p, d) => this.renderFieldComparison(d.vocab1VocabularyId, d.vocab2VocabularyId, d.vocabularyIdMismatch, d.vocabularyId), + }, + { + title: ko.i18n('columns.standardConcept', 'Standard Concept'), + render: (s, p, d) => this.renderFieldComparison(d.vocab1StandardConcept, d.vocab2StandardConcept, d.standardConceptMismatch, d.standardConcept), + visible: false + }, + { + title: ko.i18n('columns.invalidReason', 'Invalid Reason'), + render: (s, p, d) => this.renderFieldComparison(d.vocab1InvalidReason, d.vocab2InvalidReason, d.invalidReasonMismatch, d.invalidReason), + visible: false + }, + ]; + } + + getOptions() { + return { + ...commonUtils.getTableOptions('L'), + order: [[1, 'asc'], [2, 'desc']], + Facets: [ + { 'caption': ko.i18n('facets.caption.match', 'Match'), 'binding': d => this.getMatchDisplayText(this.getMatchType(d)) }, + { 'caption': ko.i18n('facets.caption.class', 'Class'), 'binding': d => d.conceptClassId }, + { 'caption': ko.i18n('facets.caption.domain', 'Domain'), 'binding': d => d.domainId }, + { 'caption': ko.i18n('facets.caption.vocabulary', 'Vocabulary'), 'binding': d => d.vocabularyId }, + { 'caption': ko.i18n('facets.caption.hasRecords', 'Has Records'), 'binding': d => (parseInt((d.recordCount || '-1').replace(/,/g, '')) > 0) ? 'true' : 'false' }, + { 'caption': ko.i18n('facets.caption.hasDescendantRecords', 'Has Descendant Records'), 'binding': d => (parseInt((d.descendantRecordCount || '-1').replace(/,/g, '')) > 0) ? 'true' : 'false' }, + ] + }; + } + + getMatchType(d) { + const inBothCS = d.conceptInCS1AndCS2 === 1; + + if (inBothCS) { + // Check for any mismatches + const hasMismatches = + d.nameMismatch || + d.conceptCodeMismatch || + d.domainIdMismatch || + d.vocabularyIdMismatch || + d.conceptClassIdMismatch || + d.validStartDateMismatch || + d.validEndDateMismatch || + d.standardConceptMismatch || + d.invalidReasonMismatch; + + if (hasMismatches) { + return 'BOTH_WITH_DIFF'; + } else { + return 'BOTH_SAME'; + } + } else if (d.conceptInCS1Only === 1) { + return 'CS1_ONLY'; + } else if (d.conceptInCS2Only === 1) { + return 'CS2_ONLY'; + } + + return 'UNKNOWN'; + } + + getMatchDisplayText(matchType) { + switch(matchType) { + case 'BOTH_SAME': + return ko.i18n('facets.match.bothSame', 'Both (Same)')(); + case 'BOTH_WITH_DIFF': + return ko.i18n('facets.match.bothWithDiff', 'Both (Has Diffs)')(); + case 'CS1_ONLY': + return ko.i18n('facets.match.only1', 'CS1 Only')(); + case 'CS2_ONLY': + return ko.i18n('facets.match.only2', 'CS2 Only')(); + default: + return ko.i18n('facets.match.unknown', 'Unknown')(); + } + } + + showSaveNewModal() { + this.saveConceptSetShow(true); + } + + refreshRecordCounts(obj, event) { + if (event.originalEvent) { + this.recordCountsRefreshing(true); + const compareResults = this.compareResults(); + const conceptIds = compareResults.map(o => o.conceptId).filter(id => id != null); + cdmResultsAPI.getConceptRecordCount(this.currentResultSource().sourceKey, conceptIds, compareResults) + .then(() => { + this.compareResults(compareResults); + }) + .finally(() => { + this.recordCountsRefreshing(false); + }); + } + } + + toggleShowDiagram() { + this.showDiagram(!this.showDiagram()); + } + + updateOutsideFilters(key) { + this.outsideFilters(key); + } + + updateLastSelectedMatchFilter(key) { + this.lastSelectedMatchFilter(key); + } + + renderFieldComparison(value1, value2, hasMismatch, fallbackValue) { + const isCrossVocab = value1 !== null && value2 !== null; + + if (isCrossVocab) { + if (hasMismatch) { + let html = '
'; + html += `
${this.escapeHtml(value1 || '')}
`; + html += `
${this.escapeHtml(value2 || '')}
`; + html += '
'; + return html; + } else { + return this.escapeHtml(value1 || value2 || ''); + } + } else { + return this.escapeHtml(fallbackValue || ''); + } + } + + renderDateFieldComparison(date1, date2, hasMismatch, fallbackDate, type) { + if (type === "sort") { + return fallbackDate ? +fallbackDate : 0; + } + + const formatDate = (date) => { + return date ? MomentApi.formatDateTimeWithFormat(date, MomentApi.DATE_FORMAT) : ''; + }; + + const formattedDate1 = formatDate(date1); + const formattedDate2 = formatDate(date2); + const formattedFallbackDate = formatDate(fallbackDate); + + const isCrossVocab = date1 !== null && date2 !== null; + + if (isCrossVocab) { + if (hasMismatch) { + let html = '
'; + html += `
${formattedDate1}
`; + html += `
${formattedDate2}
`; + html += '
'; + return html; + } else { + return formattedDate1 || formattedDate2; + } + } else { + return formattedFallbackDate; + } + } + + escapeHtml(text) { + if (!text) return ''; + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, m => map[m]); + } + } + + return commonUtils.build('compare-results-included-sourcecodes', CompareResultsIncludedSourceCodes, view); +}); \ No newline at end of file diff --git a/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-sourcecodes.less b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-sourcecodes.less new file mode 100644 index 000000000..5a75c41bc --- /dev/null +++ b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-sourcecodes.less @@ -0,0 +1,44 @@ +.id-not-found { + color: #ff9900; +} +.name-mismatch, .field-mismatch { + color: #ff9900; + margin-right: 4px; +} +.vocab-concept-names, .vocab-field-values { + font-size: 0.9em; + + .vocab-name-row, .vocab-field-row { + line-height: 1.6; + padding: 2px 0; + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } + + i.field-mismatch { + vertical-align: middle; + } + } +} + +.vocab-source-column { + .vocab-source-row { + line-height: 1.6; + padding: 2px 0; + white-space: nowrap; + font-size: 0.85em; + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } + } +} \ No newline at end of file diff --git a/js/pages/configuration/components/modal/conceptset-batch-compare-modal.html b/js/pages/configuration/components/modal/conceptset-batch-compare-modal.html new file mode 100644 index 000000000..83fe50651 --- /dev/null +++ b/js/pages/configuration/components/modal/conceptset-batch-compare-modal.html @@ -0,0 +1,429 @@ + + + + + + +
+ +
+ +

Loading authors...

+
+ + + + + + + + + + +
+ +
+ +

Loading concept sets...

+
+ + +
+ + + + + + + +
+
+ + + \ No newline at end of file diff --git a/js/pages/configuration/components/modal/conceptset-batch-compare-modal.js b/js/pages/configuration/components/modal/conceptset-batch-compare-modal.js new file mode 100644 index 000000000..ef7f17d78 --- /dev/null +++ b/js/pages/configuration/components/modal/conceptset-batch-compare-modal.js @@ -0,0 +1,780 @@ +define([ + 'knockout', + 'text!./conceptset-batch-compare-modal.html', + 'components/Component', + 'utils/CommonUtils', + 'utils/AutoBind', + 'utils/DatatableUtils', + 'services/ConceptSet', + 'services/AuthAPI', + 'services/Tags', + 'services/User', + 'services/Vocabulary', + 'atlas-state', + 'less!./conceptset-batch-compare-modal.less', + 'databindings', +], function ( + ko, + view, + Component, + commonUtils, + AutoBind, + datatableUtils, + conceptSetService, + authApi, + TagsService, + UserService, + vocabularyService, + sharedState, +) { + class ConceptSetBatchCompareModal extends AutoBind(Component) { + constructor(params) { + super(params); + + this.isModalShown = params.isModalShown || ko.observable(false); + + // Vocabulary sources + this.vocabularySources = ko.computed(() => { + const vocabularySources = []; + sharedState.sources().forEach((source) => { + if (source.hasVocabulary && authApi.isPermittedAccessSource(source.sourceKey)) { + vocabularySources.push({ + sourceKey: source.sourceKey, + sourceName: source.sourceName, + version: source.version() || '' + }); + } + }); + return vocabularySources; + }); + + // Selected vocabularies + this.selectedBaseVocabulary = ko.observable(null); + this.selectedTargetVocabulary = ko.observable(null); + + // Initialize with current vocabulary if available + if (this.vocabularySources().length > 0) { + const currentSourceKey = sharedState.sourceKeyOfVocabUrl(); + const currentSource = this.vocabularySources().find(s => s.sourceKey === currentSourceKey); + + if (currentSource) { + this.selectedBaseVocabulary(currentSource); + } else { + this.selectedBaseVocabulary(this.vocabularySources()[0]); + } + } + + // Display computed observables + this.selectedBaseVocabularyDisplay = ko.pureComputed(() => { + const selected = this.selectedBaseVocabulary(); + return selected ? `[${selected.sourceName}] ${selected.version}` : ko.i18n('components.batchCompare.selectVocabulary', 'Select Vocabulary')(); + }); + + this.selectedTargetVocabularyDisplay = ko.pureComputed(() => { + const selected = this.selectedTargetVocabulary(); + return selected ? `[${selected.sourceName}] ${selected.version}` : ko.i18n('components.batchCompare.selectVocabulary', 'Select Vocabulary')(); + }); + + // Vocabulary validation + this.vocabularySelectionError = ko.pureComputed(() => { + const base = this.selectedBaseVocabulary(); + const target = this.selectedTargetVocabulary(); + return base && target && base.sourceKey === target.sourceKey; + }); + + // Date filters with operators + this.createdDateOperator = ko.observable('between'); + this.createdDateFrom = ko.observable(''); + this.createdDateTo = ko.observable(''); + + this.updatedDateOperator = ko.observable('between'); + this.updatedDateFrom = ko.observable(''); + this.updatedDateTo = ko.observable(''); + + // Author filter - Multi-select + this.availableAuthors = ko.observableArray(); + this.selectedAuthors = ko.observableArray(); + this.showAuthorsModal = ko.observable(false); + this.authorsMessage = ko.observable(); + this.tableOptions = commonUtils.getTableOptions('S'); + + this.isLoadingAuthors = ko.observable(false); + this.isLoadingTags = ko.observable(false); + + // Helper function to escape HTML attributes + const escapeAttr = (str) => { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(/'/g, ''') + .replace(/"/g, '"') + .replace(//g, '>'); + }; + + // Helper function to escape HTML content + const escapeHtml = (str) => { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + }; + + // Author columns configuration + this.availableAuthorsColumns = [ + { + title: '', + width: '20px', + sortable: false, + searchable: false, + render: (s, p, d) => { + if (!d) return ''; + d.select = () => { + if (!d.selected()) { + this.authorsMessage(''); + d.selected(true); + } else { + d.selected(false); + } + }; + return ``; + }, + }, + { + title: ko.unwrap(ko.i18n('columns.name', 'Name')), + width: '150px', + data: 'name', + searchable: true, + render: (s, p, d) => { + if (!d) return ''; + const displayName = d.name || d.login || ''; + const escapedName = escapeAttr(displayName); + const escapedHtml = escapeHtml(displayName); + return `${escapedHtml}`; + } + }, + { + title: ko.unwrap(ko.i18n('columns.login', 'Login')), + width: '120px', + data: 'login', + searchable: true, + render: (s, p, d) => { + if (!d) return ''; + const login = d.login || ''; + const escapedLogin = escapeAttr(login); + const escapedHtml = escapeHtml(login); + return ``; + } + } + ]; + + // Tags - Multi-select + this.availableTags = ko.observableArray(); + this.selectedTags = ko.observableArray(); + this.showTagsModal = ko.observable(false); + this.tagsMessage = ko.observable(); + + // Tag columns configuration + this.availableTagsColumns = [ + { + title: '', + width: '20px', + sortable: false, + render: (s, p, d) => { + d.select = () => { + if (!d.selected()) { + this.tagsMessage(''); + d.selected(true); + } else { + d.selected(false); + } + }; + return ``; + }, + }, + { + title: ko.i18n('columns.group', 'Group'), + width: '100px', + render: (s, p, d) => { + const groupName = d.groups && d.groups[0] ? d.groups[0].name : ''; + const escapedGroupName = escapeAttr(groupName); + const escapedHtml = escapeHtml(groupName); + return `${escapedHtml}`; + } + }, + { + title: ko.i18n('columns.name', 'Name'), + width: '100px', + render: (s, p, d) => { + const tagName = d.name || ''; + const color = d.color || (d.groups && d.groups[0] && d.groups[0].color) || '#cecece'; + const icon = d.icon || (d.groups && d.groups[0] && d.groups[0].icon) || 'fa fa-tag'; + const displayName = tagName.length > 22 ? tagName.substring(0, 20) + '...' : tagName; + const escapedTagName = escapeAttr(tagName); + const escapedHtml = escapeHtml(displayName); + return ` + + ${escapedHtml} + `; + } + }, + { + title: ko.i18n('columns.description', 'Description'), + width: '225px', + render: (s, p, d) => { + const desc = d.description || '-'; + const escapedDesc = escapeAttr(desc); + const escapedHtml = escapeHtml(desc); + return `${escapedHtml}`; + } + } + ]; + + this.isLoadingConceptSets = ko.observable(false); + this.conceptSetsMessage = ko.observable(); + + // Concept Set columns configuration + this.availableConceptSetsColumns = [ + { + title: '', + width: '20px', + sortable: false, + searchable: false, + render: (s, p, d) => { + if (!d) return ''; + d.select = () => { + if (!d.selected()) { + this.conceptSetsMessage(''); + d.selected(true); + } else { + d.selected(false); + } + }; + return ``; + }, + }, + { + title: ko.unwrap(ko.i18n('columns.id', 'ID')), + width: '80px', + data: 'id', + searchable: true, + }, + { + title: ko.unwrap(ko.i18n('columns.name', 'Name')), + data: 'name', + searchable: true, + render: (s, p, d) => { + if (!d) return ''; + const name = d.name || ''; + const escapedName = escapeAttr(name); + const escapedHtml = escapeHtml(name); + return `${escapedHtml}`; + } + } + ]; + + // Skip locked checkbox + this.skipLocked = ko.observable(false); + + // Compare source codes checkbox + this.compareSourceCodes = ko.observable(true); + + // Execution state + this.isExecuting = ko.observable(false); + this.actionInProgress = ko.observable(''); + + // Can execute validation + this.canExecute = ko.pureComputed(() => { + const hasBaseVocab = this.selectedBaseVocabulary() !== null; + const hasTargetVocab = this.selectedTargetVocabulary() !== null; + const vocabsAreDifferent = !this.vocabularySelectionError(); + + // Check if at least one filter criterion is provided + const hasDateFilter = !!( + this.createdDateFrom() || + this.createdDateTo() || + this.updatedDateFrom() || + this.updatedDateTo() + ); + + const hasAuthorFilter = this.selectedAuthors().length > 0; + const hasTagFilter = this.selectedTags().length > 0; + const hasConceptSetIdFilter = this.conceptSetIdsForFilter().length > 0; + + // At least one filter must be active + const hasAnyFilter = hasDateFilter || hasAuthorFilter || hasTagFilter || hasConceptSetIdFilter; + + return hasBaseVocab && hasTargetVocab && vocabsAreDifferent && hasAnyFilter; + }); + + // Load tags and authors when modal is shown + this.isModalShown.subscribe(open => { + if (open) { + this.loadAvailableTags(); + this.loadAvailableAuthors(); + } else { + // Clear everything when modal is closed + this.resetForm(); + } + }); + + this.isCheckingCount = ko.observable(false); + this.filterCount = ko.observable(null); + this.filterCountMessage = ko.observable(''); + + // Subscribe to filter changes to clear the count + const clearFilterCount = () => { + this.filterCount(null); + this.filterCountMessage(''); + }; + + // Concept Set IDs Filter + this.conceptSetIdsText = ko.observable(''); + this.selectedConceptSetIds = ko.observableArray(); + this.availableConceptSetsForFilter = ko.observableArray(); + this.showConceptSetSelectorModal = ko.observable(false); + + // Track which mode is active + this.isTextModeActive = ko.pureComputed(() => { + return this.conceptSetIdsText().trim().length > 0; + }); + + this.isSelectorModeActive = ko.pureComputed(() => { + return this.selectedConceptSetIds().length > 0; + }); + + this.conceptSetIdsFromText = ko.pureComputed(() => { + const text = this.conceptSetIdsText().trim(); + if (!text) return []; + return text.split(',') + .map(id => id.trim()) + .filter(id => id && /^\d+$/.test(id)) + .map(id => parseInt(id, 10)); + }); + + // Computed to get final list of IDs + this.conceptSetIdsForFilter = ko.pureComputed(() => { + const textIds = this.conceptSetIdsFromText(); + if (textIds.length > 0) { + return textIds; + } + return this.selectedConceptSetIds(); + }); + + // Validation + this.conceptSetIdsValid = ko.pureComputed(() => { + const text = this.conceptSetIdsText().trim(); + if (!text) return true; + const ids = text.split(',').map(id => id.trim()); + return ids.every(id => /^\d+$/.test(id)); + }); + + this.conceptSetIdsValidationMessage = ko.pureComputed(() => { + if (!this.conceptSetIdsValid()) { + return ko.i18n('components.batchCompare.invalidConceptSetIds', + 'Invalid format. Please enter comma-separated numbers (e.g., 1,2,3)')(); + } + return ''; + }); + + // Local count of concept set IDs + this.conceptSetIdsLocalCount = ko.pureComputed(() => { + return this.conceptSetIdsForFilter().length; + }); + + this.conceptSetIdsCountMessage = ko.pureComputed(() => { + const count = this.conceptSetIdsLocalCount(); + if (count === 0) return ''; + return ko.i18nformat('components.batchCompare.conceptSetIdsLocalCount', + '<%=count%> concept set ID(s) specified', + { count: count })(); + }); + + // Clear selector selections when text is entered + this.conceptSetIdsText.subscribe(newValue => { + if (newValue && newValue.trim().length > 0) { + this.selectedConceptSetIds([]); + this.availableConceptSetsForFilter().forEach(cs => { + cs.selected(false); + }); + } + }); + + // Clear text when selector selections are made + this.selectedConceptSetIds.subscribe(newValue => { + if (newValue && newValue.length > 0) { + this.conceptSetIdsText(''); + } + }); + + this.createdDateOperator.subscribe(clearFilterCount); + this.createdDateFrom.subscribe(clearFilterCount); + this.createdDateTo.subscribe(clearFilterCount); + this.updatedDateOperator.subscribe(clearFilterCount); + this.updatedDateFrom.subscribe(clearFilterCount); + this.updatedDateTo.subscribe(clearFilterCount); + this.selectedAuthors.subscribe(clearFilterCount); + this.selectedTags.subscribe(clearFilterCount); + this.skipLocked.subscribe(clearFilterCount); + this.conceptSetIdsText.subscribe(clearFilterCount); + this.selectedConceptSetIds.subscribe(clearFilterCount); + } + + async loadAvailableTags() { + if (this.isLoadingTags()) return; + + this.isLoadingTags(true); + try { + const res = await TagsService.loadAvailableTags(); + this.availableTags(res.filter(t => { + if (!t.groups || t.groups.length === 0) { + return false; + } + return true; + }).map(tag => ({ selected: ko.observable(false), ...tag }))); + } catch (error) { + console.error('Error loading tags:', error); + this.availableTags([]); + } finally { + this.isLoadingTags(false); + } + } + + async loadAvailableAuthors() { + if (this.isLoadingAuthors()) return; + + this.isLoadingAuthors(true); + try { + const users = await UserService.getUsers(); + const sortedUsers = users.sort((a, b) => { + const nameA = a.name || a.login; + const nameB = b.name || b.login; + return nameA.localeCompare(nameB); + }); + this.availableAuthors(sortedUsers.map(user => ({ + selected: ko.observable(false), + ...user + }))); + } catch (error) { + console.error('Error loading authors:', error); + this.availableAuthors([]); + } finally { + this.isLoadingAuthors(false); + } + } + + // Load available concept sets for selector + async loadAvailableConceptSetsForFilter() { + if (this.availableConceptSetsForFilter().length > 0) return; + + this.isLoadingConceptSets(true); + try { + const response = await vocabularyService.getConceptSetList(); + const conceptSets = response.data || response; + + this.availableConceptSetsForFilter( + conceptSets + .filter(cs => cs.id && cs.name) + .map(cs => ({ + selected: ko.observable(false), + id: cs.id, + name: cs.name + })) + .sort((a, b) => a.id - b.id) + ); + } catch (error) { + console.error('Error loading concept sets for filter:', error); + this.availableConceptSetsForFilter([]); + alert(ko.i18n('components.batchCompare.errorLoadingConceptSets', + 'Failed to load concept sets. Please try again.')()); + } finally { + this.isLoadingConceptSets(false); + } + } + + // Toggle between modes + toggleConceptSetIdFilterMode() { + const newMode = this.conceptSetIdFilterMode() === 'text' ? 'selector' : 'text'; + this.conceptSetIdFilterMode(newMode); + + if (newMode === 'selector') { + this.loadAvailableConceptSetsForFilter(); + } + } + + // Open selector modal + async openConceptSetSelectorModal() { + // Don't open if text mode is active + if (this.isTextModeActive()) { + return; + } + + await this.loadAvailableConceptSetsForFilter(); + + // Sync selections with current state + const currentIds = this.selectedConceptSetIds(); + this.availableConceptSetsForFilter().forEach(cs => { + cs.selected(currentIds.includes(cs.id)); + }); + + this.conceptSetsMessage(''); + this.showConceptSetSelectorModal(true); + } + + applyConceptSetSelection() { + const selected = this.availableConceptSetsForFilter() + .filter(cs => cs.selected()) + .map(cs => cs.id); + this.selectedConceptSetIds(selected); + this.showConceptSetSelectorModal(false); + this.conceptSetsMessage(''); + } + + removeSelectedConceptSet(conceptSetId) { + this.selectedConceptSetIds.remove(conceptSetId); + const available = this.availableConceptSetsForFilter().find(cs => cs.id === conceptSetId); + if (available) { + available.selected(false); + } + } + + async openTagsModal() { + // Ensure tags are loaded + if (this.availableTags().length === 0) { + await this.loadAvailableTags(); + } + + ko.utils.arrayForEach(this.availableTags(), t => { + t.selected(this.selectedTags().some(st => st.id === t.id)); + }); + this.tagsMessage(''); + this.showTagsModal(true); + } + + applyTagsSelection() { + this.selectedTags(this.availableTags().filter(t => t.selected())); + this.showTagsModal(false); + this.tagsMessage(''); + } + + removeTag(tag) { + this.selectedTags.remove(t => t.id === tag.id); + const availableTag = this.availableTags().find(t => t.id === tag.id); + if (availableTag) { + availableTag.selected(false); + } + } + + async openAuthorsModal() { + // Ensure authors are loaded + if (this.availableAuthors().length === 0) { + await this.loadAvailableAuthors(); + } + + ko.utils.arrayForEach(this.availableAuthors(), a => { + a.selected(this.selectedAuthors().some(sa => sa.id === a.id)); + }); + this.authorsMessage(''); + this.showAuthorsModal(true); + } + + applyAuthorsSelection() { + this.selectedAuthors(this.availableAuthors().filter(a => a.selected())); + this.showAuthorsModal(false); + this.authorsMessage(''); + } + + removeAuthor(author) { + this.selectedAuthors.remove(a => a.id === author.id); + const availableAuthor = this.availableAuthors().find(a => a.id === author.id); + if (availableAuthor) { + availableAuthor.selected(false); + } + } + + // Process dates based on operator + getProcessedDates(operator, fromDate, toDate) { + switch (operator) { + case 'between': + return { + from: fromDate || null, + to: toDate || null + }; + case 'before': + return { + from: null, + to: toDate || null + }; + case 'after': + return { + from: fromDate || null, + to: null + }; + default: + return { + from: null, + to: null + }; + } + } + + // Build request payload + buildRequestPayload(includeVocabularies = true) { + const createdDates = this.getProcessedDates( + this.createdDateOperator(), + this.createdDateFrom(), + this.createdDateTo() + ); + + const updatedDates = this.getProcessedDates( + this.updatedDateOperator(), + this.updatedDateFrom(), + this.updatedDateTo() + ); + + const payload = { + createdDateFrom: createdDates.from, + createdDateTo: createdDates.to, + updatedDateFrom: updatedDates.from, + updatedDateTo: updatedDates.to, + authors: this.selectedAuthors().map(a => a.id), + tags: this.selectedTags().map(t => t.id), + skipLocked: this.skipLocked() + }; + + // Only include vocabulary-related fields if requested and available + if (includeVocabularies) { + payload.jobName = `Concept Set Batch Compare - ${new Date().toISOString()}`; + payload.source1Key = this.selectedBaseVocabulary()?.sourceKey || null; + payload.source2Key = this.selectedTargetVocabulary()?.sourceKey || null; + payload.compareSourceCodes = this.compareSourceCodes(); + } + + const conceptSetIds = this.conceptSetIdsForFilter(); + if (conceptSetIds.length > 0) { + payload.conceptSetIds = conceptSetIds; + } + + return payload; + } + + // Run batch compare + runBatchCompare() { + if (!this.canExecute() || this.isExecuting()) { + return; + } + + this.isExecuting(true); + this.actionInProgress(ko.i18n('components.batchCompare.running', 'Running batch comparison...')()); + + const requestPayload = this.buildRequestPayload(); + + conceptSetService.runCohortCompareBatchJob(requestPayload) + .then(() => { + console.log("Batch comparison job queued successfully"); + this.isModalShown(false); + this.resetForm(); + + alert(ko.i18n('components.batchCompare.success', 'Batch comparison job has been queued successfully')()); + }) + .catch(error => { + console.error(`Error running batch comparison: ${error}`); + alert(ko.i18n('components.batchCompare.error', 'Failed to queue batch comparison job')()); + }) + .finally(() => { + this.isExecuting(false); + this.actionInProgress(''); + }); + } + + // Cancel and close modal + cancel() { + this.isModalShown(false); + this.resetForm(); + } + + // Reset form to initial state + resetForm() { + this.createdDateOperator('between'); + this.createdDateFrom(''); + this.createdDateTo(''); + + this.updatedDateOperator('between'); + this.updatedDateFrom(''); + this.updatedDateTo(''); + + this.selectedAuthors([]); + this.selectedTags([]); + this.skipLocked(false); + this.compareSourceCodes(false); + + // Reset target vocabulary but keep base vocabulary + this.selectedTargetVocabulary(null); + + // Reset tag selections + ko.utils.arrayForEach(this.availableTags(), (tag) => tag.selected(false)); + + // Reset author selections + ko.utils.arrayForEach(this.availableAuthors(), (author) => author.selected(false)); + + // Reset concept set IDs + this.conceptSetIdsText(''); + this.selectedConceptSetIds([]); + + this.filterCount(null); + this.filterCountMessage(''); + this.isCheckingCount(false); + } + + checkFilterCount() { + if (this.isCheckingCount()) { + return; + } + + this.isCheckingCount(true); + this.filterCountMessage(''); + this.filterCount(null); + + const requestPayload = this.buildRequestPayload(false); + + conceptSetService.checkConceptSetFilterCount(requestPayload) + .then(response => { + const count = response.data.count; + this.filterCount(count); + + const localCount = this.conceptSetIdsLocalCount(); + let message = ''; + + if (localCount > 0) { + // Show both counts + message = ko.i18nformat('components.batchCompare.conceptSetCountWithIds', + '<%=matchCount%> concept set(s) match the criteria (<%=idCount%> ID(s) specified)', + { matchCount: count, idCount: localCount })(); + } else { + // Show only match count + if (count === 0) { + message = ko.i18n('components.batchCompare.noConceptSets', + 'No concept sets match the specified criteria')(); + } else { + message = ko.i18nformat('components.batchCompare.conceptSetCount', + '<%=count%> concept set(s) match the specified criteria', + { count: count })(); + } + } + + this.filterCountMessage(message); + }) + .catch(error => { + console.error('Error checking filter count:', error); + this.filterCountMessage( + ko.i18n('components.batchCompare.errorCheckingCount', + 'Error checking concept set count')() + ); + }) + .finally(() => { + this.isCheckingCount(false); + }); + } + } + return commonUtils.build('conceptset-batch-compare-modal', ConceptSetBatchCompareModal, view); +}); \ No newline at end of file diff --git a/js/pages/configuration/components/modal/conceptset-batch-compare-modal.less b/js/pages/configuration/components/modal/conceptset-batch-compare-modal.less new file mode 100644 index 000000000..a948f5fa0 --- /dev/null +++ b/js/pages/configuration/components/modal/conceptset-batch-compare-modal.less @@ -0,0 +1,489 @@ +.batch-compare-modal { + max-height: none; + + .vocabulary-selection-section, + .filter-section { + margin-bottom: 20px; + padding: 15px; + background-color: #f8f9fa; + border: 1px solid #ddd; + + .heading { + margin-top: 0; + margin-bottom: 15px; + } + } + + .vocabulary-row { + display: flex; + align-items: center; + margin-bottom: 10px; + gap: 10px; + + label { + width: 80px; + margin-bottom: 0; + font-weight: bold; + font-size: 13px; + flex-shrink: 0; + } + + .vocabulary-dropdown { + flex: 1; + min-width: 250px; // Minimum width for usability + max-width: none; // Remove fixed max-width + + .btn { + width: 100%; + text-align: left; + display: flex; + align-items: center; + justify-content: space-between; + padding-right: 12px; + + .vocab-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-right: 8px; + } + + .caret { + flex-shrink: 0; + margin-top: 0; + } + } + + .dropdown-menu { + width: 100%; + max-height: 300px; + overflow-y: auto; + + li a { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + } + } + } + } + + .alert-warning { + margin-top: 10px; + margin-bottom: 0; + } + + .date-range-group { + display: flex; + align-items: center; + margin-bottom: 15px; + gap: 10px; + + label { + width: 80px; + margin-bottom: 0; + font-weight: bold; + flex-shrink: 0; + } + + .date-inputs { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-height: 34px; + flex-wrap: wrap; // Allow wrapping on very small screens + + .date-operator { + width: 120px; + flex-shrink: 0; + } + + .date-input { + flex: 1; + min-width: 150px; + max-width: 200px; + } + + .date-separator { + flex-shrink: 0; + } + } + } + + // Unified selector styling for Authors, Tags, and Concept Sets + .form-group.selector-group { + display: flex; + align-items: flex-start; + margin-bottom: 10px; + gap: 10px; + + label { + width: 120px; + margin-bottom: 0; + margin-top: 6px; // Align with button + font-weight: bold; + flex-shrink: 0; + } + + .selector-button { + width: 170px; + flex-shrink: 0; + + &:disabled, + &[disabled] { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } + } + + .selector-controls { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; // Allow flex shrinking + + .selector-separator { + font-size: 13px; + color: #666; + font-style: italic; + padding: 0 5px; + flex-shrink: 0; + } + + .inline-text-input { + flex: 1; + min-width: 200px; // Minimum width for usability + max-width: none; // Remove fixed max-width + height: 31px; + font-size: 13px; + } + } + } + + // Unified selected items container + .selected-items-container { + margin-top: 5px; + margin-bottom: 15px; + padding: 10px; + background-color: #fff; + border: 1px solid #dee2e6; + border-radius: 3px; + + .info-row { + margin-bottom: 10px; + font-size: 13px; + color: #3c763d; + + i { + margin-right: 5px; + } + } + } + + .selected-items-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + // Unified badge styling + .item-badge { + display: inline-flex; + align-items: center; + padding: 5px 10px; + border-radius: 3px; + font-size: 13px; + + i:first-child { + margin-right: 6px; + font-size: 12px; + } + + .item-remove { + margin-left: 8px; + opacity: 0.6; + text-decoration: none; + + &:hover { + opacity: 1; + } + + i { + margin: 0; + font-size: 11px; + } + } + } + + // Author-specific styling + .author-badge { + background-color: #e7f3ff; + border: 1px solid #b3d9ff; + color: #004085; + + .item-remove { + color: #004085; + } + } + + // Concept Set-specific styling + .conceptset-badge { + background-color: #fff3cd; + border: 1px solid #ffc107; + color: #856404; + + .item-remove { + color: #856404; + } + } + + // Tag-specific styling (keep existing tag colors) + .tag { + display: inline-flex; + align-items: center; + padding: 5px 10px; + border-radius: 3px; + font-size: 13px; + color: #fff; + + i:first-child { + margin-right: 6px; + font-size: 12px; + } + + .tag-remove { + margin-left: 8px; + color: #fff; + opacity: 0.8; + text-decoration: none; + + &:hover { + opacity: 1; + } + + i { + margin: 0; + font-size: 11px; + } + } + } + + // Feedback section for concept set IDs + .selector-feedback { + margin-top: 5px; + margin-bottom: 10px; + + .help-block { + margin-bottom: 5px; + font-size: 13px; + + i { + margin-right: 5px; + } + + &.text-danger { + color: #a94442; + + i { + color: #a94442; + } + } + + &.text-success { + color: #3c763d; + + i { + color: #3c763d; + } + } + } + } + + .skip-locked-checkbox, + .compare-source-codes-checkbox { + label { + display: flex; + align-items: center; + font-size: 13px; + cursor: pointer; + + input[type="checkbox"] { + margin-right: 8px; + margin-top: 0; + } + + span { + line-height: 1.4; + } + } + } + + .compare-source-codes-checkbox { + margin-top: 10px; + } + + .action-buttons { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; + + .btn { + min-width: 110px; + } + } + + // Responsive adjustments for smaller screens + @media (max-width: 768px) { + + .vocabulary-row, + .date-range-group, + .form-group.selector-group { + flex-direction: column; + align-items: stretch; + + label { + width: auto; + margin-bottom: 5px; + } + + .vocabulary-dropdown, + .date-inputs, + .selector-controls { + width: 100%; + } + + .selector-button { + width: 100%; + } + } + + .date-inputs { + + .date-operator, + .date-input { + width: 100% !important; + max-width: none; + } + } + } +} + +// Fix modal body scrolling +.modal-body { + &.padded { + max-height: calc(100vh - 200px); + overflow-y: auto; + } +} + +// Modal content styling +.tag-modal-content, +.author-modal-content, +.concept-set-selector-content { + max-height: 60vh; + overflow-y: auto; + + .cell-name, + .cell-author-name, + .cell-author-login, + .cell-tag-name, + .cell-tag-description { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.modal-footer { + .modal-message { + float: left; + padding: 5px; + + i { + color: #17a2b8; + } + } + + .modal-buttons { + float: right; + + button+button { + margin-left: 10px; + } + } + + &::after { + content: ""; + display: table; + clear: both; + } +} + +.filter-count-section { + margin-top: 15px; + margin-bottom: 15px; + + .filter-count-result { + display: inline-block; + margin-left: 15px; + + i { + margin-right: 5px; + + &.fa-info-circle { + color: #17a2b8; + } + + &.fa-exclamation-triangle { + color: #ffc107; + } + } + + span { + font-size: 14px; + + &.text-warning { + color: #856404; + } + } + } + + .filter-count-loading { + display: inline-block; + margin-left: 15px; + + .fa-spinner { + margin-right: 5px; + color: #17a2b8; + } + + span { + font-size: 14px; + color: #666; + } + } +} + +.performance-warning { + margin-top: 15px; + margin-bottom: 15px; + padding: 8px 12px; + color: #721c24; + background-color: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 3px; + font-size: 13px; + line-height: 1.5; + + i.fa-exclamation-triangle { + margin-right: 6px; + color: #721c24; + } +} \ No newline at end of file diff --git a/js/pages/configuration/configuration.html b/js/pages/configuration/configuration.html index 831dcf859..f27791bc5 100644 --- a/js/pages/configuration/configuration.html +++ b/js/pages/configuration/configuration.html @@ -145,5 +145,4 @@ - - + \ No newline at end of file diff --git a/js/pages/configuration/configuration.js b/js/pages/configuration/configuration.js index 6aa4919d7..71ccdb3f4 100644 --- a/js/pages/configuration/configuration.js +++ b/js/pages/configuration/configuration.js @@ -15,6 +15,7 @@ define([ 'services/CacheAPI', 'less!./configuration.less', 'components/heading', + './components/modal/conceptset-batch-compare-modal', ], function ( ko, view, diff --git a/js/pages/jobs/job-manager.html b/js/pages/jobs/job-manager.html index dbab7d105..0be707294 100644 --- a/js/pages/jobs/job-manager.html +++ b/js/pages/jobs/job-manager.html @@ -1,28 +1,41 @@ +
-
Refresh Jobs
+
+
+ +
+
+
-
+ + + +
+ + +
- \ No newline at end of file + + + \ No newline at end of file diff --git a/js/pages/jobs/job-manager.js b/js/pages/jobs/job-manager.js index 4890f4ea2..7ec9fa3be 100644 --- a/js/pages/jobs/job-manager.js +++ b/js/pages/jobs/job-manager.js @@ -11,6 +11,8 @@ define([ 'databindings', 'components/ac-access-denied', 'components/heading', + '../configuration/components/modal/conceptset-batch-compare-modal', + 'less!./job-manager.less' ], function ( ko, @@ -18,49 +20,188 @@ define([ Page, AutoBind, commonUtils, - jobsService, + JobsService, config, momentApi, authApi ) { - class JobManager extends AutoBind(Page) { - constructor(params) { - super(params); - this.jobs = ko.observableArray([]); - this.tableOptions = commonUtils.getTableOptions('L'); - this.columns = ko.observableArray([ - {title: ko.i18n('columns.executionId', 'Execution Id'), data: 'executionId'}, - {title: ko.i18n('columns.jobName', 'Job Name'), data: 'jobParameters.jobName'}, - {title: ko.i18n('columns.status', 'Status'), data: 'status'}, - {title: ko.i18n('columns.startDate', 'Start Date'), data: 'startDate'}, - {title: ko.i18n('columns.endDate', 'End Date'), data: 'endDate'} - ]); - if (config.userAuthenticationEnabled) { - // Add 'Author' column after 'Status' column - this.columns.splice(3, 0, {title: ko.i18n('columns.author', 'Author'), data: 'jobParameters.jobAuthor', 'defaultContent': ''}); + class JobManager extends AutoBind(Page) { + constructor(params) { + super(params); + this.jobs = ko.observableArray([]); + this.isLoading = ko.observable(false); + this.downloading = ko.observable(null); + this.isBatchCompareModalShown = ko.observable(false); + + const { pageLength, lengthMenu } = commonUtils.getTableOptions('L'); + this.pageLength = pageLength; + this.lengthMenu = lengthMenu; + + // permission checks to be BEFORE using them in computed observables + this.isAuthenticated = authApi.isAuthenticated; + this.canReadJobs = ko.pureComputed(() => { + return authApi.isPermittedReadJobs(); + }); + + this.canBatchCompare = ko.pureComputed(() => { + return authApi.isPermittedBatchCompare(); + }); + + this.columns = ko.computed(() => { + const baseColumns = [ + { title: ko.i18n('columns.executionId', 'Execution Id'), data: 'executionId' }, + { title: ko.i18n('columns.jobName', 'Job Name'), data: 'jobParameters.jobName' }, + { title: ko.i18n('columns.status', 'Status'), data: 'status' }, + { title: ko.i18n('columns.startDate', 'Start Date'), data: 'startDate' }, + { title: ko.i18n('columns.endDate', 'End Date'), data: 'endDate' } + ]; + + if (config.userAuthenticationEnabled) { + baseColumns.splice(3, 0, { + title: ko.i18n('columns.author', 'Author'), + data: 'jobParameters.jobAuthor', + defaultContent: '' + }); + } + + if (this.canBatchCompare()) { + baseColumns.push({ + title: ko.i18n('columns.actions', 'Actions'), + sortable: false, + className: 'text-center', + render: (d, t, r) => { + if (r.hasArtifact === true) { + const downloading = this.downloading(); + if (downloading === r.executionId) { + return ''; + } + return ``; + } + return ''; + } + }); + } + + return baseColumns; + }); + + if (this.canReadJobs()) { + this.updateJobs(); + } + + // Subscribe to modal close event to refresh jobs + this.isBatchCompareModalShown.subscribe((isShown) => { + if (!isShown) { + // Modal was closed, refresh the jobs list + this.updateJobs(); + } + }); } - this.isAuthenticated = authApi.isAuthenticated; - this.canReadJobs = ko.pureComputed(() => { - return authApi.isPermittedReadJobs(); - }); - if (this.canReadJobs()) { - this.updateJobs(); + async updateJobs() { + this.isLoading(true); + try { + const jobs = await JobsService.getList(); + + const processedJobs = jobs.map((job) => { + const { startDate = null, endDate = null } = job; + job.startDate = startDate ? momentApi.formatDateTime(new Date(startDate)) : '-'; + job.endDate = endDate && (endDate > startDate) ? momentApi.formatDateTime(new Date(endDate)) : '-'; + job.jobParameters.jobName == undefined && (job.jobParameters.jobName = 'n/a'); + return job; + }); + + this.jobs(processedJobs); + + } catch (ex) { + console.error('Failed to load jobs:', ex); + } finally { + this.isLoading(false); + } } - } - async updateJobs() { - const jobs = await jobsService.getList(); - this.jobs(jobs.map((job) => { - const { startDate = null, endDate = null } = job; - job.startDate = startDate ? momentApi.formatDateTime(new Date(startDate)) : '-'; - job.endDate = endDate && (endDate > startDate) ? momentApi.formatDateTime(new Date(endDate)) : '-'; - job.jobParameters.jobName == undefined && (job.jobParameters.jobName = 'n/a'); - return job; - })); - } + async onRowClick(d, e) { + // Only process row clicks if user has batch compare permission + if (!this.canBatchCompare()) { + return; + } + + try { + const { executionId } = d; + if (e.target.className.includes('downloadArtifactIcon')) { + await this.downloadArtifact(executionId); + } + } catch (ex) { + console.error('Row click error:', ex); + } + } + + async downloadArtifact(executionId) { + if (this.downloading() === executionId) { + return; // Already downloading + } - } + this.downloading(executionId); + + // Trigger re-render to show spinner + this.jobs.valueHasMutated(); + + try { + await JobsService.downloadArtifact(executionId); + } catch (error) { + console.error('Failed to download artifact:', error); + + // Helper function to unwrap i18n values + const getText = (key, defaultText) => { + const value = ko.i18n(key, defaultText); + // Check if it's a knockout observable or computed + return ko.isObservable(value) ? value() : String(value); + }; + + // Create a meaningful error message + let errorMessage = ''; + + if (error && typeof error === 'object' && 'status' in error) { + const status = error.status; + const statusText = error.statusText || ''; + + switch (status) { + case 204: + errorMessage = getText('jobs.noArtifactAvailable', 'No artifact available for this job.'); + break; + case 404: + errorMessage = getText('jobs.artifactNotFound', 'Artifact not found.'); + break; + case 403: + errorMessage = getText('jobs.artifactForbidden', 'You do not have permission to download this artifact.'); + break; + case 500: + errorMessage = getText('jobs.artifactServerError', 'Server error occurred while generating artifact.'); + break; + default: + errorMessage = getText('jobs.downloadError', 'Failed to download artifact. Please try again.'); + if (statusText) { + errorMessage += ` (${status}: ${statusText})`; + } else { + errorMessage += ` (Status: ${status})`; + } + } + } else { + errorMessage = getText('jobs.downloadError', 'Failed to download artifact. Please try again.'); + } + + alert(errorMessage); + } finally { + this.downloading(null); + // Trigger re-render to remove spinner + this.jobs.valueHasMutated(); + } + } + + runConceptSetDiffReport() { + this.isBatchCompareModalShown(true); + } + } - return commonUtils.build('job-manager', JobManager, view); -}); + return commonUtils.build('job-manager', JobManager, view); + }); \ No newline at end of file diff --git a/js/pages/jobs/job-manager.less b/js/pages/jobs/job-manager.less new file mode 100644 index 000000000..65801d6b4 --- /dev/null +++ b/js/pages/jobs/job-manager.less @@ -0,0 +1,20 @@ +#wrapperJobs { + .downloadArtifactIcon { + color: #337ab7; + cursor: pointer; + min-width: 30px; + font-size: 16px; + + &:hover { + color: #23527c; + } + + &.fa-spinner { + color: #5cb85c; + } + } + + .text-muted { + color: #999; + } +} \ No newline at end of file diff --git a/js/services/AuthAPI.js b/js/services/AuthAPI.js index 082377ba2..e53d40c4d 100644 --- a/js/services/AuthAPI.js +++ b/js/services/AuthAPI.js @@ -508,6 +508,10 @@ define(function(require, exports) { const isPermittedConceptSetAnnotationsDelete = function (conceptSetId) { return isPermitted('conceptset:' + conceptSetId + ':annotation:*:delete'); + }; + + const isPermittedBatchCompare = function () { + return isPermitted('conceptset:compare-batch:post'); }; const isPermittedRunAs = () => isPermitted('user:runas:post'); @@ -639,7 +643,7 @@ define(function(require, exports) { isPermittedViewDataSourceReportDetails, isPermittedConceptSetAnnotationsDelete, - + isPermittedBatchCompare, loadUserInfo, TOKEN_HEADER, runAs, diff --git a/js/services/ConceptSet.js b/js/services/ConceptSet.js index 77e290942..2e8edd75e 100644 --- a/js/services/ConceptSet.js +++ b/js/services/ConceptSet.js @@ -46,15 +46,24 @@ define(function (require) { .catch(authApi.handleAccessDenied); } + function runCohortCompareBatchJob(requestPayload) { + return httpService.doPost(config.webAPIRoot + 'conceptset/compare-batch', requestPayload) + .catch(authApi.handleAccessDenied); + } + function deleteConceptSet(conceptSetId) { return httpService.doDelete(config.webAPIRoot + 'conceptset/' + (conceptSetId || '-1')) .catch(authApi.handleAccessDenied); } - function exists(name, id) { + function exists(name, id) { return httpService.doGet(`${config.webAPIRoot}conceptset/${id}/exists?name=${name}`) .then(({ data }) => data); - } + } + + function checkConceptSetFilterCount(filterRequest) { + return httpService.doPost(config.webAPIRoot + 'conceptset/check-filter-count', filterRequest); + } async function saveConceptSet(conceptSet) { let promise; @@ -158,6 +167,7 @@ define(function (require) { saveConceptSet, saveConceptSetItems, runDiagnostics, + runCohortCompareBatchJob, getVersions, getVersion, getVersionExpression, @@ -165,7 +175,8 @@ define(function (require) { copyVersion, saveConceptSetAnnotation, getConceptSetAnnotation, - deleteConceptSetAnnotation + deleteConceptSetAnnotation, + checkConceptSetFilterCount, }; return api; diff --git a/js/services/Jobs.js b/js/services/Jobs.js index 38d21b1f7..0951d58b3 100644 --- a/js/services/Jobs.js +++ b/js/services/Jobs.js @@ -3,19 +3,35 @@ define(function (require, exports) { const httpService = require('services/http'); const config = require('appConfig'); const constants = require('const'); + const FileService = require('services/file'); return class JobsService { static getList() { return httpService.doGet(constants.apiPaths.jobs()) - .then(({ data: jobs } = { data: { content: [] } }) => jobs.content); + .then(({ data: jobs } = { data: { content: [] } }) => jobs.content); } static get(id) { - return httpService.doGet(constants.apiPaths.job(id)); + return httpService.doGet(constants.apiPaths.job(id)); } static getByName(name, type) { - return httpService.doGet(constants.apiPaths.jobByName(name, type)); + return httpService.doGet(constants.apiPaths.jobByName(name, type)); + } + + /** + * Download artifact for a job execution + * @param {number} executionId - The job execution ID + * @returns {Promise} Promise that resolves when download starts + */ + static downloadArtifact(executionId) { + const url = constants.apiPaths.jobArtifact(executionId); + + return FileService.loadZipNoRename(url) + .catch((error) => { + console.error("Error when downloading artifact:", error); + throw error; + }); } } }); \ No newline at end of file diff --git a/js/services/Vocabulary.js b/js/services/Vocabulary.js index 01e935d6e..6a8f6cb1c 100644 --- a/js/services/Vocabulary.js +++ b/js/services/Vocabulary.js @@ -177,31 +177,46 @@ define(function (require, exports) { } function compareConceptSet(compareTargets, url, sourceKey) { - const vocabUrl = getVocabUrl(url, sourceKey); - - var getComparedConceptSetPromise = $.ajax({ - url: vocabUrl + 'compare', - data: JSON.stringify(compareTargets), - method: 'POST', - contentType: 'application/json', - error: authAPI.handleAccessDenied, - }); - - return getComparedConceptSetPromise; + // Use the same vocabulary for both if sourceKey provided + return compareConceptSetsOverDiffVocabularies(sourceKey, sourceKey, + { items: compareTargets[0] }, + { items: compareTargets[1] }, + true); } - - function compareConceptSetCsv(compareTargets,types, url, sourceKey) { - const vocabUrl = getVocabUrl(url, sourceKey); - - var getComparedConceptSetPromise = $.ajax({ - url: vocabUrl + 'compare-arbitrary', - data: JSON.stringify({compareTargets: compareTargets, types:types}), - method: 'POST', - contentType: 'application/json', - error: authAPI.handleAccessDenied, - }); - - return getComparedConceptSetPromise; + + function compareConceptSetCsv(compareTargets, types, url, sourceKey) { + // Use the same vocabulary for both if sourceKey provided + return compareConceptSetsCsvOverDiffVocabularies(sourceKey, sourceKey, + { items: compareTargets[0] }, + { items: compareTargets[1] }, + types[0], + types[1]); + } + + function compareConceptSetsOverDiffVocabularies(source1Key, source2Key, expression1, expression2, compareSourceCodes) { + const vocabUrl = config.webAPIRoot + 'vocabulary/'; + const compareConceptSetsRequest = { + source1Key: source1Key, + source2Key: source2Key, + expression1: expression1, + expression2: expression2, + compareSourceCodes: compareSourceCodes || false, + } + return httpService.doPost(vocabUrl + 'compare-diff-vocab', compareConceptSetsRequest).then(({ data }) => data); + } + + function compareConceptSetsCsvOverDiffVocabularies(source1Key, source2Key, expression1, expression2, expressionType1, expressionType2) { + const vocabUrl = config.webAPIRoot + 'vocabulary/'; + const compareConceptSetsRequest = { + source1Key: source1Key, + source2Key: source2Key, + expression1: expression1, + expression2: expression2, + expressionType1: expressionType1, + expressionType2: expressionType2, + compareSourceCodes: false, // CSV comparison doesn't support source codes by default + } + return httpService.doPost(vocabUrl + 'compare-arbitrary-diff-vocab', compareConceptSetsRequest).then(({ data }) => data); } async function loadAncestors(ancestors, descendants, url, sourceKey) { @@ -225,6 +240,8 @@ define(function (require, exports) { optimizeConceptSet: optimizeConceptSet, compareConceptSet: compareConceptSet, compareConceptSetCsv: compareConceptSetCsv, + compareConceptSetsOverDiffVocabularies, + compareConceptSetsCsvOverDiffVocabularies, loadDensity: loadDensity, loadAncestors, } diff --git a/js/services/file.js b/js/services/file.js index 5981c209a..3bef19ba8 100644 --- a/js/services/file.js +++ b/js/services/file.js @@ -19,10 +19,9 @@ define( callback(xhr); } }; - xhr.onerror = () => reject({ - status: xhr.status, - statusText: xhr.statusText - }); + xhr.onerror = () => { + callback(xhr); // Pass xhr to callback so we can handle it + }; xhr.responseType = "arraybuffer"; xhr.send(JSON.stringify(params)); } @@ -35,7 +34,11 @@ define( const blob = new Blob([xhr.response], { type: "octet/stream" }); saveAs(blob, filename); } else { - reject({ status: xhr.status, statusText: xhr.statusText }); + reject({ + status: xhr.status, + statusText: xhr.statusText || this._getDefaultStatusText(xhr.status), + url: url + }); } }); }); @@ -45,17 +48,46 @@ define( return new Promise((resolve, reject) => { this._makeRequest(url, method, params, (xhr) => { if (xhr.status === 200) { - const filename = xhr.getResponseHeader('Content-Disposition') - .split('filename=')[1] - .split(';')[0] - .replace(/\"/g, ''); // Clean up filename string + const contentDisposition = xhr.getResponseHeader('Content-Disposition'); + + if (!contentDisposition) { + reject({ + status: xhr.status, + statusText: 'Missing Content-Disposition header', + url: url + }); + return; + } + + const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + const filename = filenameMatch && filenameMatch[1] + ? filenameMatch[1].replace(/['"]/g, '') + : 'download.zip'; + const blob = new Blob([xhr.response], { type: "octet/stream" }); saveAs(blob, filename); resolve(); } else { + // Try to get error message from response + let errorMessage = xhr.statusText || this._getDefaultStatusText(xhr.status); + + // If response is JSON, try to parse error message + if (xhr.response && xhr.response.byteLength > 0) { + try { + const text = new TextDecoder().decode(xhr.response); + const json = JSON.parse(text); + if (json.message) { + errorMessage = json.message; + } + } catch (e) { + // Not JSON, ignore + } + } + reject({ status: xhr.status, - statusText: xhr.statusText + statusText: errorMessage, + url: url }); } }); @@ -66,6 +98,20 @@ define( const blob = new Blob([JSON.stringify(data)], { type: "text/json;charset=utf-8" }); saveAs(blob, 'data.json'); } + + _getDefaultStatusText(status) { + const statusTexts = { + 204: 'No Content', + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable' + }; + return statusTexts[status] || 'Unknown Error'; + } } return new FileService(); From ffca6e1b70c4f4edc3de0d730343e323b8028bce Mon Sep 17 00:00:00 2001 From: oleg-odysseus Date: Tue, 27 Jan 2026 15:23:04 +0100 Subject: [PATCH 2/3] Compensation for missing lock feature --- .../components/modal/conceptset-batch-compare-modal.html | 9 --------- .../components/modal/conceptset-batch-compare-modal.js | 6 ------ .../components/modal/conceptset-batch-compare-modal.less | 1 - 3 files changed, 16 deletions(-) diff --git a/js/pages/configuration/components/modal/conceptset-batch-compare-modal.html b/js/pages/configuration/components/modal/conceptset-batch-compare-modal.html index 83fe50651..8a491e1a2 100644 --- a/js/pages/configuration/components/modal/conceptset-batch-compare-modal.html +++ b/js/pages/configuration/components/modal/conceptset-batch-compare-modal.html @@ -212,15 +212,6 @@

- - -