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 @@
-
-
-
-
-
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..3707c4969
--- /dev/null
+++ b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-concepts.js
@@ -0,0 +1,430 @@
+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) => {
+ // Check if this is a cross-vocabulary comparison
+ const isCrossVocab = d.vocab1ConceptCode !== null || d.vocab2ConceptCode !== null;
+
+ if (isCrossVocab) {
+ // Use the vocab-specific values, falling back to the merged value
+ const code1 = d.vocab1ConceptCode;
+ const code2 = d.vocab2ConceptCode;
+
+ // If both exist and there's a mismatch, or if only one exists
+ if (d.conceptCodeMismatch || (code1 === null || code2 === null)) {
+ return this.renderFieldComparison(code1, code2, d.conceptCodeMismatch, d.conceptCode);
+ } else {
+ // Both exist and are the same
+ return this.escapeHtml(code1 || code2 || d.conceptCode || '');
+ }
+ } else {
+ // Not a cross-vocab comparison, use the fallback value
+ 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) {
+ const name1 = d.vocab1ConceptName;
+ const name2 = d.vocab2ConceptName;
+
+ if (d.nameMismatch || (name1 === null || name2 === null)) {
+ // There's a mismatch or only one vocabulary has this concept
+ const link1 = name1 ? commonUtils.renderLink(name1, p, buildConceptForLink(name1)) : '';
+ const link2 = name2 ? commonUtils.renderLink(name2, p, buildConceptForLink(name2)) : '';
+
+ let html = '
';
+ if (name1) {
+ html += `
`;
+ if (d.nameMismatch) {
+ html += ` `;
+ }
+ html += `${link1}
`;
+ }
+ if (name2) {
+ html += `
`;
+ if (d.nameMismatch) {
+ html += ` `;
+ }
+ html += `${link2}
`;
+ }
+ html += '
';
+ return html;
+ } else {
+ // Both exist and are the same
+ return commonUtils.renderLink(name1, p, buildConceptForLink(name1));
+ }
+ } else {
+ const conceptName = 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 hasValue1 = value1 !== null && value1 !== undefined;
+ const hasValue2 = value2 !== null && value2 !== undefined;
+
+ // If neither vocabulary has a value, use fallback
+ if (!hasValue1 && !hasValue2) {
+ return this.escapeHtml(fallbackValue || '');
+ }
+
+ // If only one vocabulary has a value (CS1 Only or CS2 Only case)
+ if (!hasValue1 || !hasValue2) {
+ let html = '
';
+ if (hasValue1) {
+ html += `
${this.escapeHtml(value1)}
`;
+ }
+ if (hasValue2) {
+ html += `
${this.escapeHtml(value2)}
`;
+ }
+ html += '
';
+ return html;
+ }
+
+ // Both vocabularies have values
+ if (hasMismatch) {
+ let html = '
';
+ html += `
${this.escapeHtml(value1)}
`;
+ html += `
${this.escapeHtml(value2)}
`;
+ html += '
';
+ return html;
+ } else {
+ // Same value in both
+ return this.escapeHtml(value1);
+ }
+ }
+
+ 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..06ad16c2e
--- /dev/null
+++ b/js/pages/concept-sets/components/tabs/subtabs/compare-results-included-sourcecodes.js
@@ -0,0 +1,430 @@
+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) => {
+ // Check if this is a cross-vocabulary comparison
+ const isCrossVocab = d.vocab1ConceptCode !== null || d.vocab2ConceptCode !== null;
+
+ if (isCrossVocab) {
+ // Use the vocab-specific values, falling back to the merged value
+ const code1 = d.vocab1ConceptCode;
+ const code2 = d.vocab2ConceptCode;
+
+ // If both exist and there's a mismatch, or if only one exists
+ if (d.conceptCodeMismatch || (code1 === null || code2 === null)) {
+ return this.renderFieldComparison(code1, code2, d.conceptCodeMismatch, d.conceptCode);
+ } else {
+ // Both exist and are the same
+ return this.escapeHtml(code1 || code2 || d.conceptCode || '');
+ }
+ } else {
+ // Not a cross-vocab comparison, use the fallback value
+ 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) {
+ const name1 = d.vocab1ConceptName;
+ const name2 = d.vocab2ConceptName;
+
+ if (d.nameMismatch || (name1 === null || name2 === null)) {
+ // There's a mismatch or only one vocabulary has this concept
+ const link1 = name1 ? commonUtils.renderLink(name1, p, buildConceptForLink(name1)) : '';
+ const link2 = name2 ? commonUtils.renderLink(name2, p, buildConceptForLink(name2)) : '';
+
+ let html = '
';
+ if (name1) {
+ html += `
`;
+ if (d.nameMismatch) {
+ html += ` `;
+ }
+ html += `${link1}
`;
+ }
+ if (name2) {
+ html += `
`;
+ if (d.nameMismatch) {
+ html += ` `;
+ }
+ html += `${link2}
`;
+ }
+ html += '
';
+ return html;
+ } else {
+ // Both exist and are the same
+ return commonUtils.renderLink(name1, p, buildConceptForLink(name1));
+ }
+ } else {
+ const conceptName = 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 hasValue1 = value1 !== null && value1 !== undefined;
+ const hasValue2 = value2 !== null && value2 !== undefined;
+
+ // If neither vocabulary has a value, use fallback
+ if (!hasValue1 && !hasValue2) {
+ return this.escapeHtml(fallbackValue || '');
+ }
+
+ // If only one vocabulary has a value (CS1 Only or CS2 Only case)
+ if (!hasValue1 || !hasValue2) {
+ let html = '
';
+ if (hasValue1) {
+ html += `
${this.escapeHtml(value1)}
`;
+ }
+ if (hasValue2) {
+ html += `
${this.escapeHtml(value2)}
`;
+ }
+ html += '
';
+ return html;
+ }
+
+ // Both vocabularies have values
+ if (hasMismatch) {
+ let html = '
';
+ html += `
${this.escapeHtml(value1)}
`;
+ html += `
${this.escapeHtml(value2)}
`;
+ html += '
';
+ return html;
+ } else {
+ // Same value in both
+ return this.escapeHtml(value1);
+ }
+ }
+
+ 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..8a491e1a2
--- /dev/null
+++ b/js/pages/configuration/components/modal/conceptset-batch-compare-modal.html
@@ -0,0 +1,420 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..24140b6f2
--- /dev/null
+++ b/js/pages/configuration/components/modal/conceptset-batch-compare-modal.js
@@ -0,0 +1,774 @@
+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 `
${escapedHtml}`;
+ }
+ }
+ ];
+
+ // 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}`;
+ }
+ }
+ ];
+
+ // 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.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),
+ };
+
+ // 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.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..aae1db872
--- /dev/null
+++ b/js/pages/configuration/components/modal/conceptset-batch-compare-modal.less
@@ -0,0 +1,488 @@
+.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;
+ }
+ }
+ }
+ }
+
+ .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 @@
-