diff --git a/js/components/conceptAddBox/concept-add-box.js b/js/components/conceptAddBox/concept-add-box.js
index d0ea0fab3..c99be68fe 100644
--- a/js/components/conceptAddBox/concept-add-box.js
+++ b/js/components/conceptAddBox/concept-add-box.js
@@ -44,6 +44,7 @@ define([
this.noPreview = params.noPreview || false;
this.conceptsToAdd = params.concepts;
this.canSelectSource = params.canSelectSource || false;
+ this.overrideHandleAddToConceptSet = params.overrideHandleAddToConceptSet;
this.isAdded = ko.observable(false);
this.defaultSelectionOptions = {
includeDescendants: ko.observable(false),
@@ -142,6 +143,10 @@ define([
}
handleSubmit() {
+ if (this.overrideHandleAddToConceptSet) {
+ const items = CommonUtils.buildConceptSetItems(this.conceptsToAdd(), this.selectionOptions());
+ this.overrideHandleAddToConceptSet(items);
+ } else {
clearTimeout(this.messageTimeout);
this.isSuccessMessageVisible(true);
this.messageTimeout = setTimeout(() => {
@@ -172,6 +177,7 @@ define([
CommonUtils.clearConceptsSelectionState(this.conceptsToAdd());
this.selectionOptions(this.defaultSelectionOptions);
}
+ }
toggleSelectionOption(option) {
const options = this.selectionOptions();
@@ -184,7 +190,6 @@ define([
setActiveConceptSet(conceptSet) {
this.activeConceptSet(conceptSet);
}
-
}
return CommonUtils.build('concept-add-box', ConceptAddBox, view);
diff --git a/js/components/conceptset/ConceptSetStore.js b/js/components/conceptset/ConceptSetStore.js
index b3dfa0685..fa51862c0 100644
--- a/js/components/conceptset/ConceptSetStore.js
+++ b/js/components/conceptset/ConceptSetStore.js
@@ -29,9 +29,9 @@ define([
const increment = () => currentValue++;
const value = () => currentValue;
- return {increment, value};
+ return { increment, value };
}
-
+
class ConceptSetStore extends AutoBind() {
constructor(props = {}) {
@@ -41,7 +41,7 @@ define([
this.current = ko.observable();
this.expression = ko.pureComputed(() => this.current() && this.current().expression);
this.currentConceptSetExpressionJson = ko.pureComputed(() => commonUtils.syntaxHighlight(this.expression()));
-
+
// the concepts in the expression
// concepts can appear more than once, the index object will keep the list of different items for each concept.
this.selectedConceptsIndex = ko.pureComputed(() => {
@@ -50,12 +50,12 @@ define([
const itemArr = result[item.concept.CONCEPT_ID] || [];
itemArr.push(item);
result[item.concept.CONCEPT_ID] = itemArr;
- return result;
- }, {});
+ return result;
+ }, {});
return index || {};
});
this.currentConceptIdentifierList = ko.pureComputed(() => Object.keys(this.selectedConceptsIndex()).join(','));
-
+
// the included conceptIds (from resolveConceptSetExpression, these are guarteed to be unique)
this.conceptSetInclusionIdentifiers = ko.observableArray();
this.currentIncludedConceptIdentifierList = ko.pureComputed(() => (this.conceptSetInclusionIdentifiers() || []).join(','));
@@ -66,27 +66,28 @@ define([
() => this.includedConcepts() && this.includedConcepts()
.reduce((result, item) => {
result[item.CONCEPT_ID] = item;
- return result;
- }, {})
+ return result;
+ }, {})
);
-
+
// the included source codes (from loadSourceCodes)
this.includedSourcecodes = ko.observableArray([]);
// the recommended concepts (from loadRecommended)
this.recommendedConcepts = ko.observableArray([]);
this.isRecommendedAvailable = ko.observable(true);
-
+
+
// loading state of individual aspects of the concept set store
this.resolvingConceptSetExpression = ko.observable(false);
this.loadingSourceCodes = ko.observable(false);
this.loadingIncluded = ko.observable(false);
this.loadingRecommended = ko.observable(false);
-
+
// metadata about this store
this.source = props.source || "unnamed";
this.title = props.title || "unnamed";
-
+
this.resolveCount = counter(); // handle out of order resolves
this.observer = ko.pureComputed(() => ko.toJSON(this.current() && this.current().expression.items()))
@@ -104,75 +105,79 @@ define([
this.recommendedConcepts(null);
this.conceptSetInclusionIdentifiers(null);
}
-
- clearIncluded() {
- ['includedConcepts', 'includedSourcecodes', 'recommendedConcepts', 'conceptSetInclusionIdentifiers']
- .forEach(key => this[key](null));
+
+ clearIncluded() {
+ ['includedConcepts', 'includedSourcecodes', 'recommendedConcepts', 'conceptSetInclusionIdentifiers']
+ .forEach(key => this[key](null));
['loadingIncluded', 'loadingSourceCodes', 'loadingRecommended']
.forEach(key => this[key](true));
- }
-
- async resolveConceptSetExpression() {
- this.clearIncluded();
- if (this.current()) {
- this.resolvingConceptSetExpression(true);
- this.resolveCount.increment();
- const currentResolve = this.resolveCount.value();
- const conceptSetExpression = this.current().expression;
- const identfiers = await vocabularyService.resolveConceptSetExpression(conceptSetExpression)
- if (currentResolve != this.resolveCount.value()) {
- return Promise.reject(constants.RESOLVE_OUT_OF_ORDER);
- }
- this.conceptSetInclusionIdentifiers(identfiers);
- this.resolvingConceptSetExpression(false);
- return identfiers;
- } else {
- return null;
- }
- }
-
- async refresh(mode) {
- this.currentConseptSetTab(mode);
- if (this.resolvingConceptSetExpression() || this.conceptSetInclusionIdentifiers() == null) // do nothing
+ }
+
+ async resolveConceptSetExpression() {
+ this.clearIncluded();
+ if (this.current()) {
+ this.resolvingConceptSetExpression(true);
+ this.resolveCount.increment();
+ const currentResolve = this.resolveCount.value();
+ const conceptSetExpression = this.current().expression;
+ const identfiers = await vocabularyService.resolveConceptSetExpression(conceptSetExpression)
+ if (currentResolve != this.resolveCount.value()) {
+ return Promise.reject(constants.RESOLVE_OUT_OF_ORDER);
+ }
+ this.conceptSetInclusionIdentifiers(identfiers);
+ this.resolvingConceptSetExpression(false);
+ return identfiers;
+ } else {
+ return null;
+ }
+ }
+
+ async refresh(mode) {
+ this.currentConseptSetTab(mode);
+ if (this.resolvingConceptSetExpression() || this.conceptSetInclusionIdentifiers() == null) // do nothing
return false;
- switch (mode) {
- case ViewMode.INCLUDED:
- this.includedConcepts() == null && await this.loadIncluded();
- break;
- case ViewMode.SOURCECODES:
- this.includedSourcecodes() == null && await this.loadSourceCodes();
- break;
+ switch (mode) {
+ case ViewMode.INCLUDED:
+ this.includedConcepts() == null && await this.loadIncluded();
+ break;
+ case ViewMode.SOURCECODES:
+ this.includedSourcecodes() == null && await this.loadSourceCodes();
+ break;
case ViewMode.RECOMMEND:
this.recommendedConcepts() == null && await this.loadRecommended();
- }
- }
-
+ break;
+ case ViewMode.MAPPINGS:
+ this.includedConcepts() == null && await this.loadIncluded();
+ break;
+ }
+ }
+
removeItemsByIndex(idxList) {
- const newItems = this.current().expression.items().filter((i,idx) => !idxList.includes(idx));
+ const newItems = this.current().expression.items().filter((i, idx) => !idxList.includes(idx));
this.current().expression.items(newItems);
}
-
- async loadIncluded() {
- const conceptIds = this.conceptSetInclusionIdentifiers();
- try {
- this.loadingIncluded(true);
- const response = await vocabularyService.getConceptsById(conceptIds);
- await vocabularyService.loadDensity(response.data);
- this.includedConcepts((response.data || []).map(item => ({
- ...item,
- ANCESTORS: null,
- isSelected: ko.observable(false)
- })));
- } catch (err) {
- console.error(err);
- } finally {
- this.loadingIncluded(false);
- }
- }
-
+
+ async loadIncluded() {
+ const conceptIds = this.conceptSetInclusionIdentifiers();
+ try {
+ this.loadingIncluded(true);
+ const response = await vocabularyService.getConceptsById(conceptIds);
+ await vocabularyService.loadDensity(response.data);
+ this.includedConcepts((response.data || []).map(item => ({
+ ...item,
+ ANCESTORS: null,
+ isSelected: ko.observable(false)
+ })));
+ } catch (err) {
+ console.error(err);
+ } finally {
+ this.loadingIncluded(false);
+ }
+ }
+
async loadSourceCodes() {
this.loadingSourceCodes(true);
- this.includedConcepts() == null && await this.loadIncluded();
+ this.includedConcepts() == null && await this.loadIncluded();
// load mapped
let concepts = this.includedConcepts();
const identifiers = concepts.map(c => c.CONCEPT_ID);
@@ -180,7 +185,7 @@ define([
const data = await vocabularyService.getMappedConceptsById(identifiers);
await vocabularyService.loadDensity(data);
const normalizedData = data.map(item => ({
- ...item,
+ ...item,
isSelected: ko.observable(false),
}))
this.includedSourcecodes(normalizedData);
@@ -201,11 +206,11 @@ define([
try {
const data = await vocabularyService.getRecommendedConceptsById(identifiers);
const includedSet = new Set(this.conceptSetInclusionIdentifiers());
- const excludedSet = new Set(this.current().expression.items().filter(i => i.isExcluded()).map(i=>i.concept.CONCEPT_ID));
+ const excludedSet = new Set(this.current().expression.items().filter(i => i.isExcluded()).map(i => i.concept.CONCEPT_ID));
const filtered = data.filter(f => !(includedSet.has(f.CONCEPT_ID) || excludedSet.has(f.CONCEPT_ID)));
- await vocabularyService.loadDensity(filtered);
+ await vocabularyService.loadDensity(filtered);
const normalizedData = filtered.map(item => ({
- ...item,
+ ...item,
isSelected: ko.observable(false),
}))
this.recommendedConcepts(normalizedData);
@@ -215,7 +220,7 @@ define([
this.isRecommendedAvailable(false);
this.recommendedConcepts([]);
} else {
- throw(err);
+ throw (err);
}
} finally {
this.loadingRecommended(false);
@@ -224,43 +229,43 @@ define([
async exportConceptSet(prefixFields = {}) {
- function formatBoolean (b) { return b ? "TRUE" : "FALSE"}
+ function formatBoolean(b) { return b ? "TRUE" : "FALSE" }
- function conceptCols(c) {
+ function conceptCols(c) {
return {
- "Concept ID": c.CONCEPT_ID,
- "Concept Code": c.CONCEPT_CODE,
+ "Concept ID": c.CONCEPT_ID,
+ "Concept Code": c.CONCEPT_CODE,
"Concept Name": c.CONCEPT_NAME,
- "Domain": c.DOMAIN_ID,
- "Vocabulary": c.VOCABULARY_ID,
+ "Domain": c.DOMAIN_ID,
+ "Vocabulary": c.VOCABULARY_ID,
"Standard Concept": c.STANDARD_CONCEPT,
"Valid Start Date": momentApi.formatDateTimeWithFormat(c.VALID_START_DATE, momentApi.ISO_DATE_FORMAT),
"Valid End Date": momentApi.formatDateTimeWithFormat(c.VALID_END_DATE, momentApi.ISO_DATE_FORMAT),
};
}
-
- function itemSettings(i) {
+
+ function itemSettings(i) {
return {
- "Exclude": formatBoolean(ko.unwrap(i.isExcluded)),
+ "Exclude": formatBoolean(ko.unwrap(i.isExcluded)),
"Descendants": formatBoolean(ko.unwrap(i.includeDescendants)),
"Mapped": formatBoolean(ko.unwrap(i.includeMapped))
};
}
// setup the left-most columns of result CSV
- const firstColumns = {...prefixFields, "Concept Set ID": this.current().id, "Name": this.current().name()};
+ const firstColumns = { ...prefixFields, "Concept Set ID": this.current().id, "Name": this.current().name() };
// fetch included and source codes
this.includedConcepts() == null && await this.loadIncluded();
this.includedSourcecodes() == null && await this.loadSourceCodes();
- const expressionRows = this.expression().items().map((item) => ({...firstColumns, ...conceptCols(item.concept), ...itemSettings(item)}));
+ const expressionRows = this.expression().items().map((item) => ({ ...firstColumns, ...conceptCols(item.concept), ...itemSettings(item) }));
const expressionCsv = csvUtils.toCsv(expressionRows);
- const includedRows = this.includedConcepts().map((ic) => ({...firstColumns, ...conceptCols(ic)}));
+ const includedRows = this.includedConcepts().map((ic) => ({ ...firstColumns, ...conceptCols(ic) }));
const includedCsv = csvUtils.toCsv(includedRows);
- const mappedRows = this.includedSourcecodes().map((ic) => ({...firstColumns, ...conceptCols(ic)}));
+ const mappedRows = this.includedSourcecodes().map((ic) => ({ ...firstColumns, ...conceptCols(ic) }));
const mappedCsv = csvUtils.toCsv(mappedRows);
const zip = new JSZip();
@@ -268,24 +273,24 @@ define([
zip.file("includedConcepts.csv", includedCsv);
zip.file("mappedConcepts.csv", mappedCsv);
- const zipFile = await zip.generateAsync({type:"blob", compression: "DEFLATE"});
+ const zipFile = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
saveAs(zipFile, `${this.current().name()}.zip`);
-
- }
-
+
+ }
+
static activeStores() {
const activeKeys = Object.keys(constants.ConceptSetSources).filter(key => !!this.getStore(key).current() && this.getStore(key).isEditable());
return activeKeys.map(k => ConceptSetStore.getStore(k));
}
-
+
static getStore(source) {
return registry[source];
}
-
+
static sourceKeys() {
return constants.ConceptSetSources;
}
-
+
// convienience getters
static featureAnalysis() {
return ConceptSetStore.getStore(constants.ConceptSetSources.featureAnalysis);
@@ -294,7 +299,7 @@ define([
static repository() {
return ConceptSetStore.getStore(constants.ConceptSetSources.repository);
}
-
+
static cohortDefinition() {
return ConceptSetStore.getStore(constants.ConceptSetSources.cohortDefinition);
}
@@ -302,23 +307,23 @@ define([
static characterization() {
return ConceptSetStore.getStore(constants.ConceptSetSources.characterization);
}
-
+
static incidenceRates() {
return ConceptSetStore.getStore(constants.ConceptSetSources.incidenceRates);
- }
+ }
}
-
+
// define a registry to contain individual stores
const registry = {};
-
+
// Define ConceptSetStore for each module with conceptSet tab
Object.keys(constants.ConceptSetSources).forEach(k => {
- registry[k] = new ConceptSetStore({source: k});
+ registry[k] = new ConceptSetStore({ source: k });
// freeze the object to prevent any changes to the observable references, which should not be changed.
Object.freeze(registry[k]);
});
-
- return ConceptSetStore;
-
+
+ return ConceptSetStore;
+
});
diff --git a/js/components/conceptset/const.js b/js/components/conceptset/const.js
index e8e6292ff..6101275d5 100644
--- a/js/components/conceptset/const.js
+++ b/js/components/conceptset/const.js
@@ -14,6 +14,7 @@ define([
RECOMMEND: 'recommend',
EXPORT: 'conceptset-export',
IMPORT: 'conceptset-import',
+ MAPPINGS: 'resolve-mappings'
};
const ConceptSetSources = {
diff --git a/js/pages/concept-sets/components/tabs/manual-mapping.html b/js/pages/concept-sets/components/tabs/manual-mapping.html
new file mode 100644
index 000000000..4ce758d21
--- /dev/null
+++ b/js/pages/concept-sets/components/tabs/manual-mapping.html
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/js/pages/concept-sets/components/tabs/manual-mapping.js b/js/pages/concept-sets/components/tabs/manual-mapping.js
new file mode 100644
index 000000000..94c8c53a1
--- /dev/null
+++ b/js/pages/concept-sets/components/tabs/manual-mapping.js
@@ -0,0 +1,286 @@
+define([
+ 'knockout',
+ 'text!./manual-mapping.html',
+ 'components/Component',
+ 'utils/AutoBind',
+ 'utils/CommonUtils',
+ 'atlas-state',
+ 'const',
+ 'utils/Renderers',
+ 'services/MomentAPI',
+ 'components/conceptset/utils',
+ 'services/Vocabulary',
+ 'less!./manual-mapping.less',
+ 'components/conceptAddBox/concept-add-box',
+ 'components/dataSourceSelect',
+ 'components/conceptAddBox/preview/conceptset-expression-preview',
+ 'components/conceptAddBox/preview/included-preview',
+ 'components/conceptAddBox/preview/included-preview-badge',
+ 'components/conceptAddBox/preview/included-sourcecodes-preview',
+ 'components/conceptset/included-sourcecodes',
+], function (
+ ko,
+ view,
+ Component,
+ AutoBind,
+ commonUtils,
+ sharedState,
+ globalConstants,
+ renderers,
+ momentApi,
+ conceptSetUtils,
+ vocabularyService,
+) {
+
+ class ManualMapping extends AutoBind(Component) {
+ constructor(params) {
+ super(params);
+ this.loading = params.loading;
+ this.tableOptions = params.tableOptions || commonUtils.getTableOptions('M');
+
+
+ this.currentConcept = params.currentConcept;
+ this.conceptSetStore = params.conceptSetStore;
+
+ this.standardConceptsWithCounterparts = params.standardConceptsWithCounterparts;
+ this.resultConceptSetItems = params.resultConceptSetItems;
+ this.showManualMappingModal = params.showManualMappingModal;
+ this.addedSourceCodesViaManualMapping = params.addedSourceCodesViaManualMapping;
+
+ this.nonStandardConceptsForCurrentStandard = ko.observableArray([]);
+ this.loadingNonStandardConceptsForCurrentStandard = ko.observable(false);
+
+ this.includedSourcecodes = ko.observableArray([]);
+ this.loadingIncludedSourceCodes = ko.observable(false);
+
+
+ this.relatedSourcecodesColumns = globalConstants.getRelatedSourcecodesColumns(sharedState, { canEditCurrentConceptSet: this.canEdit },
+ (data, selected) => {
+ const conceptIds = data.map(c => c.CONCEPT_ID);
+ ko.utils.arrayForEach(this.includedSourcecodes(), c => conceptIds.indexOf(c.CONCEPT_ID) > -1 && c.isSelected(selected));
+ this.includedSourcecodes.valueHasMutated();
+ });
+
+
+ this.relatedSourcecodesColumns.forEach(column => {
+ if (column.data === 'CONCEPT_NAME') {
+ column.render = (s, p, d) => {
+ var valid = d.INVALID_REASON_CAPTION == 'Invalid' ? 'invalid' : '';
+ return '' + d.CONCEPT_NAME + '';
+ };
+ }
+ });
+
+ this.relatedSourcecodesOptions = globalConstants.relatedSourcecodesOptions;
+
+ this.buttonTooltip = conceptSetUtils.getPermissionsText(true, "test");
+
+ const getNonStandardConceptsColumns = (context, commonUtils, selectAllFn) => [
+ {
+ title: '',
+ orderable: false,
+ searchable: false,
+ className: 'text-center',
+ render: () => renderers.renderCheckbox('isSelected', context.canEditCurrentConceptSet()),
+ renderSelectAll: context.canEditCurrentConceptSet(),
+ selectAll: selectAllFn
+ },
+ {
+ title: ko.i18n('columns.id', 'Id'),
+ data: 'CONCEPT_ID'
+ },
+ {
+ title: ko.i18n('columns.code', 'Code'),
+ data: 'CONCEPT_CODE'
+ },
+ {
+ title: ko.i18n('columns.name', 'Name'),
+ render: function (s, p, d) {
+ return p === 'display'
+ ? '' + d.CONCEPT_NAME + '
'
+ : d.CONCEPT_NAME;
+ }
+ },
+ {
+ title: ko.i18n('columns.class', 'Class'),
+ data: 'CONCEPT_CLASS_ID'
+ },
+ {
+ title: ko.i18n('columns.standardConceptCaption', 'Standard Concept Caption'),
+ data: 'STANDARD_CONCEPT_CAPTION',
+ visible: false
+ },
+ {
+ title: ko.i18n('columns.validStartDate', 'Valid Start Date'),
+ render: (s, type, d) => type === "sort" ? +d['VALID_START_DATE'] :
+ momentApi.formatDateTimeWithFormat(d['VALID_START_DATE'], momentApi.DATE_FORMAT),
+ visible: false
+ },
+ {
+ title: ko.i18n('columns.validEndDate', 'Valid End Date'),
+ render: (s, type, d) => type === "sort" ? +d['VALID_END_DATE'] :
+ momentApi.formatDateTimeWithFormat(d['VALID_END_DATE'], momentApi.DATE_FORMAT),
+ visible: false
+ },
+ {
+ title: ko.i18n('columns.domain', 'Domain'),
+ data: 'DOMAIN_ID'
+ },
+ {
+ title: ko.i18n('columns.vocabulary', 'Vocabulary'),
+ data: 'VOCABULARY_ID'
+ },
+ ];
+
+ this.nonStandardConceptsColumns = getNonStandardConceptsColumns({
+ canEditCurrentConceptSet: this.canEdit
+ },
+ commonUtils,
+ (data, selected) => {
+ const conceptIds = data.map(c => c.CONCEPT_ID);
+ ko.utils.arrayForEach(this.nonStandardConceptsForCurrentStandard(), c => conceptIds.indexOf(c.CONCEPT_ID) > -1 && c.isSelected(selected));
+ this.nonStandardConceptsForCurrentStandard.valueHasMutated();
+ });
+
+ this.overrideHandleAddToConceptSet = (selectedItemsToAdd) => { this.overrideAddToConceptSet(selectedItemsToAdd); };
+
+ this.loadNonStandardForCurrentStandard();
+ this.loadIncludedSourceCodes();
+ }
+
+
+ overrideAddToConceptSet(selectedItemsToAdd) {
+ console.log("overrideAddToConceptSet");
+ this.addedSourceCodesViaManualMapping(selectedItemsToAdd);
+ }
+
+ async loadNonStandardForCurrentStandard() {
+ let standardConceptId = this.currentConcept.CONCEPT_ID;
+ if (!standardConceptId) {
+ console.warn("No standard concept available to process.");
+ return;
+ }
+ try {
+ this.loadingNonStandardConceptsForCurrentStandard(true);
+ let relatedNonStandardConceptsIds = this.currentConcept.mapped_from;
+ let relatedNonStandardConcepts = this.resultConceptSetItems().filter(item => item.concept.STANDARD_CONCEPT === 'N' && relatedNonStandardConceptsIds.some(id => item.concept.CONCEPT_ID === id));
+ const formattedConcepts = relatedNonStandardConcepts.map(concept => {
+ return {
+ ...concept.concept,
+ isSelected: ko.observable(false),
+ isExcluded: ko.observable(false),
+ includeDescendants: ko.observable(false),
+ includeMapped: ko.observable(false)
+ };
+ });
+ this.nonStandardConceptsForCurrentStandard(formattedConcepts); // Update the observable array with filtered related non-standard concepts
+ } catch (err) {
+ console.error("Error loading non-standard concepts for the standard concept ID: " + standardConceptId, err);
+ this.nonStandardConceptsForCurrentStandard([]); // Handle error by providing an empty array
+ } finally {
+ this.loadingNonStandardConceptsForCurrentStandard(false);
+ }
+ }
+
+ formatDate(date) {
+ return momentApi.formatDateTimeWithFormat(date, momentApi.ISO_DATE_FORMAT);
+ }
+
+
+ deepClone(obj) {
+ return JSON.parse(JSON.stringify(obj));
+ }
+
+ mapAllSelectedToGivenStandardConcept() {
+
+ if (!this.currentConcept || typeof this.currentConcept.CONCEPT_ID === 'undefined') {
+ console.warn("No valid standard concept available to map.");
+ return;
+ }
+
+ let standardConceptId = this.currentConcept.CONCEPT_ID;
+
+ const selectedNonStandardConcepts = this.nonStandardConceptsForCurrentStandard().filter(item => item.isSelected());
+
+ if (selectedNonStandardConcepts.length === 0) {
+ console.warn("No selected non-standard concepts to map.");
+ return;
+ }
+
+ // Remove the selected non-standard concepts from resultConceptSetItems
+ let resultConceptSetItemsWithoutMappedNonStandardConcepts = this.resultConceptSetItems().filter(item => !selectedNonStandardConcepts.some(selectedConcept => selectedConcept.CONCEPT_ID === item.concept.CONCEPT_ID));
+ this.resultConceptSetItems(resultConceptSetItemsWithoutMappedNonStandardConcepts);
+
+ //Remove selected concepts from mapped_from of remaining standard concepts
+ this.standardConceptsWithCounterparts().forEach(standardConcept => {
+ const clonedData = this.deepClone(standardConcept.mapped_from);
+ standardConcept.mapped_from = clonedData.filter(
+ id => !selectedNonStandardConcepts.map(concept => concept.CONCEPT_ID).includes(id)
+ );
+ });
+
+ // Add current standard concept to resultConceptSetItems if not already present
+ if (!this.resultConceptSetItems().some(item => item.concept.CONCEPT_ID === standardConceptId)) {
+ this.resultConceptSetItems().push({
+ concept: this.currentConcept,
+ isSelected: ko.observable(false),
+ isExcluded: ko.observable(false),
+ includeDescendants: ko.observable(false),
+ includeMapped: ko.observable(false)
+ });
+ }
+
+ // Optionally remove the current standard concept from standardConceptsWithCounterparts
+ let standardConceptsWithCounterpartsWithoutCurrentMappedConcept = this.standardConceptsWithCounterparts().filter(item => item.CONCEPT_ID !== standardConceptId);
+ this.standardConceptsWithCounterparts(standardConceptsWithCounterpartsWithoutCurrentMappedConcept);
+
+ this.showManualMappingModal(false);
+ console.log("Mapping complete: Selected non-standard concepts have been mapped to the current standard concept.");
+ }
+
+
+ async loadIncludedSourceCodes() {
+ this.loadingIncludedSourceCodes(true);
+
+ let standardConceptId = this.currentConcept.CONCEPT_ID;
+ const selectedNonStandardConcepts = this.nonStandardConceptsForCurrentStandard().filter(item => item.isSelected());
+
+ let allConceptsIds = [
+ this.currentConcept.CONCEPT_ID,
+ ...selectedNonStandardConcepts.map(concept => concept.CONCEPT_ID)
+ ];
+ try {
+ const data = await vocabularyService.getMappedConceptsById(allConceptsIds);
+ await vocabularyService.loadDensity(data);
+ const normalizedData = data.map(item => ({
+ ...item,
+ isSelected: ko.observable(false),
+ }))
+ this.includedSourcecodes(normalizedData);
+ return data;
+ } catch (err) {
+ console.error(err);
+ } finally {
+ this.loadingIncludedSourceCodes(false);
+ }
+ }
+
+ isEnabledMapAllSelectedToGivenStandardConcept() {
+ return true;
+ }
+
+ canAddConcepts() {
+ return true;
+ }
+
+ getSelectedIncludedSourcecodes() {
+ return ko.unwrap(this.includedSourcecodes) && commonUtils.getSelectedConcepts(this.includedSourcecodes);
+ }
+
+ canEdit() {
+ return true;
+ }
+ }
+
+ return commonUtils.build('manual-mapping', ManualMapping, view);
+});
\ No newline at end of file
diff --git a/js/pages/concept-sets/components/tabs/manual-mapping.less b/js/pages/concept-sets/components/tabs/manual-mapping.less
new file mode 100644
index 000000000..aaddca9a8
--- /dev/null
+++ b/js/pages/concept-sets/components/tabs/manual-mapping.less
@@ -0,0 +1,23 @@
+.concept-add-container {
+ margin-left: 0.5rem;
+
+ .concept-add-box-container {
+ display: inline-flex;
+ }
+}
+.configureSourceTable tbody td, .configureSourceTable th {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+ cursor: default;
+ padding-left: 7px;
+ padding-right: 7px;
+ font-size: 11px;
+ border-bottom: 1px solid #ccc;
+ vertical-align: top;
+ line-height: 16px;
+}
+
+.configureSourceTable tr td th {
+ padding: 2px 5px;
+ user-select: none;
+}
\ No newline at end of file
diff --git a/js/pages/concept-sets/components/tabs/resolve-mappings.html b/js/pages/concept-sets/components/tabs/resolve-mappings.html
new file mode 100644
index 000000000..8382196e3
--- /dev/null
+++ b/js/pages/concept-sets/components/tabs/resolve-mappings.html
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/js/pages/concept-sets/components/tabs/resolve-mappings.js b/js/pages/concept-sets/components/tabs/resolve-mappings.js
new file mode 100644
index 000000000..dbbed3fb0
--- /dev/null
+++ b/js/pages/concept-sets/components/tabs/resolve-mappings.js
@@ -0,0 +1,447 @@
+define([
+ 'knockout',
+ 'text!./resolve-mappings.html',
+ 'components/Component',
+ 'utils/AutoBind',
+ 'utils/CommonUtils',
+ 'atlas-state',
+ 'const',
+ 'services/ConceptSet',
+ 'components/conceptset/utils',
+ 'utils/Renderers',
+ 'services/MomentAPI',
+ 'services/http',
+ 'less!./resolve-mappings.less',
+ './manual-mapping',
+ 'components/conceptAddBox/concept-add-box',
+ 'components/dataSourceSelect',
+ 'components/conceptAddBox/preview/conceptset-expression-preview',
+ 'components/conceptAddBox/preview/included-preview',
+ 'components/conceptAddBox/preview/included-preview-badge',
+ 'components/conceptAddBox/preview/included-sourcecodes-preview',
+
+], function (
+ ko,
+ view,
+ Component,
+ AutoBind,
+ commonUtils,
+ sharedState,
+ globalConstants,
+ conceptSetService,
+ conceptSetUtils,
+ renderers,
+ MomentApi,
+ httpService,
+) {
+
+ class ResolveConceptSetMappings extends AutoBind(Component) {
+ constructor(params) {
+ super(params);
+ this.loading = params.loading;
+ this.canEdit = params.canEdit;
+ this.tableOptions = params.tableOptions || commonUtils.getTableOptions('M');
+ const tableOptions = this.tableOptions;
+ this.conceptSetStore = params.conceptSetStore;
+
+ this.showPreviewModal = ko.observable(false);
+
+
+ this.previewConcepts = ko.observableArray();
+ this.previewTabsParams = ko.observable({
+ tabs: [
+ {
+ title: ko.i18n('components.conceptAddBox.previewModal.tabs.concepts', 'Concepts'),
+ key: 'expression',
+ componentName: 'conceptset-expression-preview',
+ componentParams: {
+ tableOptions,
+ conceptSetItems: this.previewConcepts
+ },
+ },
+ {
+ title: ko.i18n('cs.manager.tabs.includedConcepts', 'Included Concepts'),
+ key: 'included',
+ componentName: 'conceptset-list-included-preview',
+ componentParams: {
+ tableOptions,
+ previewConcepts: this.previewConcepts
+ },
+ hasBadge: true,
+ },
+ {
+ title: ko.i18n('cs.manager.tabs.includedSourceCodes', 'Source Codes'),
+ key: 'included-sourcecodes',
+ componentName: 'conceptset-list-included-sourcecodes-preview',
+ componentParams: {
+ tableOptions,
+ previewConcepts: this.previewConcepts
+ },
+ }
+ ]
+ });
+
+ this.commonUtils = commonUtils;
+ this.datatableLanguage = ko.i18n('datatable.language');
+ this.conceptSetItems = ko.pureComputed(() => (this.conceptSetStore.current() && this.conceptSetStore.current().expression.items()) || []);
+
+ this.initialIncludedConcepts = ko.observableArray([]);
+ this.initialStandardConceptsWithCounterparts = ko.observableArray([]);
+ this.resultConceptSetItems = ko.observableArray([]);
+ this.loadingResultConceptSetItems = ko.observable(false);
+
+ this.standardConceptsWithCounterparts = ko.observableArray([]);
+ this.loadingStandardConceptsWithCounterparts = ko.observable(false);
+
+ this.isResolveButtonEnabled = ko.pureComputed(() => {
+ const mappings = this.standardConceptsWithCounterparts();
+ return mappings.some(concept => {
+ return concept.mapped_from && concept.mapped_from.length === 1 && mappings.filter(m => m.mapped_from && m.mapped_from.includes(concept.mapped_from[0])).length === 1;
+ });
+ });
+
+ this.addedSourceCodesViaManualMapping = ko.observableArray([]);
+ this.enrichStandardWithCounterpartsAndResultsWithSourceCodes = this.enrichStandardWithCounterpartsAndResultsWithSourceCodes.bind(this);
+ this.addedSourceCodesViaManualMapping.subscribe((addedSourceCodes) => {
+ this.enrichStandardWithCounterpartsAndResultsWithSourceCodes(addedSourceCodes);
+ });
+
+ /** MANUAL MAPPING VARIABLES */
+ this.showManualMappingModal = ko.observable(false);
+ this.manualMappingModalParams = ko.observable(null);
+ this.standardConceptsWithCounterpartsRowClick = (conceptRowData) => {
+ this.openManualMapping(conceptRowData);
+ }
+
+ this.openManualMapping = (conceptRowData) => {
+ this.manualMappingModalParams(
+ {
+ currentConcept: conceptRowData,
+ tableOptions: this.tableOptions,
+ conceptSetStore: this.conceptSetStore,
+ standardConceptsWithCounterparts: this.standardConceptsWithCounterparts,
+ resultConceptSetItems: this.resultConceptSetItems,
+ showManualMappingModal: this.showManualMappingModal,
+ addedSourceCodesViaManualMapping: this.addedSourceCodesViaManualMapping,
+ }
+ );
+ this.showManualMappingModal(true);
+ }
+
+ this.canEditCurrentConceptSet = params.canEdit;
+ this.resultConceptSetColumns = [
+ {
+ data: 'concept.CONCEPT_ID',
+ },
+ {
+ data: 'concept.CONCEPT_CODE',
+ },
+ {
+ render: commonUtils.renderBoundLink,
+ },
+ {
+ data: 'concept.DOMAIN_ID',
+ },
+ {
+ data: 'concept.STANDARD_CONCEPT',
+ visible: false,
+ },
+ {
+ data: 'concept.STANDARD_CONCEPT_CAPTION',
+ },
+ ];
+
+ this.buttonTooltip = conceptSetUtils.getPermissionsText(true, "test");
+
+ this.standardConceptsColumns = [
+ {
+ title: ko.i18n('columns.id', 'Id'),
+ data: 'CONCEPT_ID'
+ },
+ {
+ title: ko.i18n('columns.code', 'Code'),
+ data: 'CONCEPT_CODE'
+ },
+ {
+ title: ko.i18n('columns.name', 'Name'),
+ data: 'CONCEPT_NAME',
+ render: function (s, p, d) {
+ var valid = d.INVALID_REASON_CAPTION == 'Invalid' ? 'invalid' : '';
+ if (p === 'display') {
+ return '' + d.CONCEPT_NAME + '';
+ }
+ return d.CONCEPT_NAME;
+ }
+ },
+ {
+ title: ko.i18n('columns.class', 'Class'),
+ data: 'CONCEPT_CLASS_ID'
+ },
+ {
+ title: ko.i18n('columns.standardConceptCaption', 'Standard Concept Caption'),
+ data: 'STANDARD_CONCEPT_CAPTION',
+ visible: false
+ },
+ {
+ title: ko.i18n('columns.validStartDate', 'Valid Start Date'),
+ render: (s, type, d) => type === "sort" ? +d['VALID_START_DATE'] :
+ MomentApi.formatDateTimeWithFormat(d['VALID_START_DATE'], MomentApi.DATE_FORMAT),
+ visible: false
+ },
+ {
+ title: ko.i18n('columns.validEndDate', 'Valid End Date'),
+ render: (s, type, d) => type === "sort" ? +d['VALID_END_DATE'] :
+ MomentApi.formatDateTimeWithFormat(d['VALID_END_DATE'], MomentApi.DATE_FORMAT),
+ visible: false
+ },
+ {
+ title: ko.i18n('columns.domain', 'Domain'),
+ data: 'DOMAIN_ID'
+ },
+ {
+ title: ko.i18n('columns.vocabulary', 'Vocabulary'),
+ data: 'VOCABULARY_ID'
+ },
+ {
+ title: ko.i18n('columns.mappedNonStandardCount', 'Mapped Non-Standard Count'),
+ data: function (item) {
+ return ko.computed(() => {
+ return this.countMappedNonStandard(item);
+ }).peek();
+ }.bind(this)
+ },
+ ];
+
+ this.includedSourcecodes = this.conceptSetStore.includedSourcecodes;
+
+ this.showWarningModal = ko.observable(false);
+ this.warningModalMessage = ko.observable('');
+ this.closeWarningModal = () => {
+ this.showWarningModal(false);
+ };
+
+ this.loadInitialIncludedConcepts(); //detached copy of the initial concept set, used for resetting to the initial state and first fill of resultConceptSetItems
+ this.loadResultConceptSetItems(); //detached copy of the initialIncludedConcepts
+ this.loadStandardWithCounterparts();
+ }
+
+ countMappedNonStandard(concept) {
+ if (concept.mapped_from) {
+ return concept.mapped_from.length.toString();
+ }
+ return "0";
+ }
+
+ deepClone(item) {
+ return JSON.parse(JSON.stringify(item));
+ }
+
+ loadInitialIncludedConcepts() {
+ const uniqueItemsMap = new Map();
+ this.conceptSetItems().forEach((item) => {
+ const conceptId = item.concept.CONCEPT_ID;
+ if (!uniqueItemsMap.has(conceptId)) {
+ const clonedItem = this.deepClone(item);
+ uniqueItemsMap.set(conceptId, {
+ ...clonedItem,
+ idx: uniqueItemsMap.size, // idx will correspond to the index in the map
+ isSelected: ko.observable(false),
+ isExcluded: ko.observable(false),
+ includeDescendants: ko.observable(false),
+ includeMapped: ko.observable(false),
+ });
+ }
+ });
+ this.initialIncludedConcepts(Array.from(uniqueItemsMap.values()));
+ }
+
+ async loadResultConceptSetItems() {
+ try {
+ this.loadingResultConceptSetItems(true);
+ const uniqueItemsMap = new Map();
+ this.initialIncludedConcepts().forEach((item) => {
+ const conceptId = item.concept.CONCEPT_ID;
+ if (!uniqueItemsMap.has(conceptId)) {
+ const clonedItem = this.deepClone(item);
+ uniqueItemsMap.set(conceptId, {
+ ...clonedItem,
+ idx: uniqueItemsMap.size, // idx will correspond to the index in the map
+ isSelected: ko.observable(false),
+ isExcluded: ko.observable(false),
+ includeDescendants: ko.observable(false),
+ includeMapped: ko.observable(false),
+ });
+ }
+ });
+ this.resultConceptSetItems(Array.from(uniqueItemsMap.values()));
+ } catch (err) {
+ console.error("Error loading result concept set items:", err);
+ } finally {
+ this.loadingResultConceptSetItems(false);
+ }
+ }
+
+ async enrichStandardWithCounterpartsAndResultsWithSourceCodes(addedSourceCodes) {
+ try {
+ this.loadingStandardConceptsWithCounterparts(true);
+ this.loadingResultConceptSetItems(true);
+ let standardForNonStandardWithMappings = await this.loadRelatedStandardConceptsWithMapping(addedSourceCodes);
+ let currentStandardsWithCounterparts = this.standardConceptsWithCounterparts();
+ let combinedListOfStandardsWithMappings = [
+ ...standardForNonStandardWithMappings,
+ ...currentStandardsWithCounterparts.filter(item => !standardForNonStandardWithMappings || standardForNonStandardWithMappings.length === 0 || standardForNonStandardWithMappings.some(existingItem => existingItem.CONCEPT_ID !== item.CONCEPT_ID))];
+
+ this.standardConceptsWithCounterparts(combinedListOfStandardsWithMappings);
+
+ //add to current results
+ let currentResults = this.resultConceptSetItems();
+
+ let addedConcepts = addedSourceCodes.map(sc => {
+ return {
+ ...sc,
+ isSelected: ko.observable(false),
+ isExcluded: ko.observable(sc.isExcluded),
+ includeDescendants: ko.observable(sc.isExcluded),
+ includeMapped: ko.observable(sc.isExcluded),
+ }
+ });
+
+ let combinedResultItems = [
+ ...currentResults,
+ ...addedConcepts,
+ ];
+ this.resultConceptSetItems(combinedResultItems);
+ } catch (err) {
+ console.error("Error enriching standard counterparts:", err);
+ } finally {
+ this.loadingStandardConceptsWithCounterparts(false);
+ this.loadingResultConceptSetItems(false);
+ }
+ }
+
+ async loadStandardWithCounterparts() {
+ try {
+ this.loadingStandardConceptsWithCounterparts(true);
+ let standardForNonStandardWithMappings = await this.loadRelatedStandardConceptsWithMapping(this.initialIncludedConcepts());
+ // Set the data avoiding duplicates
+ this.standardConceptsWithCounterparts(standardForNonStandardWithMappings);
+ this.initialStandardConceptsWithCounterparts(this.deepClone(standardForNonStandardWithMappings));
+ } catch (err) {
+ console.error("Error loading standard counterparts:", err);
+ } finally {
+ this.loadingStandardConceptsWithCounterparts(false);
+ }
+ }
+
+ async loadRelatedStandardConceptsWithMapping(concepts) {
+ const nonStandardConcepts = concepts.filter(c => c.concept.STANDARD_CONCEPT !== 'S' && c.concept.STANDARD_CONCEPT !== 'C');
+ let nonStandardConceptsIds = nonStandardConcepts.map(concept => concept.concept.CONCEPT_ID);
+ const { data: relatedStandardMappedConcepts } = await httpService.doPost(sharedState.vocabularyUrl() + 'related-standard', nonStandardConceptsIds);
+
+ const relatedStandardMappedConceptsWithFlags = relatedStandardMappedConcepts.map(concept => {
+ return {
+ ...concept,
+ isSelected: ko.observable(false),
+ isExcluded: ko.observable(false),
+ includeDescendants: ko.observable(false),
+ includeMapped: ko.observable(false),
+ }
+ });
+
+ return Array.from(relatedStandardMappedConceptsWithFlags);
+ }
+
+ renderCheckbox(field, clickable = true) {
+ return ``;
+ }
+
+ getSelectedConcepts() {
+ return ko.unwrap(this.includedSourcecodes) && commonUtils.getSelectedConcepts(this.includedSourcecodes);
+ }
+
+ resolveOneToOneMappings() {
+ const conceptIdsToRemoveFromStandard = new Set();
+ let showWarning = false;
+ const updatedItems = this.resultConceptSetItems().map((item) => {
+ if (item.concept.STANDARD_CONCEPT === 'N') {
+ const mappedStandardConcepts = this.standardConceptsWithCounterparts().filter(m => m.mapped_from && m.mapped_from.some(mappedId => mappedId === item.concept.CONCEPT_ID));
+ if (mappedStandardConcepts.length > 1) {
+ showWarning = true;
+ return item;
+ }
+ if (mappedStandardConcepts.length === 1) {
+ if (mappedStandardConcepts[0].mapped_from.length === 1) {
+ conceptIdsToRemoveFromStandard.add(mappedStandardConcepts[0].CONCEPT_ID);
+ return {
+ ...item,
+ concept: mappedStandardConcepts[0],
+ isSelected: ko.observable(false),
+ isExcluded: ko.observable(false),
+ includeDescendants: ko.observable(false),
+ includeMapped: ko.observable(false),
+ };
+ }
+ }
+ }
+ return item;
+ });
+
+ const unmappableStandardConceptsToRetain = this.standardConceptsWithCounterparts().filter(mapping =>
+ !conceptIdsToRemoveFromStandard.has(mapping.CONCEPT_ID)
+ );
+
+ this.standardConceptsWithCounterparts(unmappableStandardConceptsToRetain);
+ this.resultConceptSetItems(updatedItems);
+
+ if (showWarning) {
+ this.warningModalMessage('Encountered non-standard concepts mapped to multiple standard concepts. Please resolve those mappings manually.');
+ this.showWarningModal(true);
+ }
+ }
+
+ handlePreview() {
+ let itemsForPreview = this.resultConceptSetItems().map(item => {
+ return {
+ ...item,
+ isExcluded: ko.observable(false),
+ includeDescendants: ko.observable(false),
+ includeMapped: ko.observable(false),
+ }
+ })
+ this.previewConcepts(itemsForPreview);
+ this.showPreviewModal(true);
+ }
+
+ handleSubmit() {
+ let itemsForPreview = this.resultConceptSetItems().map(item => {
+ return {
+ ...item,
+ isExcluded: ko.observable(false),
+ includeDescendants: ko.observable(false),
+ includeMapped: ko.observable(false),
+ }
+ })
+ this.conceptSetStore.current().expression.items(itemsForPreview);
+ }
+
+ isPreviewAvailable() {
+ return true;
+ }
+
+ resetToInitialState() {
+ this.loadResultConceptSetItems();
+ let initialConcepts = this.initialStandardConceptsWithCounterparts().map(initialConcept => {
+ return {
+ ...this.deepClone(initialConcept),
+ isSelected: ko.observable(false),
+ isExcluded: ko.observable(false),
+ includeDescendants: ko.observable(false),
+ includeMapped: ko.observable(false),
+ };
+ });
+ this.standardConceptsWithCounterparts(initialConcepts);
+ }
+ }
+
+ return commonUtils.build('resolve-mappings', ResolveConceptSetMappings, view);
+});
\ No newline at end of file
diff --git a/js/pages/concept-sets/components/tabs/resolve-mappings.less b/js/pages/concept-sets/components/tabs/resolve-mappings.less
new file mode 100644
index 000000000..f9e30b8d6
--- /dev/null
+++ b/js/pages/concept-sets/components/tabs/resolve-mappings.less
@@ -0,0 +1,19 @@
+div[data-bind*="visible: showWarningModal"] {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: rgba(0, 0, 0, 0.7);
+ z-index: 1000;
+}
+
+.warning-modal-content {
+ background-color: white;
+ padding: 20px;
+ border-radius: 5px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
+}
\ No newline at end of file
diff --git a/js/pages/concept-sets/conceptset-manager.js b/js/pages/concept-sets/conceptset-manager.js
index 7402046ed..2af825dd5 100644
--- a/js/pages/concept-sets/conceptset-manager.js
+++ b/js/pages/concept-sets/conceptset-manager.js
@@ -44,7 +44,8 @@ define([
'components/authorship',
'components/name-validation',
'components/ac-access-denied',
- 'components/versions/versions'
+ 'components/versions/versions',
+ './components/tabs/resolve-mappings'
], function (
ko,
view,
@@ -327,6 +328,18 @@ define([
hasBadge: true,
preload: true,
},
+ {
+ title: ko.i18n('cs.manager.tabs.resolve-mappings', 'Resolve mappings'),
+ key: ViewMode.MAPPINGS,
+ componentName: 'resolve-mappings',
+ componentParams: {
+ ...params,
+ canEdit: this.canEdit,
+ conceptSetStore: this.conceptSetStore,
+ selectedSource: this.selectedSource,
+ loading: this.conceptSetStore.loadingIncluded,
+ },
+ },
];
this.selectedTab = ko.observable(0);
diff --git a/js/pages/concept-sets/const.js b/js/pages/concept-sets/const.js
index 87b99eb2b..f5bc4cc99 100644
--- a/js/pages/concept-sets/const.js
+++ b/js/pages/concept-sets/const.js
@@ -10,6 +10,7 @@ define(
RECOMMEND: conceptSetConstants.ViewMode.RECOMMEND,
EXPORT: conceptSetConstants.ViewMode.EXPORT,
IMPORT: conceptSetConstants.ViewMode.IMPORT,
+ MAPPINGS: conceptSetConstants.ViewMode.MAPPINGS,
EXPLORE: 'explore',
COMPARE: 'compare',
VERSIONS: 'versions',
diff --git a/js/utils/CommonUtils.js b/js/utils/CommonUtils.js
index f3940a945..aeb25dfbc 100644
--- a/js/utils/CommonUtils.js
+++ b/js/utils/CommonUtils.js
@@ -261,5 +261,6 @@ define([
isNameCharactersValid,
isNameLengthValid,
getTableOptions,
+ getConceptLinkClass,
};
});
\ No newline at end of file