From f5f2eae39f9fd00db949a08ee54e362d8e3185ce Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Wed, 14 Oct 2026 13:12:08 -0400 Subject: [PATCH 1/5] OpenConceptLab/ocl_issues#2909 | A candidate leads with its name in the input language, and the matched name comes second A candidate led with the name its match was made on, then an arrow and its display name, so a match on a translation put a name in another language first. For "Pregnancy test" in an English project, LOINC 89497-2 read "Anamnesi Punto nel tempo (episodio) Test prenatale -> Mother's Prenatal tests". - conceptNames.js picks the names. localizedDisplayName is the concept's name in the project's input language (its preferred name, then its fully specified name, then its first), or else display_name, which OCL computes from the repository's default locale. getMatchedName is the name that matched, with its locale, or null when it is the name the candidate leads with. - Concept.jsx leads with that name and shows "Matched name (): ..." on the line below it. The grid's Target Code cell, the row panel's candidates (with its AI Analysis grouping) and Search tab, and the row panel's Target Code header get the project's input language. - The candidates' table view names each row the same way. - The name-locale filter compares primary subtags, so "it" admits an it-IT name, as it already does for the names sent to the OCL AI Assistant. - New string map_project.matched_name in en, es, pt-BR and zh. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NSAveBj6W6LKJLYTWJ9rwi --- src/components/map-projects/Candidates.jsx | 13 +- src/components/map-projects/Concept.jsx | 94 ++++----- src/components/map-projects/MapProject.jsx | 5 +- .../map-projects/MappingDecisionResult.jsx | 5 +- src/components/map-projects/Search.jsx | 3 +- .../__tests__/conceptNames.test.js | 181 ++++++++++++++++++ src/components/map-projects/conceptNames.js | 100 ++++++++++ src/i18n/locales/en/translations.json | 1 + src/i18n/locales/es/translations.json | 1 + src/i18n/locales/pt-BR/translations.json | 1 + src/i18n/locales/zh/translations.json | 1 + 11 files changed, 339 insertions(+), 66 deletions(-) create mode 100644 src/components/map-projects/__tests__/conceptNames.test.js create mode 100644 src/components/map-projects/conceptNames.js diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 7629ae6b..231fe313 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -55,6 +55,7 @@ import { getAIAnalysisCandidateIDs } from './viewBuilders.js' import { getRowProgressLabel } from './rowProgress.js' +import { localizedDisplayName } from './conceptNames.js' const Sort = ({ selected, onSort }) => { const { t } = useTranslation(); @@ -162,7 +163,7 @@ const SubHeader = ({count, onClick, isCollapsed, header, indicatorColor, indicat } -const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPanel, showItem, isSelectedForMap, onMap, onFetchMore, bgColor, headerIndicatorIcon, bucketId, display, onDisplayChange, noToolbar, toolbarControl, repoVersion, alignToolbarLeft, rightControl, analysis, showAnalysis, openAnalysis, onCloseAnalysis, isInProgress, AIRecommendedCandidateId, AIAlternateCandidateIds, locales, scispacy, showAlgo, collapsed, onCollapse, candidatesScore, algoScoreFirst, byAlgorithm, showEmptyHeader, isFirst, targetCanonical, targetRelativeUrl, analysisPage, setAnalysisPage}) => { +const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPanel, showItem, isSelectedForMap, onMap, onFetchMore, bgColor, headerIndicatorIcon, bucketId, display, onDisplayChange, noToolbar, toolbarControl, repoVersion, alignToolbarLeft, rightControl, analysis, showAnalysis, openAnalysis, onCloseAnalysis, isInProgress, AIRecommendedCandidateId, AIAlternateCandidateIds, inputLocale, locales, scispacy, showAlgo, collapsed, onCollapse, candidatesScore, algoScoreFirst, byAlgorithm, showEmptyHeader, isFirst, targetCanonical, targetRelativeUrl, analysisPage, setAnalysisPage}) => { // Decorate rowViews so they work for BOTH renderers: // - Table view: SearchResults/TableResults reads legacy concept fields // (id, url, names, descriptions, source, search_meta, ...) via the @@ -173,6 +174,9 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa // - Click lookup: SearchResults.handleRowClick uses // `row.version_url || row.url || row.id`. Set those explicitly at the // end so neither earlier spread can clobber them. + // - Table view's name column reads `display_name`: give it the name in + // the project's input language, as the card view shows. Mapping and + // the concept panel read the tuple, so they keep the concept's own. const rowsForTable = (rowViews || []).map(view => { const legacy = conceptForMapping(view) || {} const def = view?.conceptDefinition @@ -182,7 +186,8 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa ...view, id: idForLookup, url: legacy.url || def?.ocl_url, - version_url: legacy.url || def?.ocl_url + version_url: legacy.url || def?.ocl_url, + display_name: localizedDisplayName(def, inputLocale) } }) // Helper: a rowView is mappable only when its concept belongs to the @@ -327,6 +332,7 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa repoVersion={repoVersion} AIRecommendedCandidateId={AIRecommendedCandidateId} AIAlternateCandidateIds={AIAlternateCandidateIds} + inputLocale={inputLocale} locales={locales} notClickable={Boolean(scispacy)} showAlgo={showAlgo} @@ -377,7 +383,7 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa // conceptCache — project-wide ConceptDefinition store, keyed by concept_key. // algosSelected — algorithm definitions (for headers/grouping). // (plans/unified-mapper-model.md "How the views map onto this model".) -const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRelativeUrl, openConceptPanel, showItem, isSelectedForMap, onMap, onFetchMore, isLoading, candidatesScore, repoVersion, analysis, onFetchRecommendation, appliedFacets, setAppliedFacets, filters, facets, columns, defaultFilters, locales, models, selectedModel, onModelChange, promptTemplates, promptTemplate, onPromptTemplateChange, onRefreshClick, rowStage, inAIAssistantGroup, algosSelected, canSelectAIModel, capacityWait=false, throttle}) => { +const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRelativeUrl, openConceptPanel, showItem, isSelectedForMap, onMap, onFetchMore, isLoading, candidatesScore, repoVersion, analysis, onFetchRecommendation, appliedFacets, setAppliedFacets, filters, facets, columns, defaultFilters, inputLocale, locales, models, selectedModel, onModelChange, promptTemplates, promptTemplate, onPromptTemplateChange, onRefreshClick, rowStage, inAIAssistantGroup, algosSelected, canSelectAIModel, capacityWait=false, throttle}) => { const { t } = useTranslation(); const [sortBy, setSortBy] = React.useState('rerank_score') const [groupBy, setGroupBy] = React.useState('quality') @@ -460,6 +466,7 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe repoVersion: repoVersion, AIRecommendedCandidateId: AIRecommendedCandidateId, AIAlternateCandidateIds: AIAlternateCandidateIds, + inputLocale: inputLocale, locales: locales, candidatesScore: candidatesScore, algoScoreFirst: algoScoreFirst, diff --git a/src/components/map-projects/Concept.jsx b/src/components/map-projects/Concept.jsx index 946a6676..66a1e1e1 100644 --- a/src/components/map-projects/Concept.jsx +++ b/src/components/map-projects/Concept.jsx @@ -1,4 +1,5 @@ import React from 'react' +import { useTranslation } from 'react-i18next' import ListItemButton from '@mui/material/ListItemButton' import ListItem from '@mui/material/ListItem' import ListItemText from '@mui/material/ListItemText' @@ -6,7 +7,6 @@ import Button from '@mui/material/Button' import Chip from '@mui/material/Chip' import Tooltip from '@mui/material/Tooltip' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' -import isString from 'lodash/isString' import map from 'lodash/map' import Retired from '../common/Retired' @@ -14,6 +14,7 @@ import Score from './Score' import MapButton from './MapButton' import ConceptSummaryProperties from '../concepts/ConceptSummaryProperties' import { conceptBelongsToTargetRepo, conceptForMapping } from './viewBuilders.js' +import { localizedDisplayName, getMatchedName } from './conceptNames.js' // Format a contributor descriptor for the convergence tooltip: // "via CIEL:1234 Activated partial thromboplastin time — SAME-AS" @@ -43,39 +44,17 @@ export const MAP_TYPE_CHIP_SX = { } -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; -}; - - -const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition, bridgeContributors, contributingAlgorithms, contributingAlgorithmIds, openConceptPanel, AIRecommendedCandidateId, AIAlternateCandidateIds, onMap, isSelectedForMap, noScore, repoVersion, synonymPrefix, isAIRecommended, isAIAlternate, showAlgo, candidatesScore, algoScoreFirst, placeholderMap, bridgeChild}) => { +const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition, bridgeContributors, contributingAlgorithms, contributingAlgorithmIds, openConceptPanel, AIRecommendedCandidateId, AIAlternateCandidateIds, onMap, isSelectedForMap, noScore, repoVersion, inputLocale, matchedName, isAIRecommended, isAIAlternate, showAlgo, candidatesScore, algoScoreFirst, placeholderMap, bridgeChild}) => { + const { t } = useTranslation() const conceptToMap = conceptForMapping({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition}) const idLabel = conceptDefinition?.id || conceptDefinition?.reference?.code const sourceLabel = conceptDefinition?.source + // Each concept leads with its name in the project's input language + // (OpenConceptLab/ocl_issues#2909); the name that matched comes second. + const displayName = localizedDisplayName(conceptDefinition, inputLocale) const mapTypeToApply = candidate?.map_type const bridgePrefixLabel = bridgeConceptDefinition - ? `${bridgeConceptDefinition.source || ''}:${bridgeConceptDefinition.id || bridgeConceptDefinition.reference?.code} ${bridgeConceptDefinition.display_name || ''}`.trim() + ? `${bridgeConceptDefinition.source || ''}:${bridgeConceptDefinition.id || bridgeConceptDefinition.reference?.code} ${localizedDisplayName(bridgeConceptDefinition, inputLocale) || ''}`.trim() : '' // Score chip click opens the unified ConceptDetailsPanel with the Match // Details tab focused. openConceptPanel resolves the per-concept enrichment @@ -124,21 +103,14 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition )} - {`${sourceLabel || ''}:${idLabel} ${conceptDefinition?.display_name || ''}`.trim()} + {`${sourceLabel || ''}:${idLabel} ${displayName || ''}`.trim()} ) : ( <> {`${sourceLabel || ''}:${idLabel}`} - { - !bridgeConceptDefinition && synonymPrefix && - - - → - - } - {conceptDefinition?.display_name} + {displayName} { // Legacy algo-view path: a target row with bridge @@ -151,8 +123,8 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition {candidate?.map_type ? `[${candidate.map_type}]` : ''} → - {conceptDefinition?.display_name - ? `${sourceLabel || ''}:${idLabel} ${conceptDefinition.display_name}` + {displayName + ? `${sourceLabel || ''}:${idLabel} ${displayName}` : ''} @@ -169,6 +141,13 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition } secondary={
+ { + !bridgeConceptDefinition && matchedName && +
+ {t('map_project.matched_name')}{matchedName.locale ? ` (${matchedName.locale})` : ''}:  + +
+ }
@@ -318,7 +297,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; `noMatchedName` hides it everywhere. +const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown, onCardClick, onMap, isSelectedForMap, noScore, repoVersion, isAIRecommended, sx, notClickable, noMatchedName, 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 +317,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("", "").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('', "").replaceAll('', '') - } + const matchedName = noMatchedName ? null : getMatchedName({ + highlights: candidate?.highlights, + concept: conceptDefinition, + displayName: localizedDisplayName(conceptDefinition, inputLocale), + filterLocales: locales + }) const baseProps = { id: idForUI, @@ -357,7 +332,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 +365,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 +438,7 @@ const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown conceptRow={child.conceptRow} bridgeConceptDefinition={conceptDefinition} repoVersion={repoVersion} - synonymPrefix={synonymPrefix} + matchedName={matchedName} openConceptPanel={openConceptPanel} AIRecommendedCandidateId={AIRecommendedCandidateId} AIAlternateCandidateIds={AIAlternateCandidateIds} @@ -498,7 +474,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/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 7882629e..9c22525b 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -1133,7 +1133,7 @@ const MapProject = () => { null } - + ) @@ -6503,6 +6503,7 @@ const MapProject = () => { candidatesScore={candidatesScore} targetConcept={targetConcept} repoVersion={repoVersion} + inputLocale={inputLocale} row={row} rowIndex={rowIndex} repo={repo} @@ -6602,6 +6603,7 @@ const MapProject = () => { setAppliedFacets(() => ({...appliedFacets, [rowIndex]: filters})) fetchAllCandidatesForRow(null, 0, false, false, filters, true) }} + inputLocale={inputLocale} locales={filters.locale || ''} models={AIModels} selectedModel={AIModel} @@ -6641,6 +6643,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..ea979fa4 100644 --- a/src/components/map-projects/MappingDecisionResult.jsx +++ b/src/components/map-projects/MappingDecisionResult.jsx @@ -17,8 +17,9 @@ import Retired from '../common/Retired' import ConceptSummaryProperties from '../concepts/ConceptSummaryProperties' import MapButton from './MapButton' import Score from './Score' +import { localizedDisplayName } from './conceptNames.js' -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, openConceptPanel, candidatesScore}) => { const { t } = useTranslation(); const parentParams = targetConcept?.url ? URIToParentParams(targetConcept.url) : {} const contributingAlgorithms = targetConcept?.contributingAlgorithms @@ -162,7 +163,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} ${localizedDisplayName(targetConcept, inputLocale) || ''}`} { targetConcept.retired && diff --git a/src/components/map-projects/Search.jsx b/src/components/map-projects/Search.jsx index f1876613..ab394d73 100644 --- a/src/components/map-projects/Search.jsx +++ b/src/components/map-projects/Search.jsx @@ -24,7 +24,7 @@ import Concept 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') @@ -209,6 +209,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..306007b7 --- /dev/null +++ b/src/components/map-projects/__tests__/conceptNames.test.js @@ -0,0 +1,181 @@ +/** + * 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.equal(displayName, "Mother's Prenatal tests") + 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: ['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/conceptNames.js b/src/components/map-projects/conceptNames.js new file mode 100644 index 00000000..c9a417f5 --- /dev/null +++ b/src/components/map-projects/conceptNames.js @@ -0,0 +1,100 @@ +/** + * 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 plainText = html => (html || '').replaceAll('', '').replaceAll('', '') + +const sameName = (a, b) => { + const normalize = text => (text || '').replace(/\s+/g, ' ').trim().toLowerCase() + return normalize(a) === normalize(b) +} + +const toLocaleList = locales => (typeof locales === 'string' ? locales.split(',') : (locales || [])) + .map(locale => primarySubtag(locale?.trim())) + .filter(Boolean) + +// 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 list or 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 = plainText(fragment) + 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 = toLocaleList(filterLocales) + if(allowed.length && !allowed.includes(primarySubtag(entry?.locale))) + return null + return { + html: fragment.replaceAll('', "").replaceAll('', ''), + locale: entry?.locale + } +} diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 1367cf2e..5836fec6 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -576,6 +576,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 41180134..81c43b98 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -547,6 +547,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 303b8574..956daf08 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -576,6 +576,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 9953349a..f5244040 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -572,6 +572,7 @@ "view_existing_analysis": "查看现有", "run_new_analysis": "运行新的分析", "matched": "已匹配", + "matched_name": "匹配名称", "dates": "日期", "none": "无", "decision_changed_to_none": "决策已更改为无", From 797f81a8fbbe9d39c3b2c477ebbd4d6c45fa600b Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Wed, 14 Oct 2026 13:12:22 -0400 Subject: [PATCH 2/5] closes OpenConceptLab/ocl_issues#2909 | The Input Language help says candidates lead with their name in it The helper text said the input language only chose which name locales go to the OCL AI Assistant. It now also sets the name each candidate leads with, so the text says so, in en, es, pt-BR and zh, and in ConfigurationForm's English default. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NSAveBj6W6LKJLYTWJ9rwi --- src/components/map-projects/ConfigurationForm.jsx | 2 +- src/i18n/locales/en/translations.json | 2 +- src/i18n/locales/es/translations.json | 2 +- src/i18n/locales/pt-BR/translations.json | 2 +- src/i18n/locales/zh/translations.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/map-projects/ConfigurationForm.jsx b/src/components/map-projects/ConfigurationForm.jsx index 94de2a20..d30e628b 100644 --- a/src/components/map-projects/ConfigurationForm.jsx +++ b/src/components/map-projects/ConfigurationForm.jsx @@ -216,7 +216,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/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 5836fec6..86d9383f 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -423,7 +423,7 @@ "loading_name_locales": "Loading Name Locales...", "filter_name_locales_helper": "Note: Restricting candidates to specific languages limits cross-lingual matching capabilities, but may improve the accuracy of results for certain datasets, especially if the input data is in the same language as a target repository's default language.", "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", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 81c43b98..f56990ca 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -398,7 +398,7 @@ "loading_name_locales": "Cargando Locales de Nombre...", "filter_name_locales_helper": "Nota: Restringir candidatos a idiomas específicos limita las capacidades de coincidencia entre idiomas, pero puede mejorar la precisión de los resultados para ciertos conjuntos de datos, especialmente si los datos de entrada están en el mismo idioma que el idioma predeterminado del repositorio objetivo.", "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", diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index 956daf08..5d3b2183 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -423,7 +423,7 @@ "loading_name_locales": "Carregando idiomas de nome...", "filter_name_locales_helper": "Nota: Restringir candidatos a idiomas específicos limita capacidades de correspondência inter-idiomas, mas pode melhorar a precisão dos resultados para certos conjuntos de dados, especialmente se os dados de entrada estão no mesmo idioma que o idioma padrão do repositório de destino.", "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", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index f5244040..85385e3f 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -423,7 +423,7 @@ "loading_name_locales": "正在加载名称语言环境...", "filter_name_locales_helper": "注意:将候选对象限制为特定语言会限制跨语言匹配功能,但可能会提高某些数据集的准确性,特别是如果输入数据与目标存储库的默认语言相同。", "input_language": "输入语言", - "input_language_helper": "输入数据的语言。决定将目标存储库名称的哪些区域设置发送给 OCL AI 助手。", + "input_language_helper": "输入数据的语言。候选项优先显示该语言的名称,并决定将目标存储库名称的哪些区域设置发送给 OCL AI 助手。", "ai_effective_locales": "AI 将收到以下区域设置的名称:{{locales}}", "ai_all_locales": "AI 将收到所有区域设置的名称(不筛选)", "default_filter": "默认筛选器", From 071ef7a4afac23eb9a6379b405d2949dd839a3f2 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Wed, 14 Oct 2026 13:23:20 -0400 Subject: [PATCH 3/5] closes OpenConceptLab/ocl_issues#2909 | The grid, the row panel header and both tables name a concept as its candidate card does Codex review, pass 1: - F1.1: the grid's Target Code cell and the row panel's Target Code header now show the matched name, with its locale, under the name the target leads with, from the mapped concept's search highlights, as a candidate card does. The header's box has a fixed height, so there the line keeps to one line, with the whole of it on hover. The name-locale filter reaches both. Concept's noMatchedName prop, now unused, is gone. - F1.2: the Search tab's table view, like the candidates' one, names each concept in the input language. TableResults lets an extra column with a base column's id replace it in place (withExtraColumns, unit-tested), and both tables pass a name column that renders localizedDisplayName. The rows stay as they were, so Map and the concept panel keep the concept's own display_name. The candidates' rows no longer rename display_name. - F1.3: sorting candidates by display name sorts on the name shown in the input language (sortRowViews takes inputLocale), including bridge children. Tested. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NSAveBj6W6LKJLYTWJ9rwi --- src/components/map-projects/Candidates.jsx | 16 +++---- src/components/map-projects/Concept.jsx | 47 ++++++++++++++----- src/components/map-projects/MapProject.jsx | 3 +- .../map-projects/MappingDecisionResult.jsx | 17 +++++-- src/components/map-projects/Search.jsx | 3 +- .../map-projects/__tests__/views.test.js | 9 ++++ src/components/map-projects/viewBuilders.js | 11 +++-- src/components/search/TableResults.jsx | 4 +- .../search/__tests__/tableColumns.test.js | 37 +++++++++++++++ src/components/search/tableColumns.js | 15 ++++++ 10 files changed, 130 insertions(+), 32 deletions(-) create mode 100644 src/components/search/__tests__/tableColumns.test.js create mode 100644 src/components/search/tableColumns.js diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 231fe313..99bd9a1c 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -40,7 +40,7 @@ import SearchResults from '../search/SearchResults'; import SearchFilters from '../search/SearchFilters' import NoResults from '../search/NoResults'; import Mappings from './Mappings' -import Concept from './Concept' +import Concept, { localizedNameColumn } from './Concept' import ConceptIcon from '../concepts/ConceptIcon' import MapButton from './MapButton' import AICandidatesAnalysis from './AICandidatesAnalysis' @@ -55,7 +55,6 @@ import { getAIAnalysisCandidateIDs } from './viewBuilders.js' import { getRowProgressLabel } from './rowProgress.js' -import { localizedDisplayName } from './conceptNames.js' const Sort = ({ selected, onSort }) => { const { t } = useTranslation(); @@ -174,9 +173,6 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa // - Click lookup: SearchResults.handleRowClick uses // `row.version_url || row.url || row.id`. Set those explicitly at the // end so neither earlier spread can clobber them. - // - Table view's name column reads `display_name`: give it the name in - // the project's input language, as the card view shows. Mapping and - // the concept panel read the tuple, so they keep the concept's own. const rowsForTable = (rowViews || []).map(view => { const legacy = conceptForMapping(view) || {} const def = view?.conceptDefinition @@ -186,8 +182,7 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa ...view, id: idForLookup, url: legacy.url || def?.ocl_url, - version_url: legacy.url || def?.ocl_url, - display_name: localizedDisplayName(def, inputLocale) + version_url: legacy.url || def?.ocl_url } }) // Helper: a rowView is mappable only when its concept belongs to the @@ -207,6 +202,7 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa const getExtraColumns = () => { let cols = [ + localizedNameColumn(inputLocale), { sortable: false, id: 'mappings', @@ -534,9 +530,9 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe // key/order so the children's order tracks the parent rule. Bridge // children don't have their own raw score; sorting by algo_score // leaves them in insertion order via sortRowViews' -1 sentinel. - candidates: sortRowViews(views, sortBy, order).map(view => ( + candidates: sortRowViews(views, sortBy, order, inputLocale).map(view => ( view.type === 'bridge' && view.bridgeChildren?.length - ? { ...view, bridgeChildren: sortRowViews(view.bridgeChildren, sortBy, order) } + ? { ...view, bridgeChildren: sortRowViews(view.bridgeChildren, sortBy, order, inputLocale) } : view )) })) @@ -565,7 +561,7 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe let available = [] let lowRanked = [] let pendingRerank = [] - sortRowViews(qualityRowViews, sortBy, order).forEach(view => { + sortRowViews(qualityRowViews, sortBy, order, inputLocale).forEach(view => { const score = view.conceptRow?.rerank_score if(byScore) { // Bucket on score presence first: unscored rows belong in their diff --git a/src/components/map-projects/Concept.jsx b/src/components/map-projects/Concept.jsx index 66a1e1e1..01d1add8 100644 --- a/src/components/map-projects/Concept.jsx +++ b/src/components/map-projects/Concept.jsx @@ -43,9 +43,40 @@ export const MAP_TYPE_CHIP_SX = { } } +// The name the match was made on, with its locale, as a line under the name +// a concept leads with (getMatchedName). Exported for the row panel's +// Target Code header (MappingDecisionResult), whose box has a fixed height: +// `noWrap` keeps it to one line there, with the whole of it on hover. +export const MatchedName = ({ matchedName, noWrap }) => { + const { t } = useTranslation() + if(!matchedName) return null + const label = `${t('map_project.matched_name')}${matchedName.locale ? ` (${matchedName.locale})` : ''}:` + return ( + ]*>/g, '')}` : undefined} + > + {label}  + + + ) +} + +// Table view's name column, naming each concept in the project's input +// language as the cards do. It changes only how the column renders: rows, +// which Map and the concept panel read, keep the concept's display_name. +export const localizedNameColumn = inputLocale => ({ + id: 'name', + renderer: item => ( + + {item.retired && } {localizedDisplayName(item, inputLocale)} + + ) +}) + const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition, bridgeContributors, contributingAlgorithms, contributingAlgorithmIds, openConceptPanel, AIRecommendedCandidateId, AIAlternateCandidateIds, onMap, isSelectedForMap, noScore, repoVersion, inputLocale, matchedName, isAIRecommended, isAIAlternate, showAlgo, candidatesScore, algoScoreFirst, placeholderMap, bridgeChild}) => { - const { t } = useTranslation() const conceptToMap = conceptForMapping({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition}) const idLabel = conceptDefinition?.id || conceptDefinition?.reference?.code const sourceLabel = conceptDefinition?.source @@ -141,13 +172,7 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition } secondary={
- { - !bridgeConceptDefinition && matchedName && -
- {t('map_project.matched_name')}{matchedName.locale ? ` (${matchedName.locale})` : ''}:  - -
- } + {!bridgeConceptDefinition && }
@@ -300,8 +325,8 @@ const legacyToRowView = (legacy) => { // // `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; `noMatchedName` hides it everywhere. -const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown, onCardClick, onMap, isSelectedForMap, noScore, repoVersion, isAIRecommended, sx, notClickable, noMatchedName, inputLocale, locales, showAlgo, candidatesScore, algoScoreFirst, asTarget, AIRecommendedCandidateId, AIAlternateCandidateIds, targetCanonical, targetRelativeUrl}) => { +// 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 @@ -317,7 +342,7 @@ const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown const isSelectedToShow = isShown ? isShown(idForUI) : false const aiAlternateIds = Array.isArray(AIAlternateCandidateIds) ? AIAlternateCandidateIds : [] - const matchedName = noMatchedName ? null : getMatchedName({ + const matchedName = getMatchedName({ highlights: candidate?.highlights, concept: conceptDefinition, displayName: localizedDisplayName(conceptDefinition, inputLocale), diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 9c22525b..2021761c 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -1133,7 +1133,7 @@ const MapProject = () => { null } - + ) @@ -6504,6 +6504,7 @@ const MapProject = () => { targetConcept={targetConcept} repoVersion={repoVersion} inputLocale={inputLocale} + locales={filters.locale || ''} row={row} rowIndex={rowIndex} repo={repo} diff --git a/src/components/map-projects/MappingDecisionResult.jsx b/src/components/map-projects/MappingDecisionResult.jsx index ea979fa4..f1c00a63 100644 --- a/src/components/map-projects/MappingDecisionResult.jsx +++ b/src/components/map-projects/MappingDecisionResult.jsx @@ -17,10 +17,20 @@ import Retired from '../common/Retired' import ConceptSummaryProperties from '../concepts/ConceptSummaryProperties' import MapButton from './MapButton' import Score from './Score' -import { localizedDisplayName } from './conceptNames.js' +import { localizedDisplayName, getMatchedName } from './conceptNames.js' +import { MatchedName } from './Concept' -const MappingDecisionResult = ({targetConcept, row, rowIndex, mapTypes, allMapTypes, onMap, proposed, columns, repoVersion, inputLocale, 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 @@ -163,7 +173,7 @@ const MappingDecisionResult = ({targetConcept, row, rowIndex, mapTypes, allMapTy primary={ - {`${parentParams.repo || targetConcept.repo?.short_code || targetConcept?.repo?.id}:${targetConcept.id} ${localizedDisplayName(targetConcept, inputLocale) || ''}`} + {`${parentParams.repo || targetConcept.repo?.short_code || targetConcept?.repo?.id}:${targetConcept.id} ${targetName || ''}`} { targetConcept.retired && @@ -173,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 ab394d73..d82ba408 100644 --- a/src/components/map-projects/Search.jsx +++ b/src/components/map-projects/Search.jsx @@ -20,7 +20,7 @@ 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' @@ -61,6 +61,7 @@ const Search = ({searchStr, setSearchStr, onSearch, repo, repoVersion, concepts, const getExtraColumns = () => { let cols = [ + localizedNameColumn(inputLocale), { sortable: false, id: 'mappings', diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index 86742fbb..a0794f9b 100644 --- a/src/components/map-projects/__tests__/views.test.js +++ b/src/components/map-projects/__tests__/views.test.js @@ -648,6 +648,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: {} }, diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 719a3dd2..9234ac9f 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 } } 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..89e86569 --- /dev/null +++ b/src/components/search/__tests__/tableColumns.test.js @@ -0,0 +1,37 @@ +/** + * 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: appends extra columns after the base ones', () => { + const mappings = { id: 'mappings', renderer: () => 'map' } + assert.deepEqual(withExtraColumns(BASE, [mappings]).map(column => column.id), ['id', 'name', 'datatype', 'mappings']) +}) + +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)) + ] +} From d03da8cdc3b494af978c08dd8b3ef18327a4fec7 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Wed, 14 Oct 2026 13:31:18 -0400 Subject: [PATCH 4/5] closes OpenConceptLab/ocl_issues#2909 | A reopened project's grid, and both tables, show the matched name too Codex review, pass 2: - F2.1 (and F1.1, which it reopened): a project reopened from its saved file restores each mapped target from the CSV, with no names or highlights, so its grid cell named it in the repository's language and showed no matched name. withCandidateNames gives the target the names and highlights its row's candidates hold for it, found by findTargetRowView: the URL-then-id match the row panel header already used, now shared. Both are unit-tested with a target as a reopened project restores it. - F2.2: the table views' name column shows the matched name, with its locale, under the name, as a candidate card does, and the name-locale filter reaches it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NSAveBj6W6LKJLYTWJ9rwi --- src/components/map-projects/Candidates.jsx | 2 +- src/components/map-projects/Concept.jsx | 29 ++++++--- src/components/map-projects/MapProject.jsx | 12 +--- src/components/map-projects/Search.jsx | 2 +- .../map-projects/__tests__/views.test.js | 63 ++++++++++++++++++- src/components/map-projects/viewBuilders.js | 36 +++++++++++ 6 files changed, 123 insertions(+), 21 deletions(-) diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 99bd9a1c..96e54935 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -202,7 +202,7 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa const getExtraColumns = () => { let cols = [ - localizedNameColumn(inputLocale), + localizedNameColumn(inputLocale, locales), { sortable: false, id: 'mappings', diff --git a/src/components/map-projects/Concept.jsx b/src/components/map-projects/Concept.jsx index 01d1add8..ee79221b 100644 --- a/src/components/map-projects/Concept.jsx +++ b/src/components/map-projects/Concept.jsx @@ -63,16 +63,27 @@ export const MatchedName = ({ matchedName, noWrap }) => { ) } -// Table view's name column, naming each concept in the project's input -// language as the cards do. It changes only how the column renders: rows, -// which Map and the concept panel read, keep the concept's display_name. -export const localizedNameColumn = inputLocale => ({ +// Table view's name column, naming each concept as the cards do: in the +// project's input language, with the name it matched on below. It changes +// only how the column renders: rows, which Map and the concept panel read, +// keep the concept's display_name. +export const localizedNameColumn = (inputLocale, locales) => ({ id: 'name', - renderer: item => ( - - {item.retired && } {localizedDisplayName(item, inputLocale)} - - ) + renderer: item => { + const displayName = localizedDisplayName(item, inputLocale) + const matchedName = getMatchedName({ + highlights: item.search_meta?.search_highlight, + concept: item, + displayName, + filterLocales: locales + }) + return ( + + {item.retired && } {displayName} + + + ) + } }) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 2021761c..4a19dafb 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 { getDefaultTargetRepoVersion, getProjectTargetRepoVersion, 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' @@ -1133,7 +1133,7 @@ const MapProject = () => { null } - + ) @@ -5344,13 +5344,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 = { diff --git a/src/components/map-projects/Search.jsx b/src/components/map-projects/Search.jsx index d82ba408..bf040a5e 100644 --- a/src/components/map-projects/Search.jsx +++ b/src/components/map-projects/Search.jsx @@ -61,7 +61,7 @@ const Search = ({searchStr, setSearchStr, onSearch, repo, repoVersion, concepts, const getExtraColumns = () => { let cols = [ - localizedNameColumn(inputLocale), + localizedNameColumn(inputLocale, locales), { sortable: false, id: 'mappings', diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index a0794f9b..f6eaae44 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 ---------- @@ -674,3 +676,62 @@ 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) + assert.equal(findTargetRowView(views, null), 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/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 9234ac9f..1bc87500 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -414,6 +414,42 @@ 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) => { + if(!target) return undefined + 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 = rowState ? findTargetRowView(buildQualityRowViews(rowState, conceptCache), target) : undefined + 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 From 19f6eba865e75fd43323252b0efcc85a26d4fe7d Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Thu, 15 Oct 2026 21:57:28 -0400 Subject: [PATCH 5/5] OpenConceptLab/ocl_issues#2909 | Cut what the simplification review found unused or repeated Co-Authored-By: Claude Opus 5.5 --- src/components/map-projects/Concept.jsx | 1 - .../map-projects/__tests__/conceptNames.test.js | 2 -- src/components/map-projects/__tests__/views.test.js | 1 - src/components/map-projects/conceptNames.js | 12 +++--------- src/components/map-projects/viewBuilders.js | 5 ++--- src/components/search/__tests__/tableColumns.test.js | 5 ----- 6 files changed, 5 insertions(+), 21 deletions(-) diff --git a/src/components/map-projects/Concept.jsx b/src/components/map-projects/Concept.jsx index ee79221b..3dbc5a79 100644 --- a/src/components/map-projects/Concept.jsx +++ b/src/components/map-projects/Concept.jsx @@ -474,7 +474,6 @@ const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown conceptRow={child.conceptRow} bridgeConceptDefinition={conceptDefinition} repoVersion={repoVersion} - matchedName={matchedName} openConceptPanel={openConceptPanel} AIRecommendedCandidateId={AIRecommendedCandidateId} AIAlternateCandidateIds={AIAlternateCandidateIds} diff --git a/src/components/map-projects/__tests__/conceptNames.test.js b/src/components/map-projects/__tests__/conceptNames.test.js index 306007b7..8af53cc5 100644 --- a/src/components/map-projects/__tests__/conceptNames.test.js +++ b/src/components/map-projects/__tests__/conceptNames.test.js @@ -84,7 +84,6 @@ test('localizedDisplayName: falls back to the repository default-locale display test('getMatchedName: the translated name that matched comes second, with its locale (the ticket case)', () => { const displayName = localizedDisplayName(PRENATAL_TESTS, 'en') - assert.equal(displayName, "Mother's Prenatal tests") assert.deepEqual( getMatchedName({ highlights: { synonyms: ['Anamnesi Punto nel tempo (episodio) Test prenatale'] }, @@ -161,7 +160,6 @@ test('getMatchedName: hides a matched name outside the name-locale filter, compa } assert.equal(getMatchedName({ ...args, filterLocales: 'en,es' }), null) assert.equal(getMatchedName({ ...args, filterLocales: 'en, it' })?.locale, 'it-IT') - assert.equal(getMatchedName({ ...args, filterLocales: ['it'] })?.locale, 'it-IT') assert.equal(getMatchedName({ ...args, filterLocales: '' })?.locale, 'it-IT') }) diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index f6eaae44..fb9ddfae 100644 --- a/src/components/map-projects/__tests__/views.test.js +++ b/src/components/map-projects/__tests__/views.test.js @@ -712,7 +712,6 @@ test('findTargetRowView: matches a target on its OCL URL, or on its id when eith 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) - assert.equal(findTargetRowView(views, null), undefined) }) test('withCandidateNames: a restored target gets the names and highlights its row\'s candidates hold for it', () => { diff --git a/src/components/map-projects/conceptNames.js b/src/components/map-projects/conceptNames.js index c9a417f5..5beea67b 100644 --- a/src/components/map-projects/conceptNames.js +++ b/src/components/map-projects/conceptNames.js @@ -13,17 +13,11 @@ import { primarySubtag } from './normalizers.js' const FULLY_SPECIFIED = /^fully[\s_-]?specified$/i -const plainText = html => (html || '').replaceAll('', '').replaceAll('', '') - const sameName = (a, b) => { const normalize = text => (text || '').replace(/\s+/g, ' ').trim().toLowerCase() return normalize(a) === normalize(b) } -const toLocaleList = locales => (typeof locales === 'string' ? locales.split(',') : (locales || [])) - .map(locale => primarySubtag(locale?.trim())) - .filter(Boolean) - // 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 = []) => { @@ -79,18 +73,18 @@ export const localizedDisplayName = (concept, inputLocale) => { * 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 list or a comma-separated string) hides a + * 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 = plainText(fragment) + 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 = toLocaleList(filterLocales) + const allowed = (filterLocales || '').split(',').map(locale => primarySubtag(locale.trim())).filter(Boolean) if(allowed.length && !allowed.includes(primarySubtag(entry?.locale))) return null return { diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 1bc87500..76997ce2 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -419,8 +419,7 @@ export const conceptForMapping = (rowView) => { * OCL URL or, when either lacks one, its id. */ export const findTargetRowView = (views, target) => { - if(!target) return undefined - return (views || []).find(view => { + return views.find(view => { const mapped = conceptForMapping(view) if(!mapped) return false if(target.url && mapped.url) @@ -438,7 +437,7 @@ export const findTargetRowView = (views, target) => { * it is. */ export const withCandidateNames = (target, rowState, conceptCache) => { - const view = rowState ? findTargetRowView(buildQualityRowViews(rowState, conceptCache), target) : undefined + const view = findTargetRowView(buildQualityRowViews(rowState, conceptCache), target) if(!view) return target return { ...target, diff --git a/src/components/search/__tests__/tableColumns.test.js b/src/components/search/__tests__/tableColumns.test.js index 89e86569..024a22e3 100644 --- a/src/components/search/__tests__/tableColumns.test.js +++ b/src/components/search/__tests__/tableColumns.test.js @@ -16,11 +16,6 @@ const BASE = [ { id: 'datatype', value: 'datatype' } ] -test('withExtraColumns: appends extra columns after the base ones', () => { - const mappings = { id: 'mappings', renderer: () => 'map' } - assert.deepEqual(withExtraColumns(BASE, [mappings]).map(column => column.id), ['id', 'name', 'datatype', 'mappings']) -}) - 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' }])