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/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 6fa4ff3a..fcaaebc7 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, withCachedConcept, withCandidateNames, withOwnNames, withSavedMatch } from './viewBuilders.js' import { formatClockTime, getCapacityWaitLabel, mergeCapacityWaits } from './rowProgress.js' import './MapProject.scss' @@ -1415,6 +1416,7 @@ const MapProject = () => { let _proposed = {} let _repo = null let _states = {...rowStatuses} + 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 +1441,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__'] @@ -5320,15 +5322,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 } @@ -5338,7 +5332,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) : [] @@ -5364,6 +5359,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 8af53cc5..b5068d43 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". @@ -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) }) @@ -177,3 +179,15 @@ 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", 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 fb9ddfae..88865fd3 100644 --- a/src/components/map-projects/__tests__/views.test.js +++ b/src/components/map-projects/__tests__/views.test.js @@ -25,7 +25,10 @@ import { conceptForMapping, sortRowViews, findTargetRowView, - withCandidateNames + withCachedConcept, + withCandidateNames, + withOwnNames, + withSavedMatch } from '../viewBuilders.js' // ---------- Helpers ---------- @@ -734,3 +737,97 @@ 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) +}) + +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) + assert.deepEqual(withSavedMatch(searchedTarget, undefined), searchedTarget) + assert.deepEqual(withSavedMatch(searchedTarget, { url: searchedTarget.url, rowIndex: '3' }), searchedTarget) +}) + +// 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.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) + 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 5beea67b..a506a0b2 100644 --- a/src/components/map-projects/conceptNames.js +++ b/src/components/map-projects/conceptNames.js @@ -13,6 +13,16 @@ import { primarySubtag } from './normalizers.js' const FULLY_SPECIFIED = /^fully[\s_-]?specified$/i +const escapeHtml = text => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') + +/** + * 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>', '') + const sameName = (a, b) => { const normalize = text => (text || '').replace(/\s+/g, ' ').trim().toLowerCase() return normalize(a) === normalize(b) @@ -69,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 @@ -88,7 +98,8 @@ export const getMatchedName = ({highlights, concept, displayName, filterLocales} if(allowed.length && !allowed.includes(primarySubtag(entry?.locale))) return null return { - html: fragment.replaceAll('', "").replaceAll('', ''), + html: highlightHtml(fragment, ""), + text, locale: entry?.locale } } diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 76997ce2..411ce42d 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -449,6 +449,60 @@ 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 + * (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 names = savedNames(saved?.names) + const highlight = savedHighlight(saved?.search_meta?.search_highlight) + return { + ...target, + ...(names.length ? {names} : {}), + ...(highlight ? {search_meta: {...target?.search_meta, search_highlight: highlight}} : {}) + } +} + +/** + * 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. + */ +export const withCachedConcept = (target, cached) => cached && { + ...cached, + ...Object.fromEntries(ROW_OWN_FIELDS.filter(field => target?.[field]).map(field => [field, target[field]])) +} + /** * Compute the unified score (0-100 percentile) and the raw algorithm score * for a candidate+row. Unified score = per-(row, concept) rerank score