From 064516361cfe3121be049cb1e274b38ea0a11f5d Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Thu, 15 Oct 2026 21:57:50 -0400 Subject: [PATCH 1/4] closes OpenConceptLab/ocl_issues#2916 | A reopened project restores each mapped target's names and match highlight from its saved matches Co-Authored-By: Claude Opus 5.5 --- src/components/map-projects/MapProject.jsx | 8 ++- .../__tests__/conceptNames.test.js | 11 +++++ .../map-projects/__tests__/views.test.js | 49 ++++++++++++++++++- src/components/map-projects/conceptNames.js | 5 +- src/components/map-projects/viewBuilders.js | 18 +++++++ 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 6fa4ff3a..a9e3061f 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -53,6 +53,7 @@ import omitBy from 'lodash/omitBy' import reject from 'lodash/reject' import uniq from 'lodash/uniq' import compact from 'lodash/compact' +import keyBy from 'lodash/keyBy' import flatten from 'lodash/flatten' import keys from 'lodash/keys' import pickBy from 'lodash/pickBy' @@ -115,7 +116,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, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, withCandidateNames } from './viewBuilders.js' +import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, withCandidateNames, withSavedMatch } from './viewBuilders.js' import { formatClockTime, getCapacityWaitLabel, mergeCapacityWaits } from './rowProgress.js' import './MapProject.scss' @@ -1415,6 +1416,9 @@ const MapProject = () => { let _proposed = {} let _repo = null let _states = {...rowStatuses} + // Each mapped target's saved concept, by row: its names and match + // highlight aren't in the CSV (OpenConceptLab/ocl_issues#2916). + const savedMatches = keyBy(compact(projectData?.matches), 'rowIndex') const repoVersionURL = data['__Repo URL__'] || data['__map_repo_url__'] const repoURL = dropVersion(repoVersionURL) const repoVersion = data['__Repo Version__'] || getTargetRepoVersionFromUrl(repoVersionURL) @@ -1439,7 +1443,7 @@ const MapProject = () => { repo: repo } if(concept?.id) { - _mapSelected[index] = concept + _mapSelected[index] = withSavedMatch(concept, savedMatches[index]) _repo = repo } let rowStateLabel = data['__State__'] || data['__row_status__'] || data['__row_map_status__'] diff --git a/src/components/map-projects/__tests__/conceptNames.test.js b/src/components/map-projects/__tests__/conceptNames.test.js index 8af53cc5..c5860c63 100644 --- a/src/components/map-projects/__tests__/conceptNames.test.js +++ b/src/components/map-projects/__tests__/conceptNames.test.js @@ -177,3 +177,14 @@ 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) }) + +test('getMatchedName: only the highlight tags are markup', () => { + assert.deepEqual( + getMatchedName({ + highlights: { synonyms: ["Mother's & Prenatal"] }, + concept: { names: [] }, + displayName: 'Prenatal tests' + }), + { html: "<i>Mother's</i> & Prenatal", locale: undefined } + ) +}) diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index fb9ddfae..551f9198 100644 --- a/src/components/map-projects/__tests__/views.test.js +++ b/src/components/map-projects/__tests__/views.test.js @@ -25,7 +25,8 @@ import { conceptForMapping, sortRowViews, findTargetRowView, - withCandidateNames + withCandidateNames, + withSavedMatch } from '../viewBuilders.js' // ---------- Helpers ---------- @@ -734,3 +735,49 @@ test('withCandidateNames: keeps a target\'s own names and highlights, and a targ assert.equal(withCandidateNames(searched, namedRowState, namedCache), searched) assert.equal(withCandidateNames(restoredTarget, undefined, namedCache), restoredTarget) }) + +// ---------- withSavedMatch (OpenConceptLab/ocl_issues#2916) ---------- + +// A target mapped from the Search tab: none of the row's candidates holds it. +const searchedNames = [ + { name: 'Hemoglobin [Mass/volume] in Blood', locale: 'en', locale_preferred: true }, + { name: 'Hemoglobina en sangre', locale: 'es', locale_preferred: true } +] +const searchedHighlight = { synonyms: ['Hemoglobina en sangre'] } +const searchedTarget = { + id: '718-7', + display_name: 'Hemoglobin [Mass/volume] in Blood', + url: '/orgs/Regenstrief/sources/LOINC/concepts/718-7/', + search_meta: { search_normalized_score: '', search_score: '', algorithm: '' }, + repo: { id: 'LOINC' } +} +// Its `matches` entry, as a save sends it: the concept the project held. +const savedEntry = { + url: searchedTarget.url, + rowIndex: '3', + concept: { ...searchedTarget, names: searchedNames, search_meta: { search_score: 12.5, search_highlight: searchedHighlight } } +} + +test('withSavedMatch: a restored target gets the names and highlight its saved entry holds', () => { + const out = withSavedMatch(searchedTarget, savedEntry) + assert.deepEqual(out.names, searchedNames) + assert.deepEqual(out.search_meta, { ...searchedTarget.search_meta, search_highlight: searchedHighlight }) + assert.equal(out.display_name, searchedTarget.display_name) + assert.equal(out.repo, searchedTarget.repo) + assert.equal(withCandidateNames(out, namedRowState, namedCache).names, searchedNames) +}) + +test('withSavedMatch: an entry for another concept, or none, leaves the target as its CSV restored it', () => { + const other = { ...savedEntry, url: '/orgs/Regenstrief/sources/LOINC/concepts/2093-3/' } + assert.deepEqual(withSavedMatch(searchedTarget, other), searchedTarget) + assert.deepEqual(withSavedMatch(searchedTarget, undefined), searchedTarget) + assert.deepEqual(withSavedMatch(searchedTarget, { url: searchedTarget.url, rowIndex: '3' }), searchedTarget) +}) + +test('withSavedMatch: what the next save sends for a restored target restores it again', () => { + const restored = withSavedMatch(searchedTarget, savedEntry) + const resaved = JSON.parse(JSON.stringify({ url: restored.url, rowIndex: '3', concept: restored })) + const reopened = withSavedMatch(searchedTarget, resaved) + assert.deepEqual(reopened.names, searchedNames) + assert.deepEqual(reopened.search_meta.search_highlight, searchedHighlight) +}) diff --git a/src/components/map-projects/conceptNames.js b/src/components/map-projects/conceptNames.js index 5beea67b..1d387628 100644 --- a/src/components/map-projects/conceptNames.js +++ b/src/components/map-projects/conceptNames.js @@ -13,6 +13,9 @@ import { primarySubtag } from './normalizers.js' const FULLY_SPECIFIED = /^fully[\s_-]?specified$/i +// A highlight can come from a saved project, so only its tags are markup. +const escapeHtml = text => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') + const sameName = (a, b) => { const normalize = text => (text || '').replace(/\s+/g, ' ').trim().toLowerCase() return normalize(a) === normalize(b) @@ -88,7 +91,7 @@ export const getMatchedName = ({highlights, concept, displayName, filterLocales} if(allowed.length && !allowed.includes(primarySubtag(entry?.locale))) return null return { - html: fragment.replaceAll('', "").replaceAll('', ''), + html: escapeHtml(fragment).replaceAll('<em>', "").replaceAll('</em>', ''), locale: entry?.locale } } diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 76997ce2..4cece3e1 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -449,6 +449,24 @@ export const withCandidateNames = (target, rowState, conceptCache) => { } } +/** + * A mapped target restored from a saved project's CSV, with the `names` and + * the match highlight its row's `matches` entry saved for it + * (OpenConceptLab/ocl_issues#2916). The CSV keeps only the target's id, name + * and URL, but every save also sends each mapped target's concept in + * `matches`. A target mapped from the Search tab has no candidate to take them + * from. An entry for another concept is ignored. + */ +export const withSavedMatch = (target, savedMatch) => { + const saved = savedMatch?.url && savedMatch.url === target?.url ? savedMatch.concept : undefined + const highlight = saved?.search_meta?.search_highlight + return { + ...target, + ...(Array.isArray(saved?.names) ? {names: saved.names} : {}), + ...(highlight ? {search_meta: {...target?.search_meta, search_highlight: highlight}} : {}) + } +} + /** * Compute the unified score (0-100 percentile) and the raw algorithm score * for a candidate+row. Unified score = per-(row, concept) rerank score From 777740f83b59d6fe6136b4827fcc55d9831e840f Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Thu, 15 Oct 2026 22:15:02 -0400 Subject: [PATCH 2/4] OpenConceptLab/ocl_issues#2916 | Keep a mapped target's own names and match per row, and escape highlights in Match Details - getConcept returns a copy of the cached concept with the row's own names and match (withCachedConcept), instead of writing them into the shared entry - the row panel keeps the target's own names and highlight over a candidate's (withOwnNames) - Match Details renders highlights through highlightHtml, as the matched name does Co-Authored-By: Claude Opus 5.5 --- src/components/map-projects/MapProject.jsx | 17 +++---- .../map-projects/MatchDetailsPanel.jsx | 3 +- .../__tests__/conceptNames.test.js | 3 +- .../map-projects/__tests__/views.test.js | 51 ++++++++++++++++--- src/components/map-projects/conceptNames.js | 11 +++- src/components/map-projects/viewBuilders.js | 24 +++++++++ 6 files changed, 88 insertions(+), 21 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index a9e3061f..a03cd2a3 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -116,7 +116,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, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, withCandidateNames, withSavedMatch } from './viewBuilders.js' +import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, withCachedConcept, withCandidateNames, withOwnNames, withSavedMatch } from './viewBuilders.js' import { formatClockTime, getCapacityWaitLabel, mergeCapacityWaits } from './rowProgress.js' import './MapProject.scss' @@ -5324,15 +5324,7 @@ const MapProject = () => { } const getConcept = (concept, returnSelf=true) => { - let cached = (concept?.url || concept?.id) ? findConceptByIdOrURLFromCache(concept?.url || concept?.id) : false - if(cached && concept?.search_meta) - cached.search_meta = concept.search_meta - if(cached && concept?.source) - cached.source = concept.source - if(cached && concept?.contributingAlgorithms) - cached.contributingAlgorithms = concept.contributingAlgorithms - if(cached && concept?.contributingAlgorithmIds) - cached.contributingAlgorithmIds = concept.contributingAlgorithmIds + const cached = withCachedConcept(concept, (concept?.url || concept?.id) ? findConceptByIdOrURLFromCache(concept?.url || concept?.id) : false) return returnSelf ? (cached || concept) : cached } @@ -5342,7 +5334,8 @@ const MapProject = () => { const isConfigureInSplitView = configure && file?.name const columnsForTable = getColumnsForTable() - let targetConcept = mapSelected[rowIndex] ? getConcept(mapSelected[rowIndex], true) : false + // A copy: the code below changes it, and mapSelected holds the mapped target. + let targetConcept = mapSelected[rowIndex] ? {...getConcept(mapSelected[rowIndex], true)} : false const targetRowViews = rowMatchStateRef.current?.[rowIndex] ? buildQualityRowViews(rowMatchStateRef.current[rowIndex], conceptCacheRef.current) : [] @@ -5368,6 +5361,8 @@ const MapProject = () => { if(meta?.search_normalized_score) targetConcept.search_meta = meta } + if(targetConcept) + targetConcept = withOwnNames(targetConcept, mapSelected[rowIndex]) const labelDisplayedRows = ({ from, to, count }) => { return `${from.toLocaleString()}–${to.toLocaleString()} of ${count?.toLocaleString()}`; diff --git a/src/components/map-projects/MatchDetailsPanel.jsx b/src/components/map-projects/MatchDetailsPanel.jsx index fef320d0..1fbf05a5 100644 --- a/src/components/map-projects/MatchDetailsPanel.jsx +++ b/src/components/map-projects/MatchDetailsPanel.jsx @@ -11,6 +11,7 @@ import map from 'lodash/map' import startCase from 'lodash/startCase' import { getScoreDetails, ScoreValueChip } from './Score' +import { highlightHtml } from './conceptNames.js' // Per-algorithm score chip rendered inline. Used both for the legacy single- // algorithm case (raw_score = candidate.score) and the multi-algo aggregated @@ -163,7 +164,7 @@ const MatchDetailsPanel = ({concept, contributingAlgorithms, candidatesScore}) = > { map(values, value => { - const highlightedValue = value.replaceAll('', '').replaceAll('', '') + const highlightedValue = highlightHtml(value) return ( // Match the Concept Details Names/Descriptions typography // (Locales.jsx LocalePrimary): 0.875rem, primary text, diff --git a/src/components/map-projects/__tests__/conceptNames.test.js b/src/components/map-projects/__tests__/conceptNames.test.js index c5860c63..b2cd838a 100644 --- a/src/components/map-projects/__tests__/conceptNames.test.js +++ b/src/components/map-projects/__tests__/conceptNames.test.js @@ -12,7 +12,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { localizedDisplayName, getMatchedName } from '../conceptNames.js' +import { localizedDisplayName, getMatchedName, highlightHtml } 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". @@ -187,4 +187,5 @@ test('getMatchedName: only the highlight tags are markup', () => { }), { html: "<i>Mother's</i> & Prenatal", locale: undefined } ) + assert.equal(highlightHtml('Hb Hemoglobin'), '<i>Hb</i> Hemoglobin') }) diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index 551f9198..83380f73 100644 --- a/src/components/map-projects/__tests__/views.test.js +++ b/src/components/map-projects/__tests__/views.test.js @@ -25,7 +25,9 @@ import { conceptForMapping, sortRowViews, findTargetRowView, + withCachedConcept, withCandidateNames, + withOwnNames, withSavedMatch } from '../viewBuilders.js' @@ -774,10 +776,47 @@ test('withSavedMatch: an entry for another concept, or none, leaves the target a assert.deepEqual(withSavedMatch(searchedTarget, { url: searchedTarget.url, rowIndex: '3' }), searchedTarget) }) -test('withSavedMatch: what the next save sends for a restored target restores it again', () => { - const restored = withSavedMatch(searchedTarget, savedEntry) - const resaved = JSON.parse(JSON.stringify({ url: restored.url, rowIndex: '3', concept: restored })) - const reopened = withSavedMatch(searchedTarget, resaved) - assert.deepEqual(reopened.names, searchedNames) - assert.deepEqual(reopened.search_meta.search_highlight, searchedHighlight) +// Two rows mapped to the same concept, each with its own match. The save sends +// each row's concept as getConcept gives it: withCachedConcept over the +// concept a row panel cached, or the target itself. +const otherHighlight = { synonyms: ['Hemoglobin [Mass/volume] in Blood'] } +const otherEntry = { ...savedEntry, rowIndex: '4', concept: { ...savedEntry.concept, search_meta: { search_highlight: otherHighlight } } } +const saveEntries = (targets, cached) => targets.map(([rowIndex, target]) => + ({ url: target.url, rowIndex, concept: withCachedConcept(target, cached) || target })) + +test('withSavedMatch: what the next save sends restores each row again, cached or not', () => { + const rows = [['3', withSavedMatch(searchedTarget, savedEntry)], ['4', withSavedMatch(searchedTarget, otherEntry)]] + const cached = { ...searchedTarget, names: searchedNames, search_meta: { search_score: 1 } } + for(const cache of [undefined, cached]) { + const [first, second] = JSON.parse(JSON.stringify(saveEntries(rows, cache))) + assert.deepEqual(withSavedMatch(searchedTarget, first).names, searchedNames) + assert.deepEqual(withSavedMatch(searchedTarget, first).search_meta.search_highlight, searchedHighlight) + assert.deepEqual(withSavedMatch(searchedTarget, second).search_meta.search_highlight, otherHighlight) + } + assert.deepEqual(cached.search_meta, { search_score: 1 }) +}) + +test('withCachedConcept: each row gets its own copy of the cached concept, with its own match', () => { + const cached = { id: '718-7', url: searchedTarget.url, names: searchedNames, search_meta: { search_score: 1 } } + const first = withCachedConcept({ search_meta: { search_highlight: searchedHighlight }, source: 'LOINC' }, cached) + const second = withCachedConcept({ search_meta: { search_highlight: otherHighlight } }, cached) + assert.deepEqual(first.search_meta.search_highlight, searchedHighlight) + assert.equal(first.source, 'LOINC') + assert.deepEqual(second.search_meta.search_highlight, otherHighlight) + assert.equal(second.names, searchedNames) + assert.equal(withCachedConcept({ names: glucoseNames }, cached).names, glucoseNames) + assert.deepEqual(cached, { id: '718-7', url: searchedTarget.url, names: searchedNames, search_meta: { search_score: 1 } }) + assert.equal(withCachedConcept(searchedTarget, false), false) +}) + +test('withOwnNames: a mapped target keeps its own names and highlight over a candidate\'s, and takes the candidate\'s when it has none', () => { + const own = withSavedMatch(searchedTarget, savedEntry) + // A semantic candidate for the same concept: no names in its definition, no highlight. + const fromCandidate = { ...searchedTarget, names: undefined, search_meta: { algorithm: 'ocl-semantic', search_normalized_score: 91 } } + const out = withOwnNames(fromCandidate, own) + assert.equal(out.names, searchedNames) + assert.deepEqual(out.search_meta, { algorithm: 'ocl-semantic', search_normalized_score: 91, search_highlight: searchedHighlight }) + const withNames = { ...fromCandidate, names: glucoseNames, search_meta: { search_highlight: { name: ['x'] } } } + assert.equal(withOwnNames(withNames, searchedTarget).names, glucoseNames) + assert.deepEqual(withOwnNames(withNames, searchedTarget).search_meta.search_highlight, { name: ['x'] }) }) diff --git a/src/components/map-projects/conceptNames.js b/src/components/map-projects/conceptNames.js index 1d387628..59000bb9 100644 --- a/src/components/map-projects/conceptNames.js +++ b/src/components/map-projects/conceptNames.js @@ -13,9 +13,16 @@ import { primarySubtag } from './normalizers.js' const FULLY_SPECIFIED = /^fully[\s_-]?specified$/i -// A highlight can come from a saved project, so only its tags are markup. const escapeHtml = text => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') +/** + * A search highlight as HTML, with its tags as `open` and `close`. A + * highlight can come from a saved project, so the rest is escaped: only + * is markup. + */ +export const highlightHtml = (fragment, open = '', close = '') => + escapeHtml(fragment).replaceAll('<em>', open).replaceAll('</em>', close) + const sameName = (a, b) => { const normalize = text => (text || '').replace(/\s+/g, ' ').trim().toLowerCase() return normalize(a) === normalize(b) @@ -91,7 +98,7 @@ export const getMatchedName = ({highlights, concept, displayName, filterLocales} if(allowed.length && !allowed.includes(primarySubtag(entry?.locale))) return null return { - html: escapeHtml(fragment).replaceAll('<em>', "").replaceAll('</em>', ''), + html: highlightHtml(fragment, ""), locale: entry?.locale } } diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 4cece3e1..b0a2d540 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -467,6 +467,30 @@ export const withSavedMatch = (target, savedMatch) => { } } +/** + * A mapped target with its own names and match highlight put back over a + * candidate's: the ones it was mapped with, or that a reopened project + * restored (OpenConceptLab/ocl_issues#2916). + */ +export const withOwnNames = (target, own) => ({ + ...target, + names: own?.names || target.names, + search_meta: {...target.search_meta, search_highlight: own?.search_meta?.search_highlight || target.search_meta?.search_highlight} +}) + +const ROW_OWN_FIELDS = ['names', 'search_meta', 'source', 'contributingAlgorithms', 'contributingAlgorithmIds'] + +/** + * The cached concept a mapped target names, with the target's own names, + * `search_meta`, source and contributing algorithms, as `withOwnNames` keeps + * them. A copy: every row mapped to the concept shares the cache entry. + * Returns `cached` as it is when there is none. + */ +export const withCachedConcept = (target, cached) => cached ? { + ...cached, + ...Object.fromEntries(ROW_OWN_FIELDS.filter(field => target?.[field]).map(field => [field, target[field]])) +} : cached + /** * Compute the unified score (0-100 percentile) and the raw algorithm score * for a candidate+row. Unified score = per-(row, concept) rerank score From a146c7e69d8170c7208b11b81ccb9be5515677e4 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Thu, 15 Oct 2026 22:22:22 -0400 Subject: [PATCH 3/4] OpenConceptLab/ocl_issues#2916 | Cut what the simplification review found unused or repeated Co-Authored-By: Claude Opus 5.5 --- src/components/map-projects/MapProject.jsx | 2 -- src/components/map-projects/__tests__/views.test.js | 1 - src/components/map-projects/conceptNames.js | 6 +++--- src/components/map-projects/viewBuilders.js | 5 ++--- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index a03cd2a3..fcaaebc7 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -1416,8 +1416,6 @@ const MapProject = () => { let _proposed = {} let _repo = null let _states = {...rowStatuses} - // Each mapped target's saved concept, by row: its names and match - // highlight aren't in the CSV (OpenConceptLab/ocl_issues#2916). const savedMatches = keyBy(compact(projectData?.matches), 'rowIndex') const repoVersionURL = data['__Repo URL__'] || data['__map_repo_url__'] const repoURL = dropVersion(repoVersionURL) diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index 83380f73..898f6eef 100644 --- a/src/components/map-projects/__tests__/views.test.js +++ b/src/components/map-projects/__tests__/views.test.js @@ -766,7 +766,6 @@ test('withSavedMatch: a restored target gets the names and highlight its saved e assert.deepEqual(out.search_meta, { ...searchedTarget.search_meta, search_highlight: searchedHighlight }) assert.equal(out.display_name, searchedTarget.display_name) assert.equal(out.repo, searchedTarget.repo) - assert.equal(withCandidateNames(out, namedRowState, namedCache).names, searchedNames) }) test('withSavedMatch: an entry for another concept, or none, leaves the target as its CSV restored it', () => { diff --git a/src/components/map-projects/conceptNames.js b/src/components/map-projects/conceptNames.js index 59000bb9..b25b16e1 100644 --- a/src/components/map-projects/conceptNames.js +++ b/src/components/map-projects/conceptNames.js @@ -16,12 +16,12 @@ const FULLY_SPECIFIED = /^fully[\s_-]?specified$/i const escapeHtml = text => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') /** - * A search highlight as HTML, with its tags as `open` and `close`. A + * A search highlight as HTML, with its tags as `open` and . A * highlight can come from a saved project, so the rest is escaped: only * is markup. */ -export const highlightHtml = (fragment, open = '', close = '') => - escapeHtml(fragment).replaceAll('<em>', open).replaceAll('</em>', close) +export const highlightHtml = (fragment, open = '') => + escapeHtml(fragment).replaceAll('<em>', open).replaceAll('</em>', '') const sameName = (a, b) => { const normalize = text => (text || '').replace(/\s+/g, ' ').trim().toLowerCase() diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index b0a2d540..cdc41393 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -484,12 +484,11 @@ const ROW_OWN_FIELDS = ['names', 'search_meta', 'source', 'contributingAlgorithm * The cached concept a mapped target names, with the target's own names, * `search_meta`, source and contributing algorithms, as `withOwnNames` keeps * them. A copy: every row mapped to the concept shares the cache entry. - * Returns `cached` as it is when there is none. */ -export const withCachedConcept = (target, cached) => cached ? { +export const withCachedConcept = (target, cached) => cached && { ...cached, ...Object.fromEntries(ROW_OWN_FIELDS.filter(field => target?.[field]).map(field => [field, target[field]])) -} : cached +} /** * Compute the unified score (0-100 percentile) and the raw algorithm score From fd87cdcc3b0edc9ce9f75a50b37d7aacb8b9fca2 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Thu, 15 Oct 2026 22:32:47 -0400 Subject: [PATCH 4/4] OpenConceptLab/ocl_issues#2916 | Restore only well-formed saved names and highlights, and title the matched name with its plain text Co-Authored-By: Claude Opus 5.5 --- src/components/map-projects/Concept.jsx | 2 +- .../map-projects/__tests__/conceptNames.test.js | 8 +++++--- .../map-projects/__tests__/views.test.js | 14 +++++++++++++- src/components/map-projects/conceptNames.js | 9 +++++---- src/components/map-projects/viewBuilders.js | 17 +++++++++++++++-- 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/components/map-projects/Concept.jsx b/src/components/map-projects/Concept.jsx index 3dbc5a79..4b0a2304 100644 --- a/src/components/map-projects/Concept.jsx +++ b/src/components/map-projects/Concept.jsx @@ -55,7 +55,7 @@ export const MatchedName = ({ matchedName, noWrap }) => { ]*>/g, '')}` : undefined} + title={noWrap ? `${label} ${matchedName.text}` : undefined} > {label}  diff --git a/src/components/map-projects/__tests__/conceptNames.test.js b/src/components/map-projects/__tests__/conceptNames.test.js index b2cd838a..b5068d43 100644 --- a/src/components/map-projects/__tests__/conceptNames.test.js +++ b/src/components/map-projects/__tests__/conceptNames.test.js @@ -92,6 +92,7 @@ test('getMatchedName: the translated name that matched comes second, with its lo }), { html: "Anamnesi Punto nel tempo (episodio) Test prenatale", + text: 'Anamnesi Punto nel tempo (episodio) Test prenatale', locale: 'it-IT' } ) @@ -136,6 +137,7 @@ test('getMatchedName: the default-locale display name that matched comes second }), { html: "Pregnancy test", + text: 'Pregnancy test', locale: 'en' } ) @@ -148,7 +150,7 @@ test('getMatchedName: takes the synonym with the longest highlighted match', () concept: PREGNANCY_TEST, displayName: 'Pregnancy test' }), - { html: "Prueba de embarazo", locale: 'es' } + { html: "Prueba de embarazo", text: 'Prueba de embarazo', locale: 'es' } ) }) @@ -169,7 +171,7 @@ test('getMatchedName: a matched name no concept name carries shows without a loc concept: PREGNANCY_TEST, displayName: 'Pregnancy test' } - assert.deepEqual(getMatchedName(args), { html: "Gravindex", locale: undefined }) + assert.deepEqual(getMatchedName(args), { html: "Gravindex", text: 'Gravindex', locale: undefined }) assert.equal(getMatchedName({ ...args, filterLocales: 'en' }), null) }) @@ -185,7 +187,7 @@ test('getMatchedName: only the highlight tags are markup', () => { concept: { names: [] }, displayName: 'Prenatal tests' }), - { html: "<i>Mother's</i> & Prenatal", locale: undefined } + { html: "<i>Mother's</i> & Prenatal", text: "Mother's & Prenatal", locale: undefined } ) assert.equal(highlightHtml('Hb Hemoglobin'), '<i>Hb</i> Hemoglobin') }) diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index 898f6eef..88865fd3 100644 --- a/src/components/map-projects/__tests__/views.test.js +++ b/src/components/map-projects/__tests__/views.test.js @@ -768,6 +768,18 @@ test('withSavedMatch: a restored target gets the names and highlight its saved e assert.equal(out.repo, searchedTarget.repo) }) +test('withSavedMatch: keeps only well-formed names and highlights from a saved entry', () => { + const malformed = { ...savedEntry, concept: { + names: [{ name: 'Hemoglobina en sangre', locale: 'es' }, { name: { es: 'x' }, locale: 'es' }, { name: 'Hb', locale: 7 }, 'Hb', null], + search_meta: { search_highlight: { synonyms: ['Hemoglobina en sangre', 42, null], name: 'Hemoglobin', descriptions: [{}] } } + } } + const out = withSavedMatch(searchedTarget, malformed) + assert.deepEqual(out.names, [{ name: 'Hemoglobina en sangre', locale: 'es' }]) + assert.deepEqual(out.search_meta.search_highlight, { synonyms: ['Hemoglobina en sangre'], descriptions: [] }) + const unusable = { ...savedEntry, concept: { names: [{ name: 3 }], search_meta: { search_highlight: ['x'] } } } + assert.deepEqual(withSavedMatch(searchedTarget, unusable), searchedTarget) +}) + test('withSavedMatch: an entry for another concept, or none, leaves the target as its CSV restored it', () => { const other = { ...savedEntry, url: '/orgs/Regenstrief/sources/LOINC/concepts/2093-3/' } assert.deepEqual(withSavedMatch(searchedTarget, other), searchedTarget) @@ -813,7 +825,7 @@ test('withOwnNames: a mapped target keeps its own names and highlight over a can // A semantic candidate for the same concept: no names in its definition, no highlight. const fromCandidate = { ...searchedTarget, names: undefined, search_meta: { algorithm: 'ocl-semantic', search_normalized_score: 91 } } const out = withOwnNames(fromCandidate, own) - assert.equal(out.names, searchedNames) + assert.deepEqual(out.names, searchedNames) assert.deepEqual(out.search_meta, { algorithm: 'ocl-semantic', search_normalized_score: 91, search_highlight: searchedHighlight }) const withNames = { ...fromCandidate, names: glucoseNames, search_meta: { search_highlight: { name: ['x'] } } } assert.equal(withOwnNames(withNames, searchedTarget).names, glucoseNames) diff --git a/src/components/map-projects/conceptNames.js b/src/components/map-projects/conceptNames.js index b25b16e1..a506a0b2 100644 --- a/src/components/map-projects/conceptNames.js +++ b/src/components/map-projects/conceptNames.js @@ -16,9 +16,9 @@ const FULLY_SPECIFIED = /^fully[\s_-]?specified$/i const escapeHtml = text => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') /** - * A search highlight as HTML, with its tags as `open` and . A - * highlight can come from a saved project, so the rest is escaped: only - * is markup. + * A search highlight as HTML: each becomes `open` and each a . + * A highlight can come from a saved project, so the rest is escaped: only + * is markup. */ export const highlightHtml = (fragment, open = '') => escapeHtml(fragment).replaceAll('<em>', open).replaceAll('</em>', '') @@ -79,7 +79,7 @@ export const localizedDisplayName = (concept, inputLocale) => { /** * 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, + * candidate leads with): `{html, text, 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 @@ -99,6 +99,7 @@ export const getMatchedName = ({highlights, concept, displayName, filterLocales} return null return { html: highlightHtml(fragment, ""), + text, locale: entry?.locale } } diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index cdc41393..411ce42d 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -449,6 +449,18 @@ export const withCandidateNames = (target, rowState, conceptCache) => { } } +// A saved entry is data a client wrote, so only the shapes the names and +// highlights are rendered from are kept: names with a string name (and a +// string locale and type, if any), and highlights as lists of strings. +const isText = value => typeof value === 'string' +const savedNames = names => (Array.isArray(names) ? names : []).filter(name => + isText(name?.name) && (name.locale == null || isText(name.locale)) && (name.name_type == null || isText(name.name_type))) +const savedHighlight = highlight => { + if(!highlight || typeof highlight !== 'object' || Array.isArray(highlight)) return undefined + const kept = Object.entries(highlight).filter(([, values]) => Array.isArray(values)).map(([key, values]) => [key, values.filter(isText)]) + return kept.length ? Object.fromEntries(kept) : undefined +} + /** * A mapped target restored from a saved project's CSV, with the `names` and * the match highlight its row's `matches` entry saved for it @@ -459,10 +471,11 @@ export const withCandidateNames = (target, rowState, conceptCache) => { */ export const withSavedMatch = (target, savedMatch) => { const saved = savedMatch?.url && savedMatch.url === target?.url ? savedMatch.concept : undefined - const highlight = saved?.search_meta?.search_highlight + const names = savedNames(saved?.names) + const highlight = savedHighlight(saved?.search_meta?.search_highlight) return { ...target, - ...(Array.isArray(saved?.names) ? {names: saved.names} : {}), + ...(names.length ? {names} : {}), ...(highlight ? {search_meta: {...target?.search_meta, search_highlight: highlight}} : {}) } }