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 @@ + + +
+
+
+ +
+ +
+ +
+
+
+ + + +
+
+
+
+ +
+ +
+ + + + + + + + + + +
+
+ + +
+ +
+ + + + + + + +
+ +
+ + +
+ +
+
+
+
+

Warning

+

+ +
+
+
\ 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