Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/map-projects/Concept.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const MatchedName = ({ matchedName, noWrap }) => {
<span
className='col-xs-12 padding-0'
style={{display: 'block', fontSize: '12px', ...(noWrap ? {whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis'} : {})}}
title={noWrap ? `${label} ${matchedName.html.replace(/<[^>]*>/g, '')}` : undefined}
title={noWrap ? `${label} ${matchedName.text}` : undefined}
>
{label}&nbsp;
<span className='searchable' dangerouslySetInnerHTML={{__html: matchedName.html}}/>
Expand Down
21 changes: 9 additions & 12 deletions src/components/map-projects/MapProject.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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__']
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
: []
Expand All @@ -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()}`;
Expand Down
3 changes: 2 additions & 1 deletion src/components/map-projects/MatchDetailsPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -163,7 +164,7 @@ const MatchDetailsPanel = ({concept, contributingAlgorithms, candidatesScore}) =
>
{
map(values, value => {
const highlightedValue = value.replaceAll('<em>', '<b>').replaceAll('</em>', '</b>')
const highlightedValue = highlightHtml(value)
return (
// Match the Concept Details Names/Descriptions typography
// (Locales.jsx LocalePrimary): 0.875rem, primary text,
Expand Down
20 changes: 17 additions & 3 deletions src/components/map-projects/__tests__/conceptNames.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -92,6 +92,7 @@ test('getMatchedName: the translated name that matched comes second, with its lo
}),
{
html: "Anamnesi Punto nel tempo (episodio) <b className='searchable'>Test</b> prenatale",
text: 'Anamnesi Punto nel tempo (episodio) Test prenatale',
locale: 'it-IT'
}
)
Expand Down Expand Up @@ -136,6 +137,7 @@ test('getMatchedName: the default-locale display name that matched comes second
}),
{
html: "<b className='searchable'>Pregnancy</b> <b className='searchable'>test</b>",
text: 'Pregnancy test',
locale: 'en'
}
)
Expand All @@ -148,7 +150,7 @@ test('getMatchedName: takes the synonym with the longest highlighted match', ()
concept: PREGNANCY_TEST,
displayName: 'Pregnancy test'
}),
{ html: "<b className='searchable'>Prueba de embarazo</b>", locale: 'es' }
{ html: "<b className='searchable'>Prueba de embarazo</b>", text: 'Prueba de embarazo', locale: 'es' }
)
})

Expand All @@ -169,11 +171,23 @@ test('getMatchedName: a matched name no concept name carries shows without a loc
concept: PREGNANCY_TEST,
displayName: 'Pregnancy test'
}
assert.deepEqual(getMatchedName(args), { html: "<b className='searchable'>Gravindex</b>", locale: undefined })
assert.deepEqual(getMatchedName(args), { html: "<b className='searchable'>Gravindex</b>", text: '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)
})

test('getMatchedName: only the highlight tags are markup', () => {
assert.deepEqual(
getMatchedName({
highlights: { synonyms: ["<i>Mother's</i> & <em>Prenatal</em>"] },
concept: { names: [] },
displayName: 'Prenatal tests'
}),
{ html: "&lt;i&gt;Mother&#39;s&lt;/i&gt; &amp; <b className='searchable'>Prenatal</b>", text: "<i>Mother's</i> & Prenatal", locale: undefined }
)
assert.equal(highlightHtml('<i>Hb</i> <em>Hemoglobin</em>'), '&lt;i&gt;Hb&lt;/i&gt; <b>Hemoglobin</b>')
})
99 changes: 98 additions & 1 deletion src/components/map-projects/__tests__/views.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import {
conceptForMapping,
sortRowViews,
findTargetRowView,
withCandidateNames
withCachedConcept,
withCandidateNames,
withOwnNames,
withSavedMatch
} from '../viewBuilders.js'

// ---------- Helpers ----------
Expand Down Expand Up @@ -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: ['<em>Hemoglobina</em> 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: ['<em>Hemoglobina</em> 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: ['<em>Hemoglobina</em> en sangre'], descriptions: [] })
const unusable = { ...savedEntry, concept: { names: [{ name: 3 }], search_meta: { search_highlight: ['<em>x</em>'] } } }
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: ['<em>Hemoglobin</em> [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'] })
})
15 changes: 13 additions & 2 deletions src/components/map-projects/conceptNames.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ import { primarySubtag } from './normalizers.js'

const FULLY_SPECIFIED = /^fully[\s_-]?specified$/i

const escapeHtml = text => text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;')

/**
* A search highlight as HTML: each <em> becomes `open` and each </em> a </b>.
* A highlight can come from a saved project, so the rest is escaped: only
* <em> is markup.
*/
export const highlightHtml = (fragment, open = '<b>') =>
escapeHtml(fragment).replaceAll('&lt;em&gt;', open).replaceAll('&lt;/em&gt;', '</b>')

const sameName = (a, b) => {
const normalize = text => (text || '').replace(/\s+/g, ' ').trim().toLowerCase()
return normalize(a) === normalize(b)
Expand Down Expand Up @@ -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
Expand All @@ -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('<em>', "<b className='searchable'>").replaceAll('</em>', '</b>'),
html: highlightHtml(fragment, "<b className='searchable'>"),
text,
locale: entry?.locale
}
}
54 changes: 54 additions & 0 deletions src/components/map-projects/viewBuilders.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading