@@ -318,7 +333,11 @@ const legacyToRowView = (legacy) => {
// Legacy callers (Target Code column, decision tables, search results)
// pass a flat concept-shape object instead — legacyToRowView wraps those
// so a single render path covers both worlds while PR3 cleanup is pending.
-const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown, onCardClick, onMap, isSelectedForMap, noScore, repoVersion, isAIRecommended, sx, notClickable, noSynonymPrefix, locales, showAlgo, candidatesScore, algoScoreFirst, asTarget, AIRecommendedCandidateId, AIAlternateCandidateIds, targetCanonical, targetRelativeUrl}) => {
+//
+// `inputLocale` is the project's input language: each concept leads with its
+// name in it. `locales` is the project's name-locale filter, which hides a
+// matched name in any other language.
+const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown, onCardClick, onMap, isSelectedForMap, noScore, repoVersion, isAIRecommended, sx, notClickable, inputLocale, locales, showAlgo, candidatesScore, algoScoreFirst, asTarget, AIRecommendedCandidateId, AIAlternateCandidateIds, targetCanonical, targetRelativeUrl}) => {
const rowView = concept?.conceptDefinition ? concept : legacyToRowView(concept)
if(!rowView?.conceptDefinition) return null
const { type, candidate, conceptDefinition, conceptRow, bridgeConceptDefinition, bridgeChildren, bridgeContributors, contributingAlgorithmIds, contributingAlgorithms } = rowView
@@ -334,20 +353,12 @@ const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown
const isSelectedToShow = isShown ? isShown(idForUI) : false
const aiAlternateIds = Array.isArray(AIAlternateCandidateIds) ? AIAlternateCandidateIds : []
- let synonymPrefix = ''
- const highlights = candidate?.highlights
- const synonymHighlight = highlights?.synonyms
- const nameHighlight = highlights?.name
- if(!nameHighlight?.length && synonymHighlight?.length && !noSynonymPrefix) {
- let bestMatch = getBestSynonym(synonymHighlight) || synonymHighlight[0]
- if(locales && bestMatch && conceptDefinition?.names) {
- let raw = bestMatch.replaceAll("
", "")
- let _locales = isString(locales) ? locales.split(',') : locales
- if(_locales?.length > 0 && !_locales.includes(conceptDefinition.names.find(name => name.name.startsWith(raw))?.locale))
- bestMatch = ''
- }
- synonymPrefix = (bestMatch || '').replaceAll('
', '')
- }
+ const matchedName = getMatchedName({
+ highlights: candidate?.highlights,
+ concept: conceptDefinition,
+ displayName: localizedDisplayName(conceptDefinition, inputLocale),
+ filterLocales: locales
+ })
const baseProps = {
id: idForUI,
@@ -357,7 +368,8 @@ const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown
lastChild: lastChild,
isSelectedToShow: isSelectedToShow,
sx: sx,
- onCardClick: onCardClick
+ onCardClick: onCardClick,
+ inputLocale: inputLocale
}
if(type === 'bridge') {
@@ -389,7 +401,7 @@ const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown
conceptDefinition={conceptDefinition}
conceptRow={conceptRow}
repoVersion={repoVersion}
- synonymPrefix={synonymPrefix}
+ matchedName={matchedName}
openConceptPanel={openConceptPanel}
AIRecommendedCandidateId={AIRecommendedCandidateId}
AIAlternateCandidateIds={AIAlternateCandidateIds}
@@ -462,7 +474,6 @@ const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown
conceptRow={child.conceptRow}
bridgeConceptDefinition={conceptDefinition}
repoVersion={repoVersion}
- synonymPrefix={synonymPrefix}
openConceptPanel={openConceptPanel}
AIRecommendedCandidateId={AIRecommendedCandidateId}
AIAlternateCandidateIds={AIAlternateCandidateIds}
@@ -498,7 +509,7 @@ const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown
contributingAlgorithms={contributingAlgorithms}
contributingAlgorithmIds={contributingAlgorithmIds}
repoVersion={repoVersion}
- synonymPrefix={synonymPrefix}
+ matchedName={matchedName}
openConceptPanel={openConceptPanel}
AIRecommendedCandidateId={AIRecommendedCandidateId}
AIAlternateCandidateIds={AIAlternateCandidateIds}
diff --git a/src/components/map-projects/ConfigurationForm.jsx b/src/components/map-projects/ConfigurationForm.jsx
index 25f38f9f..8f15f848 100644
--- a/src/components/map-projects/ConfigurationForm.jsx
+++ b/src/components/map-projects/ConfigurationForm.jsx
@@ -220,7 +220,7 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n
{...params}
size='small'
label={t('map_project.input_language', 'Input Language')}
- helperText={t('map_project.input_language_helper', 'Language of the input data. Determines which target-repo name locales are sent to the OCL AI Assistant.')}
+ helperText={t('map_project.input_language_helper', 'Language of the input data. Candidates lead with their name in this language, and it determines which target-repo name locales are sent to the OCL AI Assistant.')}
/>
)}
/>
diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx
index 4c38b599..6fa4ff3a 100644
--- a/src/components/map-projects/MapProject.jsx
+++ b/src/components/map-projects/MapProject.jsx
@@ -115,7 +115,7 @@ import { DEFAULT_ENCODER_MODEL } from './rerankerModels'
import { normalizeAlgorithmInvocation, hasSuccessfulAlgorithmResponse, getAlgorithmStagesFromResponses, lookupStatusRank, buildRecommendableConceptEntry, stripConstantClassAndDatatype, buildLookupConceptUrl, serializeCandidates, toStoredRowMatchState } from './normalizers'
import { parseConceptKey } from './conceptKey'
import { getDeclaredLocales, getDefaultTargetRepoVersion, getProjectTargetRepoVersion, getSavedTargetRepoVersion, getTargetRepoVersionFromUrl, getTargetRepoVersionId } from './projectTargetRepo'
-import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs } from './viewBuilders.js'
+import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, withCandidateNames } from './viewBuilders.js'
import { formatClockTime, getCapacityWaitLabel, mergeCapacityWaits } from './rowProgress.js'
import './MapProject.scss'
@@ -1134,7 +1134,7 @@ const MapProject = () => {
null
}
)
@@ -5342,13 +5342,7 @@ const MapProject = () => {
const targetRowViews = rowMatchStateRef.current?.[rowIndex]
? buildQualityRowViews(rowMatchStateRef.current[rowIndex], conceptCacheRef.current)
: []
- const selectedTargetRowView = targetConcept ? find(targetRowViews, view => {
- const mapped = conceptForMapping(view)
- if(!mapped) return false
- if(targetConcept?.url && mapped.url)
- return mapped.url === targetConcept.url
- return mapped.id === targetConcept?.id
- }) : false
+ const selectedTargetRowView = targetConcept ? findTargetRowView(targetRowViews, targetConcept) : false
if(selectedTargetRowView && targetConcept) {
const mergedConcept = conceptForMapping(selectedTargetRowView)
targetConcept = {
@@ -6501,6 +6495,8 @@ const MapProject = () => {
candidatesScore={candidatesScore}
targetConcept={targetConcept}
repoVersion={repoVersion}
+ inputLocale={inputLocale}
+ locales={filters.locale || ''}
row={row}
rowIndex={rowIndex}
repo={repo}
@@ -6600,6 +6596,7 @@ const MapProject = () => {
setAppliedFacets(() => ({...appliedFacets, [rowIndex]: filters}))
fetchAllCandidatesForRow(null, 0, false, false, filters, true)
}}
+ inputLocale={inputLocale}
locales={filters.locale || ''}
models={AIModels}
selectedModel={AIModel}
@@ -6639,6 +6636,7 @@ const MapProject = () => {
setAppliedFacets({...appliedFacets, [rowIndex]: filters})
search(null, null, null, null, filters)
}}
+ inputLocale={inputLocale}
locales={filters.locale || ''}
/>
}
diff --git a/src/components/map-projects/MappingDecisionResult.jsx b/src/components/map-projects/MappingDecisionResult.jsx
index 68611b51..f1c00a63 100644
--- a/src/components/map-projects/MappingDecisionResult.jsx
+++ b/src/components/map-projects/MappingDecisionResult.jsx
@@ -17,9 +17,20 @@ import Retired from '../common/Retired'
import ConceptSummaryProperties from '../concepts/ConceptSummaryProperties'
import MapButton from './MapButton'
import Score from './Score'
+import { localizedDisplayName, getMatchedName } from './conceptNames.js'
+import { MatchedName } from './Concept'
-const MappingDecisionResult = ({targetConcept, row, rowIndex, mapTypes, allMapTypes, onMap, proposed, columns, repoVersion, openConceptPanel, candidatesScore}) => {
+const MappingDecisionResult = ({targetConcept, row, rowIndex, mapTypes, allMapTypes, onMap, proposed, columns, repoVersion, inputLocale, locales, openConceptPanel, candidatesScore}) => {
const { t } = useTranslation();
+ // The target leads with its name in the input language, and the name its
+ // match was made on follows, as on a candidate (OpenConceptLab/ocl_issues#2909).
+ const targetName = localizedDisplayName(targetConcept, inputLocale)
+ const matchedName = getMatchedName({
+ highlights: targetConcept?.search_meta?.search_highlight,
+ concept: targetConcept,
+ displayName: targetName,
+ filterLocales: locales
+ })
const parentParams = targetConcept?.url ? URIToParentParams(targetConcept.url) : {}
const contributingAlgorithms = targetConcept?.contributingAlgorithms
|| targetConcept?.search_meta?.contributing_algorithms
@@ -162,7 +173,7 @@ const MappingDecisionResult = ({targetConcept, row, rowIndex, mapTypes, allMapTy
primary={
- {`${parentParams.repo || targetConcept.repo?.short_code || targetConcept?.repo?.id}:${targetConcept.id} ${targetConcept.display_name || ''}`}
+ {`${parentParams.repo || targetConcept.repo?.short_code || targetConcept?.repo?.id}:${targetConcept.id} ${targetName || ''}`}
{
targetConcept.retired &&
@@ -172,6 +183,7 @@ const MappingDecisionResult = ({targetConcept, row, rowIndex, mapTypes, allMapTy
}
secondary={
<>
+
diff --git a/src/components/map-projects/Search.jsx b/src/components/map-projects/Search.jsx
index f1876613..bf040a5e 100644
--- a/src/components/map-projects/Search.jsx
+++ b/src/components/map-projects/Search.jsx
@@ -20,11 +20,11 @@ import { highlightTexts } from '../../common/utils';
import SearchResults from '../search/SearchResults';
import SearchFilters from '../search/SearchFilters'
import Mappings from './Mappings'
-import Concept from './Concept'
+import Concept, { localizedNameColumn } from './Concept'
import MapButton from './MapButton'
import GAService from '../../services/GAService'
-const Search = ({searchStr, setSearchStr, onSearch, repo, repoVersion, concepts, openConceptPanel, showItem, isSelectedForMap, onMap, response, facets, appliedFacets, setAppliedFacets, isLoading, filters, columns, defaultFilters, locales}) => {
+const Search = ({searchStr, setSearchStr, onSearch, repo, repoVersion, concepts, openConceptPanel, showItem, isSelectedForMap, onMap, response, facets, appliedFacets, setAppliedFacets, isLoading, filters, columns, defaultFilters, inputLocale, locales}) => {
const { t } = useTranslation();
const [openFilters, setOpenFilters] = React.useState(false)
const [display, setDisplay] = React.useState('card')
@@ -61,6 +61,7 @@ const Search = ({searchStr, setSearchStr, onSearch, repo, repoVersion, concepts,
const getExtraColumns = () => {
let cols = [
+ localizedNameColumn(inputLocale, locales),
{
sortable: false,
id: 'mappings',
@@ -209,6 +210,7 @@ const Search = ({searchStr, setSearchStr, onSearch, repo, repoVersion, concepts,
isSelectedForMap={isSelectedForMap}
repoVersion={repoVersion}
noScore
+ inputLocale={inputLocale}
locales={locales}
/>
)
diff --git a/src/components/map-projects/__tests__/conceptNames.test.js b/src/components/map-projects/__tests__/conceptNames.test.js
new file mode 100644
index 00000000..8af53cc5
--- /dev/null
+++ b/src/components/map-projects/__tests__/conceptNames.test.js
@@ -0,0 +1,179 @@
+/**
+ * Tests for the candidate-name helpers in conceptNames.js
+ * (OpenConceptLab/ocl_issues#2909):
+ * - localizedDisplayName: the name a candidate leads with, in the project's
+ * input language, falling back to the repository's default locale
+ * - getMatchedName: the name that matched, shown second with its locale,
+ * only when it differs from the name the candidate leads with
+ *
+ * Run with: npm test
+ */
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { localizedDisplayName, getMatchedName } from '../conceptNames.js'
+
+// LOINC 89497-2 as the ticket found it: an English display name, and an
+// it-IT related name that ES matched for the input "Pregnancy test".
+const PRENATAL_TESTS = {
+ id: '89497-2',
+ display_name: "Mother's Prenatal tests",
+ names: [
+ { name: "Mother's Prenatal tests", locale: 'en', locale_preferred: true, name_type: 'FULLY_SPECIFIED' },
+ { name: 'Anamnesi Punto nel tempo (episodio) Test prenatale', locale: 'it-IT', locale_preferred: false }
+ ]
+}
+
+const PREGNANCY_TEST = {
+ id: '2106-3',
+ display_name: 'Pregnancy test',
+ names: [
+ { name: 'Pregnancy test', locale: 'en', locale_preferred: true },
+ { name: 'HCG test', locale: 'en' },
+ { name: 'Test de embarazo', locale: 'es' },
+ { name: 'Prueba de embarazo', locale: 'es', locale_preferred: true },
+ { name: 'Teste de gravidez', locale: 'pt-BR' }
+ ]
+}
+
+// ---------- localizedDisplayName ----------
+
+test('localizedDisplayName: keeps the display name when the project has no input language', () => {
+ assert.equal(localizedDisplayName(PREGNANCY_TEST, ''), 'Pregnancy test')
+ assert.equal(localizedDisplayName(PREGNANCY_TEST, undefined), 'Pregnancy test')
+})
+
+test('localizedDisplayName: keeps the display name when it is already in the input language', () => {
+ const concept = {
+ display_name: 'Pregnancy test',
+ names: [
+ { name: 'HCG test', locale: 'en', locale_preferred: true },
+ { name: 'Pregnancy test', locale: 'en' }
+ ]
+ }
+ assert.equal(localizedDisplayName(concept, 'en'), 'Pregnancy test')
+ assert.equal(localizedDisplayName(PRENATAL_TESTS, 'en'), "Mother's Prenatal tests")
+})
+
+test('localizedDisplayName: leads with the preferred name in the input language', () => {
+ assert.equal(localizedDisplayName(PREGNANCY_TEST, 'es'), 'Prueba de embarazo')
+})
+
+test('localizedDisplayName: matches the input language on its primary subtag', () => {
+ assert.equal(localizedDisplayName(PREGNANCY_TEST, 'pt'), 'Teste de gravidez')
+ assert.equal(localizedDisplayName(PRENATAL_TESTS, 'it'), 'Anamnesi Punto nel tempo (episodio) Test prenatale')
+})
+
+test('localizedDisplayName: without a preferred name, takes the fully specified name, then the first', () => {
+ const names = [
+ { name: 'PE', locale: 'es', name_type: 'SHORT' },
+ { name: 'Prueba de embarazo', locale: 'es', name_type: 'FULLY_SPECIFIED' }
+ ]
+ assert.equal(localizedDisplayName({ display_name: 'Pregnancy test', names }, 'es'), 'Prueba de embarazo')
+ assert.equal(localizedDisplayName({ display_name: 'Pregnancy test', names: names.slice(0, 1) }, 'es'), 'PE')
+})
+
+test('localizedDisplayName: falls back to the repository default-locale display name', () => {
+ assert.equal(localizedDisplayName(PREGNANCY_TEST, 'fr'), 'Pregnancy test')
+ assert.equal(localizedDisplayName({ display_name: 'Pregnancy test' }, 'es'), 'Pregnancy test')
+ assert.equal(localizedDisplayName(null, 'es'), undefined)
+})
+
+// ---------- getMatchedName ----------
+
+test('getMatchedName: the translated name that matched comes second, with its locale (the ticket case)', () => {
+ const displayName = localizedDisplayName(PRENATAL_TESTS, 'en')
+ assert.deepEqual(
+ getMatchedName({
+ highlights: { synonyms: ['Anamnesi Punto nel tempo (episodio) Test prenatale'] },
+ concept: PRENATAL_TESTS,
+ displayName
+ }),
+ {
+ html: "Anamnesi Punto nel tempo (episodio) Test prenatale",
+ locale: 'it-IT'
+ }
+ )
+})
+
+test('getMatchedName: nothing when the name that matched is the one the candidate leads with', () => {
+ assert.equal(
+ getMatchedName({
+ highlights: { synonyms: ['Prueba de embarazo'] },
+ concept: PREGNANCY_TEST,
+ displayName: localizedDisplayName(PREGNANCY_TEST, 'es')
+ }),
+ null
+ )
+ assert.equal(
+ getMatchedName({
+ highlights: { synonyms: ['pregnancy TEST'] },
+ concept: PREGNANCY_TEST,
+ displayName: 'Pregnancy test'
+ }),
+ null
+ )
+})
+
+test('getMatchedName: nothing when the display name matched and the candidate leads with it', () => {
+ assert.equal(
+ getMatchedName({
+ highlights: { name: ['Pregnancy test'], synonyms: ['HCG test'] },
+ concept: PREGNANCY_TEST,
+ displayName: localizedDisplayName(PREGNANCY_TEST, 'en')
+ }),
+ null
+ )
+})
+
+test('getMatchedName: the default-locale display name that matched comes second when the candidate leads in another language', () => {
+ assert.deepEqual(
+ getMatchedName({
+ highlights: { name: ['Pregnancy test'] },
+ concept: PREGNANCY_TEST,
+ displayName: localizedDisplayName(PREGNANCY_TEST, 'es')
+ }),
+ {
+ html: "Pregnancy test",
+ locale: 'en'
+ }
+ )
+})
+
+test('getMatchedName: takes the synonym with the longest highlighted match', () => {
+ assert.deepEqual(
+ getMatchedName({
+ highlights: { synonyms: ['Test de embarazo', 'Prueba de embarazo'] },
+ concept: PREGNANCY_TEST,
+ displayName: 'Pregnancy test'
+ }),
+ { html: "Prueba de embarazo", locale: 'es' }
+ )
+})
+
+test('getMatchedName: hides a matched name outside the name-locale filter, comparing primary subtags', () => {
+ const args = {
+ highlights: { synonyms: ['Anamnesi Punto nel tempo (episodio) Test prenatale'] },
+ concept: PRENATAL_TESTS,
+ displayName: "Mother's Prenatal tests"
+ }
+ assert.equal(getMatchedName({ ...args, filterLocales: 'en,es' }), null)
+ assert.equal(getMatchedName({ ...args, filterLocales: 'en, it' })?.locale, 'it-IT')
+ assert.equal(getMatchedName({ ...args, filterLocales: '' })?.locale, 'it-IT')
+})
+
+test('getMatchedName: a matched name no concept name carries shows without a locale, unless a filter is set', () => {
+ const args = {
+ highlights: { synonyms: ['Gravindex'] },
+ concept: PREGNANCY_TEST,
+ displayName: 'Pregnancy test'
+ }
+ assert.deepEqual(getMatchedName(args), { html: "Gravindex", locale: undefined })
+ assert.equal(getMatchedName({ ...args, filterLocales: 'en' }), null)
+})
+
+test('getMatchedName: nothing without highlights', () => {
+ assert.equal(getMatchedName({ concept: PREGNANCY_TEST, displayName: 'Pregnancy test' }), null)
+ assert.equal(getMatchedName({ highlights: { synonyms: [] }, concept: PREGNANCY_TEST, displayName: 'Pregnancy test' }), null)
+})
diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js
index 86742fbb..fb9ddfae 100644
--- a/src/components/map-projects/__tests__/views.test.js
+++ b/src/components/map-projects/__tests__/views.test.js
@@ -23,7 +23,9 @@ import {
buildQualityRowViews,
candidateToRowView,
conceptForMapping,
- sortRowViews
+ sortRowViews,
+ findTargetRowView,
+ withCandidateNames
} from '../viewBuilders.js'
// ---------- Helpers ----------
@@ -648,6 +650,15 @@ test('sortRowViews: display_name asc', () => {
assert.deepEqual(out.map(v => v.conceptDefinition.display_name), ['Alpha', 'Beta', 'Gamma'])
})
+test('sortRowViews: display_name sorts on the name shown in the input language (OpenConceptLab/ocl_issues#2909)', () => {
+ const views = [
+ { conceptDefinition: {id: 'glucose', display_name: 'Blood glucose', names: [{name: 'Glucosa en sangre', locale: 'es', locale_preferred: true}]} },
+ { conceptDefinition: {id: 'diabetes', display_name: 'Diabetes mellitus', names: [{name: 'Diabetes mellitus', locale: 'es'}]} }
+ ]
+ assert.deepEqual(sortRowViews(views, 'display_name', 'asc', 'es').map(v => v.conceptDefinition.id), ['diabetes', 'glucose'])
+ assert.deepEqual(sortRowViews(views, 'display_name', 'asc').map(v => v.conceptDefinition.id), ['glucose', 'diabetes'])
+})
+
test('sortRowViews: undefined scores sort to the bottom in desc order', () => {
const views = [
{ candidate: {}, conceptDefinition: {display_name: 'x'}, conceptRow: {} },
@@ -665,3 +676,61 @@ test('sortRowViews: id falls back to reference.code when id is absent', () => {
const out = sortRowViews(views, 'id', 'asc')
assert.equal(out[0].conceptDefinition.reference.code, 'a-1')
})
+
+// ---------- findTargetRowView / withCandidateNames (OpenConceptLab/ocl_issues#2909) ----------
+
+const glucoseNames = [
+ { name: 'Glucose [Mass/volume] in Blood', locale: 'en', locale_preferred: true },
+ { name: 'Glucosa en sangre', locale: 'es', locale_preferred: true }
+]
+const namedCache = {
+ [KEY_LOINC_GLUCOSE]: { ...defLOINCGlucose, names: glucoseNames },
+ [KEY_LOINC_CHOL]: defLOINCChol
+}
+const namedRowState = {
+ candidates: {
+ c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85, highlights: { synonyms: ['Glucosa en sangre'] } },
+ c2: { id: 'c2', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_CHOL, type: 'standard', score: 0.6 }
+ },
+ concept_rows: {
+ [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 87 },
+ [KEY_LOINC_CHOL]: { concept_key: KEY_LOINC_CHOL, rerank_score: 55 }
+ }
+}
+// A mapped target as a reopened project restores it from its CSV: id, name,
+// URL and scores, without names or highlights.
+const restoredTarget = {
+ id: '49494-3',
+ display_name: 'Glucose [Mass/volume] in Blood',
+ url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/',
+ search_meta: { search_normalized_score: '87', algorithm: 'ocl-search' },
+ repo: { id: 'LOINC' }
+}
+
+test('findTargetRowView: matches a target on its OCL URL, or on its id when either lacks one', () => {
+ const views = buildQualityRowViews(namedRowState, namedCache)
+ assert.equal(findTargetRowView(views, restoredTarget)?.conceptDefinition.id, '49494-3')
+ assert.equal(findTargetRowView(views, { id: '2093-3' })?.conceptDefinition.id, '2093-3')
+ assert.equal(findTargetRowView(views, { id: '49494-3', url: '/orgs/Other/sources/X/concepts/49494-3/' }), undefined)
+})
+
+test('withCandidateNames: a restored target gets the names and highlights its row\'s candidates hold for it', () => {
+ const out = withCandidateNames(restoredTarget, namedRowState, namedCache)
+ assert.deepEqual(out.names, glucoseNames)
+ assert.deepEqual(out.search_meta, {
+ search_normalized_score: '87',
+ algorithm: 'ocl-search',
+ search_highlight: { synonyms: ['Glucosa en sangre'] }
+ })
+ assert.equal(out.display_name, restoredTarget.display_name)
+ assert.equal(out.repo, restoredTarget.repo)
+})
+
+test('withCandidateNames: keeps a target\'s own names and highlights, and a target no candidate holds', () => {
+ const own = { ...restoredTarget, names: [{ name: 'own', locale: 'es' }], search_meta: { search_highlight: { name: ['own'] } } }
+ assert.equal(withCandidateNames(own, namedRowState, namedCache).names, own.names)
+ assert.deepEqual(withCandidateNames(own, namedRowState, namedCache).search_meta.search_highlight, { name: ['own'] })
+ const searched = { id: '718-7', url: '/orgs/Regenstrief/sources/LOINC/concepts/718-7/' }
+ assert.equal(withCandidateNames(searched, namedRowState, namedCache), searched)
+ assert.equal(withCandidateNames(restoredTarget, undefined, namedCache), restoredTarget)
+})
diff --git a/src/components/map-projects/conceptNames.js b/src/components/map-projects/conceptNames.js
new file mode 100644
index 00000000..5beea67b
--- /dev/null
+++ b/src/components/map-projects/conceptNames.js
@@ -0,0 +1,94 @@
+/**
+ * Which of a concept's names a candidate shows (OpenConceptLab/ocl_issues#2909).
+ *
+ * A candidate leads with its display name in the project's input language,
+ * falling back to the display name OCL computes from the repository's default
+ * locale. The name the match was made on comes second, with its locale, and
+ * only when it differs from the name the candidate leads with.
+ *
+ * Pure — no React, no lodash — so the rules are unit-testable.
+ */
+
+import { primarySubtag } from './normalizers.js'
+
+const FULLY_SPECIFIED = /^fully[\s_-]?specified$/i
+
+const sameName = (a, b) => {
+ const normalize = text => (text || '').replace(/\s+/g, ' ').trim().toLowerCase()
+ return normalize(a) === normalize(b)
+}
+
+// The highlighted synonym with the best match: an exact match first, then the
+// longest highlighted run, then one that starts with it.
+const getBestSynonym = (synonyms = []) => {
+ return synonyms
+ .map(text => {
+ const matches = [...text.matchAll(/(.*?)<\/em>/g)];
+ const longestMatch = matches.reduce(
+ (a, b) => (b[1].length > a.length ? b[1] : a),
+ ""
+ );
+
+ const emTag = `${longestMatch}`;
+ return {
+ text,
+ length: longestMatch.length,
+ isExact: text.trim() === emTag,
+ startsWith: text.startsWith(emTag)
+ };
+ })
+ .sort((a, b) => {
+ if (b.isExact !== a.isExact) return b.isExact ? 1 : -1; // ✅ exact ALWAYS first
+ if (b.length !== a.length) return b.length - a.length; // longer match
+ if (b.startsWith !== a.startsWith) return b.startsWith ? 1 : -1;
+ return 0;
+ })[0]?.text;
+};
+
+/**
+ * The name a concept leads with in `inputLocale` (matched on its primary
+ * subtag, so `pt` takes a `pt-BR` name): the display name when it is already
+ * in that language, else that language's preferred name, then its fully
+ * specified name, then its first. With no name in that language, or no input
+ * language, it is the concept's `display_name`, which OCL computes from the
+ * repository's default locale.
+ */
+export const localizedDisplayName = (concept, inputLocale) => {
+ const displayName = concept?.display_name
+ const language = primarySubtag(inputLocale)
+ const names = language && Array.isArray(concept?.names) ? concept.names : []
+ const inLanguage = names.filter(name => name?.name && primarySubtag(name.locale) === language)
+ if(!inLanguage.length || inLanguage.some(name => name.name === displayName))
+ return displayName
+ return (
+ inLanguage.find(name => name.locale_preferred) ||
+ inLanguage.find(name => FULLY_SPECIFIED.test(name.name_type || '')) ||
+ inLanguage[0]
+ ).name
+}
+
+/**
+ * The name the match was made on, to show after `displayName` (the name the
+ * candidate leads with): `{html, locale}`, with the highlighted words in bold,
+ * or null when nothing matched or the match is `displayName` itself. A
+ * display-name (`name`) highlight means the concept's `display_name` matched;
+ * otherwise it is the best `synonyms` highlight. `filterLocales` (the
+ * project's name-locale filter, a comma-separated string) hides a
+ * matched name in any other language.
+ */
+export const getMatchedName = ({highlights, concept, displayName, filterLocales}) => {
+ const fragment = highlights?.name?.[0] || getBestSynonym(highlights?.synonyms)
+ if(!fragment) return null
+ const text = fragment.replace(/<\/?em>/g, '')
+ const names = Array.isArray(concept?.names) ? concept.names : []
+ const entry = names.find(name => name?.name === text) || names.find(name => name?.name?.startsWith(text))
+ if(sameName(text, displayName) || (entry && sameName(entry.name, displayName)))
+ return null
+ const allowed = (filterLocales || '').split(',').map(locale => primarySubtag(locale.trim())).filter(Boolean)
+ if(allowed.length && !allowed.includes(primarySubtag(entry?.locale)))
+ return null
+ return {
+ html: fragment.replaceAll('', "").replaceAll('', ''),
+ locale: entry?.locale
+ }
+}
diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js
index 719a3dd2..76997ce2 100644
--- a/src/components/map-projects/viewBuilders.js
+++ b/src/components/map-projects/viewBuilders.js
@@ -15,6 +15,8 @@
// imports, so the module is loadable by Node's native ESM resolver under
// node:test.
+import { localizedDisplayName } from './conceptNames.js'
+
const compact = arr => (arr || []).filter(x => x != null && x !== false)
const values = obj => Object.values(obj || {})
const isNumber = v => typeof v === 'number' && !Number.isNaN(v)
@@ -336,16 +338,17 @@ export const buildQualityRowViews = (rowState, conceptCache) => {
/**
* Sort RowViews by the chosen key. `rerank_score` and `algo_score` are
- * numeric; `id` and `display_name` are alphabetical. Missing scores sort
- * to the bottom in `desc` order via a -1 sentinel.
+ * numeric; `id` and `display_name` are alphabetical, `display_name` on the
+ * name shown in `inputLocale` (the project's input language). Missing scores
+ * sort to the bottom in `desc` order via a -1 sentinel.
*/
-export const sortRowViews = (views, sortBy, order) => {
+export const sortRowViews = (views, sortBy, order, inputLocale) => {
const valueFor = view => {
switch(sortBy) {
case 'rerank_score': return view.conceptRow?.rerank_score ?? -1
case 'algo_score': return view.candidate?.score ?? -1
case 'id': return view.conceptDefinition?.id || view.conceptDefinition?.reference?.code || ''
- case 'display_name': return view.conceptDefinition?.display_name || ''
+ case 'display_name': return localizedDisplayName(view.conceptDefinition, inputLocale) || ''
default: return 0
}
}
@@ -411,6 +414,41 @@ export const conceptForMapping = (rowView) => {
}
}
+/**
+ * The row view of a mapped target: the one whose concept has the target's
+ * OCL URL or, when either lacks one, its id.
+ */
+export const findTargetRowView = (views, target) => {
+ return views.find(view => {
+ const mapped = conceptForMapping(view)
+ if(!mapped) return false
+ if(target.url && mapped.url)
+ return mapped.url === target.url
+ return mapped.id === target.id
+ })
+}
+
+/**
+ * A mapped target with the `names` and the match highlights its row's
+ * candidates hold for it, so it can be named in the project's input language
+ * with the name it matched on (OpenConceptLab/ocl_issues#2909). A target
+ * restored from a saved project has only its id, name and URL. What the
+ * target has of its own is kept; a target no candidate holds is returned as
+ * it is.
+ */
+export const withCandidateNames = (target, rowState, conceptCache) => {
+ const view = findTargetRowView(buildQualityRowViews(rowState, conceptCache), target)
+ if(!view) return target
+ return {
+ ...target,
+ names: target.names || view.conceptDefinition.names,
+ search_meta: {
+ ...target.search_meta,
+ search_highlight: target.search_meta?.search_highlight || view.candidate?.highlights
+ }
+ }
+}
+
/**
* Compute the unified score (0-100 percentile) and the raw algorithm score
* for a candidate+row. Unified score = per-(row, concept) rerank score
diff --git a/src/components/search/TableResults.jsx b/src/components/search/TableResults.jsx
index ed4d83f3..934f3662 100644
--- a/src/components/search/TableResults.jsx
+++ b/src/components/search/TableResults.jsx
@@ -14,6 +14,7 @@ import { visuallyHidden } from '@mui/utils';
import { filter, reject, get, sortBy, without, startCase, find, keys, map, times } from 'lodash'
import { SECONDARY_COLORS } from '../../common/colors';
import { ALL_COLUMNS } from './ResultConstants';
+import { withExtraColumns } from './tableColumns.js';
const EnhancedTableHead = props => {
const { t } = useTranslation()
@@ -96,8 +97,7 @@ const TableResults = ({selected, bgColor, handleClick, handleRowClick, handleSel
ALL_COLUMNS[resource] || [],
column => nested ? column.nested !== false : column.global !== false
);
- if(extraColumns?.length)
- columns = [...columns, ...extraColumns]
+ columns = withExtraColumns(columns, extraColumns)
columns = excludedColumns?.length ? reject(columns, column => excludedColumns?.includes(column.id)) : columns
diff --git a/src/components/search/__tests__/tableColumns.test.js b/src/components/search/__tests__/tableColumns.test.js
new file mode 100644
index 00000000..024a22e3
--- /dev/null
+++ b/src/components/search/__tests__/tableColumns.test.js
@@ -0,0 +1,32 @@
+/**
+ * Tests for withExtraColumns (tableColumns.js), which TableResults uses to add
+ * a caller's columns to a resource's base columns.
+ *
+ * Run with: npm test
+ */
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { withExtraColumns } from '../tableColumns.js'
+
+const BASE = [
+ { id: 'id', value: 'id', permanent: true },
+ { id: 'name', value: 'display_name', permanent: true, renderer: () => 'base' },
+ { id: 'datatype', value: 'datatype' }
+]
+
+test("withExtraColumns: an extra column with a base column's id replaces it in place (OpenConceptLab/ocl_issues#2909)", () => {
+ const name = { id: 'name', renderer: () => 'localized' }
+ const columns = withExtraColumns(BASE, [name, { id: 'mappings' }])
+ assert.deepEqual(columns.map(column => column.id), ['id', 'name', 'datatype', 'mappings'])
+ assert.equal(columns[1].renderer(), 'localized')
+ assert.equal(columns[1].value, 'display_name')
+ assert.equal(columns[1].permanent, true)
+ assert.equal(BASE[1].renderer(), 'base')
+})
+
+test('withExtraColumns: no extra columns leaves the base ones', () => {
+ assert.deepEqual(withExtraColumns(BASE, undefined), BASE)
+ assert.deepEqual(withExtraColumns(BASE, []), BASE)
+})
diff --git a/src/components/search/tableColumns.js b/src/components/search/tableColumns.js
new file mode 100644
index 00000000..6490805d
--- /dev/null
+++ b/src/components/search/tableColumns.js
@@ -0,0 +1,15 @@
+/**
+ * Add a caller's `extraColumns` to a resource's base table columns. An extra
+ * column with a base column's id replaces it where it stands, keeping the
+ * base column's other fields, so a caller can change how one column renders
+ * (OpenConceptLab/ocl_issues#2909: the Mapper names concepts in the project's
+ * input language). The rest are appended. Pure.
+ */
+export const withExtraColumns = (columns, extraColumns) => {
+ if(!extraColumns?.length) return columns
+ const byId = new Map(extraColumns.map(column => [column.id, column]))
+ return [
+ ...columns.map(column => byId.has(column.id) ? {...column, ...byId.get(column.id)} : column),
+ ...extraColumns.filter(column => !columns.some(base => base.id === column.id))
+ ]
+}
diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json
index f7e979ab..d4e5e235 100644
--- a/src/i18n/locales/en/translations.json
+++ b/src/i18n/locales/en/translations.json
@@ -429,7 +429,7 @@
"filter_name_locales_default_only": "This target declares only its default locale, so there are no other locales to filter by.",
"filter_name_locales_open_settings": "Declare more locales in the repository's settings in TBv3.",
"input_language": "Input Language",
- "input_language_helper": "Language of the input data. Determines which target-repo name locales are sent to the OCL AI Assistant.",
+ "input_language_helper": "Language of the input data. Candidates lead with their name in this language, and it determines which target-repo name locales are sent to the OCL AI Assistant.",
"ai_effective_locales": "AI will receive names in: {{locales}}",
"ai_all_locales": "AI will receive names in: all locales (no filter)",
"default_filter": "Default Filter",
@@ -582,6 +582,7 @@
"view_existing_analysis": "View Existing",
"run_new_analysis": "Run New Analysis",
"matched": "Matched",
+ "matched_name": "Matched name",
"dates": "Dates",
"none": "None",
"decision_changed_to_none": "Decision Changed to None",
diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json
index 48768181..9324b98a 100644
--- a/src/i18n/locales/es/translations.json
+++ b/src/i18n/locales/es/translations.json
@@ -404,7 +404,7 @@
"filter_name_locales_default_only": "Este destino solo declara su locale predeterminado, así que no hay otros locales por los que filtrar.",
"filter_name_locales_open_settings": "Declara más locales en la configuración del repositorio en TBv3.",
"input_language": "Idioma de entrada",
- "input_language_helper": "Idioma de los datos de entrada. Determina qué configuraciones regionales de los nombres del repositorio objetivo se envían al asistente de IA de OCL.",
+ "input_language_helper": "Idioma de los datos de entrada. Los candidatos muestran primero su nombre en este idioma, y determina qué configuraciones regionales de los nombres del repositorio objetivo se envían al asistente de IA de OCL.",
"ai_effective_locales": "La IA recibirá los nombres en: {{locales}}",
"ai_all_locales": "La IA recibirá los nombres en: todas las configuraciones regionales (sin filtro)",
"default_filter": "Filtro Predeterminado",
@@ -553,6 +553,7 @@
"view_existing_analysis": "Ver existente",
"run_new_analysis": "Ejecutar nuevo análisis",
"matched": "Coincidencias",
+ "matched_name": "Nombre coincidente",
"dates": "Fechas",
"none": "Ninguno",
"decision_changed_to_none": "Decisión cambiada a Ninguno",
diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json
index 0fa93c58..e784eea6 100644
--- a/src/i18n/locales/pt-BR/translations.json
+++ b/src/i18n/locales/pt-BR/translations.json
@@ -429,7 +429,7 @@
"filter_name_locales_default_only": "Este destino declara apenas seu idioma padrão, portanto não há outros idiomas para filtrar.",
"filter_name_locales_open_settings": "Declare mais idiomas nas configurações do repositório no TBv3.",
"input_language": "Idioma de entrada",
- "input_language_helper": "Idioma dos dados de entrada. Determina quais idiomas de nome do repositório de destino são enviados ao Assistente de IA do OCL.",
+ "input_language_helper": "Idioma dos dados de entrada. Os candidatos mostram primeiro o nome neste idioma, e ele determina quais idiomas de nome do repositório de destino são enviados ao Assistente de IA do OCL.",
"ai_effective_locales": "IA receberá nomes em: {{locales}}",
"ai_all_locales": "IA receberá nomes em: todos os idiomas (sem filtro)",
"default_filter": "Filtro Padrão",
@@ -582,6 +582,7 @@
"view_existing_analysis": "Ver Existente",
"run_new_analysis": "Executar Nova Análise",
"matched": "Correspondido",
+ "matched_name": "Nome correspondente",
"dates": "Datas",
"none": "Nenhum",
"decision_changed_to_none": "Decisão Alterada para Nenhum",
diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json
index 4348e8b5..9e8cef0a 100644
--- a/src/i18n/locales/zh/translations.json
+++ b/src/i18n/locales/zh/translations.json
@@ -429,7 +429,7 @@
"filter_name_locales_default_only": "该目标仅声明了其默认语言环境,因此没有其他可用于筛选的语言环境。",
"filter_name_locales_open_settings": "请在 TBv3 中的存储库设置里声明更多语言环境。",
"input_language": "输入语言",
- "input_language_helper": "输入数据的语言。决定将目标存储库名称的哪些区域设置发送给 OCL AI 助手。",
+ "input_language_helper": "输入数据的语言。候选项优先显示该语言的名称,并决定将目标存储库名称的哪些区域设置发送给 OCL AI 助手。",
"ai_effective_locales": "AI 将收到以下区域设置的名称:{{locales}}",
"ai_all_locales": "AI 将收到所有区域设置的名称(不筛选)",
"default_filter": "默认筛选器",
@@ -578,6 +578,7 @@
"view_existing_analysis": "查看现有",
"run_new_analysis": "运行新的分析",
"matched": "已匹配",
+ "matched_name": "匹配名称",
"dates": "日期",
"none": "无",
"decision_changed_to_none": "决策已更改为无",