diff --git a/src/common/constants.js b/src/common/constants.js
index d878c8b7..62afee14 100644
--- a/src/common/constants.js
+++ b/src/common/constants.js
@@ -73,6 +73,7 @@ export const DEFAULT_CASCADE_PARAMS = {
export const ALL = '*';
export const URL_REGISTRY_DOC_LINK = 'https://docs.openconceptlab.org/en/latest/oclapi/apireference/urlregistry.html'
export const MATCHING_ALGORITHMS_DOC_LINK = 'https://docs.openconceptlab.org/en/latest/oclmap/matching-algorithms.html'
+export const MAPPER_DOCS_LINK = 'https://docs.openconceptlab.org/en/latest/oclmap/overview.html'
export const MAPPER_ANNOUNCEMENT_LETTER_URL = 'https://openconceptlab.org/blog/letter-to-the-ocl-community?utm_source=oclmap&utm_medium=banner&utm_campaign=subscription_dates'
export const MAPPER_ANNOUNCEMENT_PRICING_URL = 'https://openconceptlab.org/pricing?utm_source=oclmap&utm_medium=banner&utm_campaign=subscription_dates'
export const TERMS_OF_USE_URL = 'https://openconceptlab.org/terms-of-use'
diff --git a/src/components/map-projects/ColumnMapTable.jsx b/src/components/map-projects/ColumnMapTable.jsx
index 39ca39a8..07b8937d 100644
--- a/src/components/map-projects/ColumnMapTable.jsx
+++ b/src/components/map-projects/ColumnMapTable.jsx
@@ -8,8 +8,11 @@ import Autocomplete from '@mui/material/Autocomplete'
import TextField from '@mui/material/TextField'
import Switch from '@mui/material/Switch'
import ListItemText from '@mui/material/ListItemText'
+import Typography from '@mui/material/Typography'
import ValidIcon from '@mui/icons-material/CheckCircleOutlined';
import InvalidIcon from '@mui/icons-material/CancelOutlined';
+import UnmappedIcon from '@mui/icons-material/RemoveCircleOutlineOutlined';
+import { isCodeLikeHeader } from './columnSuggestions'
import find from 'lodash/find';
import map from 'lodash/map';
import omit from 'lodash/omit';
@@ -130,6 +133,8 @@ const ColumnMapTable = ({
{
map(columns, (column, position) => {
const isValidColumn = isValid(column.label)
+ // Left unmapped on purpose (cleared, or a code-like header): no error.
+ const isDeliberatelyUnmapped = !isValidColumn && (column.unmapped || isCodeLikeHeader(column.original))
const isHidden = columnVisibilityModel[column.dataKey] === false
const sendToAIAssistant = AIAssistantColumns[column.dataKey] === false
return (
@@ -139,6 +144,8 @@ const ColumnMapTable = ({
{
isValidColumn ?
:
+ isDeliberatelyUnmapped ?
+ :
}
{column.original}
@@ -153,6 +160,12 @@ const ColumnMapTable = ({
isValid={isValidColumn}
onChange={(event, value) => onValChange(position, value?.label || value)}
/>
+ {
+ column.suggested &&
+
+ {t('map_project.column_suggested')}
+
+ }
{
'Mapping: List' === column.label &&
diff --git a/src/components/map-projects/ConfigurationForm.jsx b/src/components/map-projects/ConfigurationForm.jsx
index 1f9cde4b..61d027f4 100644
--- a/src/components/map-projects/ConfigurationForm.jsx
+++ b/src/components/map-projects/ConfigurationForm.jsx
@@ -27,7 +27,7 @@ import omit from 'lodash/omit'
import filter from 'lodash/filter'
import { formatDate, getCurrentUser, toV3URL } from '../../common/utils'
-import { MATCHING_ALGORITHMS_DOC_LINK } from '../../common/constants'
+import { MATCHING_ALGORITHMS_DOC_LINK, MAPPER_DOCS_LINK } from '../../common/constants'
import { COLORS } from '../../common/colors'
import { getProjectConfigErrors } from './algorithms'
import NamespaceDropdown from '../common/NamespaceDropdown'
@@ -490,7 +490,12 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n
{t('map_project.field_configuration')}
- {t('map_project.field_configuration_description')}
+
+ ]}
+ />
{
return {id: `property__${_filter.code}`, label: `Property: ${_filter.code}`, description: _filter.description || ''}
}))]
+ // The target repo can load after the file (and so its properties), so
+ // re-suggest columns the user has not edited yet (ocl_issues#2907).
+ const headersKey = map(headers, 'label').join('|')
+ React.useEffect(() => {
+ setColumns(prev => resuggestColumns(prev, headers))
+ }, [headersKey])
+
React.useEffect(() => {
if(!isEmpty(decisions)) {
window.addEventListener("beforeunload", alertUser);
@@ -857,7 +865,8 @@ const MapProject = () => {
setProjectFromData(data, response.data)
setAlgosSelected(response?.data?.algorithms || [])
if (response?.data.columns?.length > 0) {
- const _columns = response.data.columns.map(col => ({...omit(col, ['hidden'])}))
+ // Saved mappings are the user's, so they are never re-suggested.
+ const _columns = response.data.columns.map(col => ({...omit(col, ['hidden', 'auto', 'suggested'])}))
setColumns(_columns)
setTargetSourcesFromRows(getTargetSourcesFromRows(_columns, data))
let AIAssistantColVisibility = {}
@@ -1311,11 +1320,16 @@ const MapProject = () => {
}
const updateColumn = (position, newValue, key) => {
- let cols = {...columns}
- cols[position][key || 'label'] = newValue || ''
+ if(key === 'targetSource') {
+ // Each source picker sets its value on mount, often several in one render,
+ // so merge into the latest state; this render's copy would keep only the last.
+ setColumns(prev => updateColumnState(prev, position, {...prev[position]?.targetSource, ...newValue}, key))
+ return
+ }
+ // A manual choice takes its field back from any suggestion holding it.
+ const cols = resuggestColumns(updateColumnState(columns, position, newValue, key), headers)
setColumns(cols)
- if(key !== 'targetSource')
- setTargetSourcesFromRows(getTargetSourcesFromRows(cols))
+ setTargetSourcesFromRows(getTargetSourcesFromRows(cols))
}
const resetState = () => {
@@ -1493,8 +1507,9 @@ const MapProject = () => {
return prev
})
- let cols = getColumns(omit(_data[0], ['__index']))
- setColumns(cols)
+ // Uploaded columns start as suggestions (`auto`) until the user edits them.
+ const cols = map(getColumns(omit(_data[0], ['__index'])), col => ({...col, auto: true}))
+ setColumns(resuggestColumns(cols, headers))
}
const getReferencesForImport = (collection, scope, cascadeMethod, transformReferences) => {
diff --git a/src/components/map-projects/__tests__/columnSuggestions.test.js b/src/components/map-projects/__tests__/columnSuggestions.test.js
new file mode 100644
index 00000000..9222c11d
--- /dev/null
+++ b/src/components/map-projects/__tests__/columnSuggestions.test.js
@@ -0,0 +1,160 @@
+/**
+ * Suggested mapped fields for uploaded column headers (ocl_issues#2907).
+ *
+ * Run with: npm test
+ */
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { suggestColumnLabels, isCodeLikeHeader, updateColumnState, resuggestColumns } from '../columnSuggestions.js'
+
+const BASE = [
+ {label: 'ID'},
+ {label: 'Name'},
+ {label: 'Synonyms'},
+ {label: 'Description'},
+ {label: 'Mapping: Code'},
+ {label: 'Mapping: List'},
+ {label: 'Property: Class'},
+ {label: 'Property: Datatype'},
+]
+const LOINC = [...BASE, {label: 'Property: SYSTEM'}]
+
+test('suggestColumnLabels: term, test and description headers suggest Name, once', () => {
+ assert.deepEqual(
+ suggestColumnLabels(['Test Name', 'Term', 'Lab test', 'Test description'], BASE),
+ ['Name', null, null, null]
+ )
+})
+
+test('suggestColumnLabels: Name goes to the strongest header, not the first', () => {
+ assert.deepEqual(
+ suggestColumnLabels(['Test Date', 'Test Description', 'Test Name'], BASE),
+ [null, null, 'Name']
+ )
+})
+
+test('suggestColumnLabels: exact matches are left alone', () => {
+ assert.deepEqual(suggestColumnLabels(['Name', 'Description'], BASE), [null, null])
+})
+
+test('suggestColumnLabels: synonym headers suggest Synonyms', () => {
+ assert.deepEqual(suggestColumnLabels(['Alternate synonym'], BASE), ['Synonyms'])
+})
+
+test('suggestColumnLabels: specimen, system and sample suggest Property: SYSTEM when the target has it', () => {
+ assert.deepEqual(
+ suggestColumnLabels(['Specimen', 'System', 'Sample type'], LOINC),
+ ['Property: SYSTEM', null, null]
+ )
+})
+
+test('suggestColumnLabels: no Property: SYSTEM suggestion when the target lacks it', () => {
+ assert.deepEqual(suggestColumnLabels(['Specimen'], BASE), [null])
+})
+
+test('suggestColumnLabels: class headers are left alone, since they are already recognised', () => {
+ assert.deepEqual(suggestColumnLabels(['Class', 'Lab class'], BASE), [null, null])
+})
+
+test('suggestColumnLabels: code-like headers stay unmapped', () => {
+ assert.deepEqual(
+ suggestColumnLabels(['Local Code', 'Sample ID', 'external_id', 'UUID'], LOINC),
+ [null, null, null, null]
+ )
+})
+
+test('suggestColumnLabels: unrelated headers get no suggestion', () => {
+ assert.deepEqual(suggestColumnLabels(['Notes', 'Units'], LOINC), [null, null])
+})
+
+test('isCodeLikeHeader: matches code-like words as whole tokens', () => {
+ assert.equal(isCodeLikeHeader('Local Code'), true)
+ assert.equal(isCodeLikeHeader('ID'), true)
+ assert.equal(isCodeLikeHeader('Identity'), false)
+ assert.equal(isCodeLikeHeader('Test Name'), false)
+})
+
+// Mirrors the upload path: every column starts as an auto suggestion.
+const upload = (headers, validColumns = BASE) => resuggestColumns(
+ headers.map(original => ({label: original, original, dataKey: original, auto: true})),
+ validColumns
+)
+
+test('suggestColumnLabels: an exact match keeps its field, even when a pattern column comes first', () => {
+ assert.deepEqual(suggestColumnLabels(['Test Name', 'Name'], BASE), [null, null])
+})
+
+test('suggestColumnLabels: exact matches reserve their fields in mixed headers', () => {
+ assert.deepEqual(
+ suggestColumnLabels(['Name', 'Test Name', 'Synonyms', 'Alternate synonym'], BASE),
+ [null, null, null, null]
+ )
+})
+
+test('suggestColumnLabels: labels the user already chose are reserved', () => {
+ assert.deepEqual(suggestColumnLabels(['Test Name'], BASE, ['Name']), [null])
+})
+
+test('upload: an exact Name column and a Test Name column do not both map to Name', () => {
+ const labels = upload(['Test Name', 'Name', 'Specimen'], LOINC).map(col => col.label)
+ assert.deepEqual(labels, ['Test Name', 'Name', 'Property: SYSTEM'])
+})
+
+test('upload: a suggestion still applies when nothing collides', () => {
+ const labels = upload(['Test Name', 'Synonym'], BASE).map(col => col.label)
+ assert.deepEqual(labels, ['Name', 'Synonyms'])
+})
+
+test('regression: a manual Name choice is not duplicated when the target reloads', () => {
+ let cols = upload(['Notes', 'Test Name'], LOINC)
+ cols = updateColumnState(cols, 0, 'Name')
+ const afterLoad = resuggestColumns(cols, LOINC)
+ assert.deepEqual(afterLoad.map(col => col.label), ['Name', 'Test Name'])
+ assert.equal(afterLoad[1].suggested, false)
+})
+
+test('updateColumnState: keeps columns an array after a user edit', () => {
+ const cols = updateColumnState(upload(['Code', 'Name']), 0, 'Name')
+ assert.ok(Array.isArray(cols))
+ assert.equal(cols[0].label, 'Name')
+ assert.equal(cols[0].auto, false)
+})
+
+test('regression: editing a column, then the target loads, does not throw and keeps the edit', () => {
+ let cols = upload(['Specimen', 'Code'])
+ cols = updateColumnState(cols, 1, '')
+ assert.equal(cols[1].unmapped, true)
+ const afterLoad = resuggestColumns(cols, LOINC)
+ assert.ok(Array.isArray(afterLoad))
+ assert.equal(afterLoad[0].label, 'Property: SYSTEM')
+ assert.equal(afterLoad[0].suggested, true)
+ assert.equal(afterLoad[1].label, '')
+ assert.equal(afterLoad[1].unmapped, true)
+})
+
+test('resuggestColumns: a manual choice is not overwritten when the target changes', () => {
+ let cols = upload(['Specimen'])
+ cols = updateColumnState(cols, 0, 'Name')
+ assert.equal(resuggestColumns(cols, LOINC)[0].label, 'Name')
+})
+
+test('resuggestColumns: suggests Property: SYSTEM once the target loads it', () => {
+ const cols = upload(['Specimen'])
+ assert.equal(resuggestColumns(cols, BASE)[0].label, 'Specimen')
+ assert.equal(resuggestColumns(cols, LOINC)[0].label, 'Property: SYSTEM')
+})
+
+test('resuggestColumns: a cleared column does not hold the field it would have been suggested', () => {
+ let cols = upload(['Specimen', 'Sample'])
+ cols = updateColumnState(cols, 0, '')
+ assert.deepEqual(resuggestColumns(cols, LOINC).map(col => col.label), ['', 'Property: SYSTEM'])
+})
+
+test('resuggestColumns: a manual choice takes its field back from a suggestion', () => {
+ let cols = upload(['Notes', 'Test Name'])
+ assert.equal(cols[1].label, 'Name')
+ cols = resuggestColumns(updateColumnState(cols, 0, 'Name'), BASE)
+ assert.deepEqual(cols.map(col => col.label), ['Name', 'Test Name'])
+})
diff --git a/src/components/map-projects/columnSuggestions.js b/src/components/map-projects/columnSuggestions.js
new file mode 100644
index 00000000..32ad230f
--- /dev/null
+++ b/src/components/map-projects/columnSuggestions.js
@@ -0,0 +1,98 @@
+/**
+ * Suggested mapped fields for the columns of an uploaded spreadsheet
+ * (ocl_issues#2907).
+ *
+ * A column is only recognised when its header is exactly a mapped-field
+ * label, so a new user has to learn that vocabulary before the first run.
+ * These suggestions map common header words to a mapped field when the
+ * header is not already an exact match. They are only a starting point: they
+ * are shown as suggestions and can be changed, and nothing is saved until the
+ * project is.
+ *
+ * Code-like headers (for example "Local Code") stay unmapped on purpose.
+ * Headers containing "class" are already recognised as Property: Class by
+ * MapProject's isValidColumnValue, so they get no suggestion here.
+ */
+
+const CODE_TOKENS = ['code', 'codes', 'id', 'ids', 'uuid', 'identifier']
+
+const SUGGESTION_RULES = [
+ {label: 'Name', tokens: ['name', 'term', 'test', 'description']},
+ {label: 'Synonyms', tokens: ['synonym', 'synonyms']},
+ {label: 'Property: SYSTEM', tokens: ['specimen', 'system', 'sample']},
+]
+
+const tokenize = header => String(header || '').toLowerCase().split(/[\s_-]+/).filter(Boolean)
+
+export const isCodeLikeHeader = header => tokenize(header).some(token => CODE_TOKENS.includes(token))
+
+/**
+ * Returns one suggested label per header, aligned with `headers`, or null
+ * where there is no suggestion. `validColumns` are the mapped fields the
+ * target supports (`{label}` objects, as in MapProject's `headers`). A rule
+ * only suggests a field the target has, and each field is suggested at most
+ * once, for the first matching column.
+ */
+export const suggestColumnLabels = (headers, validColumns, reserved = []) => {
+ const findValid = label => validColumns.find(column => column.label.toLowerCase() === label.toLowerCase())
+ const isExact = header => Boolean(findValid(String(header || '')))
+
+ // Exact matches and labels the user already chose hold their field first, so
+ // no suggestion can duplicate it, wherever the column sits in the file.
+ const taken = new Set(reserved)
+ headers.forEach(header => {
+ const exactMatch = findValid(String(header || ''))
+ if(exactMatch)
+ taken.add(exactMatch.label)
+ })
+
+ const matches = headers.map((header, idx) => {
+ const alreadyRecognised = String(header || '').toLowerCase().includes('class')
+ if(isExact(header) || alreadyRecognised || isCodeLikeHeader(header))
+ return null
+
+ const tokens = tokenize(header)
+ const rule = SUGGESTION_RULES.find(rule => tokens.some(token => rule.tokens.includes(token)))
+ return rule ? {idx, rule, rank: rule.tokens.findIndex(token => tokens.includes(token))} : null
+ })
+
+ // A field goes to the column with its strongest word ("name" before "test"),
+ // then the first such column, so "Test Name" wins over an earlier "Test Date".
+ const suggestions = headers.map(() => null)
+ matches.filter(Boolean).sort((a, b) => a.rank - b.rank || a.idx - b.idx).forEach(({idx, rule}) => {
+ const target = findValid(rule.label)
+ if(target && !taken.has(target.label)) {
+ taken.add(target.label)
+ suggestions[idx] = target.label
+ }
+ })
+ return suggestions
+}
+
+/**
+ * Returns a new columns array with one column's mapping changed. A change
+ * without a key is a mapping choice: it ends any suggestion, and clearing it
+ * marks the column as unmapped on purpose.
+ */
+export const updateColumnState = (columns, position, newValue, key) => {
+ const cols = [...columns]
+ cols[position] = {...cols[position], [key || 'label']: newValue || ''}
+ if(!key) {
+ cols[position].suggested = false
+ cols[position].unmapped = !newValue
+ cols[position].auto = false
+ }
+ return cols
+}
+
+/**
+ * Re-runs suggestions for columns still driven by suggestions (`auto`) after
+ * the target's mapped fields change. Columns the user has edited are kept.
+ */
+export const resuggestColumns = (columns, validColumns) => {
+ if(!columns.some(col => col.auto))
+ return columns
+ const reserved = columns.filter(col => !col.auto && col.label).map(col => col.label)
+ const suggestedLabels = suggestColumnLabels(columns.map(col => col.auto ? col.original : null), validColumns, reserved)
+ return columns.map((col, idx) => col.auto ? {...col, label: suggestedLabels[idx] || col.original, suggested: Boolean(suggestedLabels[idx])} : col)
+}
diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json
index d4e5e235..4dc81ee0 100644
--- a/src/i18n/locales/en/translations.json
+++ b/src/i18n/locales/en/translations.json
@@ -460,7 +460,8 @@
"best_candidate_quality": "Best candidate. No match is proposed yet.",
"low_ranked": "Low Ranked",
"field_configuration": "Field Configuration",
- "field_configuration_description": "Associate fields in the input data to fields that the matching algorithms are able to understand. Assign input fields that contain concept codes or mappings to specific repositories. Learn more",
+ "field_configuration_description": "Associate fields in the input data to fields that the matching algorithms are able to understand. Assign input fields that contain concept codes or mappings to specific repositories. <0>Learn more0>",
+ "column_suggested": "Suggested from column name. Change it if it's wrong.",
"input_column": "Input Column",
"mapped_field": "Mapped Field",
"field": "Field",
diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json
index 9324b98a..8881a6f2 100644
--- a/src/i18n/locales/es/translations.json
+++ b/src/i18n/locales/es/translations.json
@@ -429,7 +429,8 @@
"best_candidate_quality": "Mejor candidato. Aún no hay ninguna coincidencia propuesta.",
"low_ranked": "De baja clasificación",
"field_configuration": "Configuración de Campo",
- "field_configuration_description": "Asocie campos en los datos de entrada con campos que los algoritmos de coincidencia puedan entender. Asigne campos de entrada que contengan códigos de concepto o mapeos a repositorios específicos. Aprenda más",
+ "field_configuration_description": "Asocie campos en los datos de entrada con campos que los algoritmos de coincidencia puedan entender. Asigne campos de entrada que contengan códigos de concepto o mapeos a repositorios específicos. <0>Aprenda más0>",
+ "column_suggested": "Sugerido a partir del nombre de la columna. Cámbielo si es incorrecto.",
"input_column": "Columna de Entrada",
"mapped_field": "Campo Mapeado",
"field": "Campo",
diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json
index e784eea6..03a8f4a0 100644
--- a/src/i18n/locales/pt-BR/translations.json
+++ b/src/i18n/locales/pt-BR/translations.json
@@ -460,7 +460,8 @@
"best_candidate_quality": "Melhor candidato. Nenhuma correspondência proposta ainda.",
"low_ranked": "Baixa classificação",
"field_configuration": "Configuração de Campo",
- "field_configuration_description": "Associe campos nos dados de entrada a campos que os algoritmos de mapeamento são capazes de entender. Atribua campos de entrada que contenham códigos de conceito ou mapeamentos a repositórios específicos. Saiba mais",
+ "field_configuration_description": "Associe campos nos dados de entrada a campos que os algoritmos de mapeamento são capazes de entender. Atribua campos de entrada que contenham códigos de conceito ou mapeamentos a repositórios específicos. <0>Saiba mais0>",
+ "column_suggested": "Sugerido pelo nome da coluna. Altere se estiver errado.",
"input_column": "Coluna de Entrada",
"mapped_field": "Campo Mapeado",
"field": "Campo",
diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json
index 9e8cef0a..b835bbd7 100644
--- a/src/i18n/locales/zh/translations.json
+++ b/src/i18n/locales/zh/translations.json
@@ -454,7 +454,8 @@
"best_candidate_quality": "最佳候选项。尚未建议匹配。",
"low_ranked": "低排名",
"field_configuration": "字段配置",
- "field_configuration_description": "将输入数据中的字段与匹配算法能够理解的字段关联。将包含概念代码或映射的输入字段分配给特定存储库。了解更多",
+ "field_configuration_description": "将输入数据中的字段与匹配算法能够理解的字段关联。将包含概念代码或映射的输入字段分配给特定存储库。<0>了解更多0>",
+ "column_suggested": "根据列名建议。如有误请更改。",
"input_column": "输入列",
"mapped_field": "映射字段",
"field": "字段",