From 10e5fa0a2358fa088f8eb2df95b341a3cbf3d9fd Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Fri, 16 Oct 2026 15:18:40 +0530 Subject: [PATCH 1/5] OpenConceptLab/ocl_issues#2907 | suggesting field mapping for column headers --- src/common/constants.js | 1 + .../map-projects/ColumnMapTable.jsx | 13 ++++ .../map-projects/ConfigurationForm.jsx | 9 ++- src/components/map-projects/MapProject.jsx | 24 ++++++- .../__tests__/columnSuggestions.test.js | 70 +++++++++++++++++++ .../map-projects/columnSuggestions.js | 55 +++++++++++++++ src/i18n/locales/en/translations.json | 3 +- src/i18n/locales/es/translations.json | 3 +- src/i18n/locales/pt-BR/translations.json | 3 +- src/i18n/locales/zh/translations.json | 3 +- 10 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 src/components/map-projects/__tests__/columnSuggestions.test.js create mode 100644 src/components/map-projects/columnSuggestions.js diff --git a/src/common/constants.js b/src/common/constants.js index d878c8b7..89d049e8 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/' 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 => { + if(!prev.some(col => col.auto)) + return prev + const suggestedLabels = suggestColumnLabels(map(prev, 'original'), headers) + return map(prev, (col, idx) => col.auto ? {...col, label: suggestedLabels[idx] || col.original, suggested: Boolean(suggestedLabels[idx])} : col) + }) + }, [headersKey]) + React.useEffect(() => { if(!isEmpty(decisions)) { window.addEventListener("beforeunload", alertUser); @@ -857,7 +870,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'])})) setColumns(_columns) setTargetSourcesFromRows(getTargetSourcesFromRows(_columns, data)) let AIAssistantColVisibility = {} @@ -1313,6 +1327,12 @@ const MapProject = () => { const updateColumn = (position, newValue, key) => { let cols = {...columns} cols[position][key || 'label'] = newValue || '' + if(!key) { + // Clearing a mapping on purpose leaves the column unmapped without an error. + cols[position].suggested = false + cols[position].unmapped = !newValue + cols[position].auto = false + } setColumns(cols) if(key !== 'targetSource') setTargetSourcesFromRows(getTargetSourcesFromRows(cols)) @@ -1494,6 +1514,8 @@ const MapProject = () => { }) let cols = getColumns(omit(_data[0], ['__index'])) + const suggestedLabels = suggestColumnLabels(map(cols, 'original'), headers) + cols = map(cols, (col, idx) => ({...col, label: suggestedLabels[idx] || col.label, suggested: Boolean(suggestedLabels[idx]), auto: true})) setColumns(cols) } 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..009e3df9 --- /dev/null +++ b/src/components/map-projects/__tests__/columnSuggestions.test.js @@ -0,0 +1,70 @@ +/** + * 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 } 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: 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) +}) diff --git a/src/components/map-projects/columnSuggestions.js b/src/components/map-projects/columnSuggestions.js new file mode 100644 index 00000000..61075553 --- /dev/null +++ b/src/components/map-projects/columnSuggestions.js @@ -0,0 +1,55 @@ +/** + * 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', 'external'] + +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) => { + const findValid = label => validColumns.find(column => column.label.toLowerCase() === label.toLowerCase()) + const suggested = new Set() + + return headers.map(header => { + const exactMatch = findValid(String(header || '')) + const alreadyRecognised = String(header || '').toLowerCase().includes('class') + if(exactMatch || alreadyRecognised || isCodeLikeHeader(header)) + return null + + const tokens = tokenize(header) + const rule = SUGGESTION_RULES.find(rule => tokens.some(token => rule.tokens.includes(token))) + const target = rule && findValid(rule.label) + if(!target || suggested.has(target.label)) + return null + + suggested.add(target.label) + return target.label + }) +} 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 more", + "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ás", + "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 mais", + "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>了解更多", + "column_suggested": "根据列名建议。如有误请更改。", "input_column": "输入列", "mapped_field": "映射字段", "field": "字段", From 74777cef99ec73f49c79da77be4265f9c1393f4e Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Fri, 16 Oct 2026 15:40:30 +0530 Subject: [PATCH 2/5] OpenConceptLab/ocl_issues#2907 | keep Field Configuration columns an array after edits updateColumn copied the columns array into a plain object, so the target re-suggestion effect threw 'prev.some is not a function' after a manual edit when the target's mapped fields loaded. Move the column updates into pure helpers that keep the array shape, and add regression tests for the edit-then-target-update sequence. Co-Authored-By: Claude Haiku 5.5 --- src/components/map-projects/MapProject.jsx | 18 ++------- .../__tests__/columnSuggestions.test.js | 38 ++++++++++++++++++- .../map-projects/columnSuggestions.js | 27 +++++++++++++ 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index ade2da40..c028285b 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -84,7 +84,7 @@ import { buildPanelPayload } from './openConceptPanel' import LoaderDialog from '../common/LoaderDialog' import Error403 from '../errors/Error403' import { HEADERS, SEMANTIC_SEARCH_HEADERS, ROW_STATES, VIEWS, DECISION_TABS, ROW_STAGES, PROMPTS_KEY_DEFAULT, PROMPTS_ACTION_TYPE_DEFAULT, SCORES_COLOR } from './constants' -import { suggestColumnLabels } from './columnSuggestions' +import { suggestColumnLabels, updateColumnState, resuggestColumns } from './columnSuggestions' import MapProjectDeleteConfirmDialog from './MapProjectDeleteConfirmDialog'; import ConfigurationForm from './ConfigurationForm' import Controls from './Controls' @@ -724,12 +724,7 @@ const MapProject = () => { // re-suggest columns the user has not edited yet (ocl_issues#2907). const headersKey = map(headers, 'label').join('|') React.useEffect(() => { - setColumns(prev => { - if(!prev.some(col => col.auto)) - return prev - const suggestedLabels = suggestColumnLabels(map(prev, 'original'), headers) - return map(prev, (col, idx) => col.auto ? {...col, label: suggestedLabels[idx] || col.original, suggested: Boolean(suggestedLabels[idx])} : col) - }) + setColumns(prev => resuggestColumns(prev, headers)) }, [headersKey]) React.useEffect(() => { @@ -1325,14 +1320,7 @@ const MapProject = () => { } const updateColumn = (position, newValue, key) => { - let cols = {...columns} - cols[position][key || 'label'] = newValue || '' - if(!key) { - // Clearing a mapping on purpose leaves the column unmapped without an error. - cols[position].suggested = false - cols[position].unmapped = !newValue - cols[position].auto = false - } + const cols = updateColumnState(columns, position, newValue, key) setColumns(cols) if(key !== 'targetSource') setTargetSourcesFromRows(getTargetSourcesFromRows(cols)) diff --git a/src/components/map-projects/__tests__/columnSuggestions.test.js b/src/components/map-projects/__tests__/columnSuggestions.test.js index 009e3df9..1f8c0da5 100644 --- a/src/components/map-projects/__tests__/columnSuggestions.test.js +++ b/src/components/map-projects/__tests__/columnSuggestions.test.js @@ -7,7 +7,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { suggestColumnLabels, isCodeLikeHeader } from '../columnSuggestions.js' +import { suggestColumnLabels, isCodeLikeHeader, updateColumnState, resuggestColumns } from '../columnSuggestions.js' const BASE = [ {label: 'ID'}, @@ -68,3 +68,39 @@ test('isCodeLikeHeader: matches code-like words as whole tokens', () => { assert.equal(isCodeLikeHeader('Identity'), false) assert.equal(isCodeLikeHeader('Test Name'), false) }) + +const upload = headers => headers.map(original => { + const [suggested] = suggestColumnLabels([original], BASE) + return {label: suggested || original, original, dataKey: original, suggested: Boolean(suggested), auto: true} +}) + +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') +}) diff --git a/src/components/map-projects/columnSuggestions.js b/src/components/map-projects/columnSuggestions.js index 61075553..072a53fb 100644 --- a/src/components/map-projects/columnSuggestions.js +++ b/src/components/map-projects/columnSuggestions.js @@ -53,3 +53,30 @@ export const suggestColumnLabels = (headers, validColumns) => { return target.label }) } + +/** + * 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 suggestedLabels = suggestColumnLabels(columns.map(col => col.original), validColumns) + return columns.map((col, idx) => col.auto ? {...col, label: suggestedLabels[idx] || col.original, suggested: Boolean(suggestedLabels[idx])} : col) +} From 14e174c7ce973d6dfb6dec2102f43e0a1709e11a Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Fri, 16 Oct 2026 16:04:04 +0530 Subject: [PATCH 3/5] OpenConceptLab/ocl_issues#2907 | reserve fields before suggesting, so no two columns map to one field Exact header matches and labels the user already chose now hold their field before any suggestion is made, so a pattern column like 'Test Name' can no longer take Name from an exact 'Name' column, wherever it sits in the file. Upload uses the same resuggestColumns path as target changes, so the logic lives in one place. Adds regression tests for the mixed exact-plus-pattern case, the reversed order, and a manual choice that collides with a suggestion. Co-Authored-By: Claude Haiku 5.5 --- src/components/map-projects/MapProject.jsx | 9 ++--- .../__tests__/columnSuggestions.test.js | 40 +++++++++++++++++-- .../map-projects/columnSuggestions.js | 23 +++++++---- 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index c028285b..0241ea40 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -84,7 +84,7 @@ import { buildPanelPayload } from './openConceptPanel' import LoaderDialog from '../common/LoaderDialog' import Error403 from '../errors/Error403' import { HEADERS, SEMANTIC_SEARCH_HEADERS, ROW_STATES, VIEWS, DECISION_TABS, ROW_STAGES, PROMPTS_KEY_DEFAULT, PROMPTS_ACTION_TYPE_DEFAULT, SCORES_COLOR } from './constants' -import { suggestColumnLabels, updateColumnState, resuggestColumns } from './columnSuggestions' +import { updateColumnState, resuggestColumns } from './columnSuggestions' import MapProjectDeleteConfirmDialog from './MapProjectDeleteConfirmDialog'; import ConfigurationForm from './ConfigurationForm' import Controls from './Controls' @@ -1501,10 +1501,9 @@ const MapProject = () => { return prev }) - let cols = getColumns(omit(_data[0], ['__index'])) - const suggestedLabels = suggestColumnLabels(map(cols, 'original'), headers) - cols = map(cols, (col, idx) => ({...col, label: suggestedLabels[idx] || col.label, suggested: Boolean(suggestedLabels[idx]), auto: true})) - 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 index 1f8c0da5..28f79fd9 100644 --- a/src/components/map-projects/__tests__/columnSuggestions.test.js +++ b/src/components/map-projects/__tests__/columnSuggestions.test.js @@ -69,9 +69,43 @@ test('isCodeLikeHeader: matches code-like words as whole tokens', () => { assert.equal(isCodeLikeHeader('Test Name'), false) }) -const upload = headers => headers.map(original => { - const [suggested] = suggestColumnLabels([original], BASE) - return {label: suggested || original, original, dataKey: original, suggested: Boolean(suggested), auto: true} +// 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', () => { diff --git a/src/components/map-projects/columnSuggestions.js b/src/components/map-projects/columnSuggestions.js index 072a53fb..fecc2244 100644 --- a/src/components/map-projects/columnSuggestions.js +++ b/src/components/map-projects/columnSuggestions.js @@ -33,23 +33,31 @@ export const isCodeLikeHeader = header => tokenize(header).some(token => CODE_TO * 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) => { +export const suggestColumnLabels = (headers, validColumns, reserved = []) => { const findValid = label => validColumns.find(column => column.label.toLowerCase() === label.toLowerCase()) - const suggested = new Set() + const isExact = header => Boolean(findValid(String(header || ''))) - return headers.map(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) + }) + + return headers.map(header => { const alreadyRecognised = String(header || '').toLowerCase().includes('class') - if(exactMatch || alreadyRecognised || isCodeLikeHeader(header)) + 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))) const target = rule && findValid(rule.label) - if(!target || suggested.has(target.label)) + if(!target || taken.has(target.label)) return null - suggested.add(target.label) + taken.add(target.label) return target.label }) } @@ -77,6 +85,7 @@ export const updateColumnState = (columns, position, newValue, key) => { export const resuggestColumns = (columns, validColumns) => { if(!columns.some(col => col.auto)) return columns - const suggestedLabels = suggestColumnLabels(columns.map(col => col.original), validColumns) + const reserved = columns.filter(col => !col.auto && col.label).map(col => col.label) + const suggestedLabels = suggestColumnLabels(columns.map(col => col.original), validColumns, reserved) return columns.map((col, idx) => col.auto ? {...col, label: suggestedLabels[idx] || col.original, suggested: Boolean(suggestedLabels[idx])} : col) } From d55a6406d13297f52bbaf04ba6e969e9f9ac196c Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Mon, 19 Oct 2026 06:55:16 +0530 Subject: [PATCH 4/5] Apply batched suggestions from code review Co-authored-by: Jonathan Payne --- src/common/constants.js | 2 +- src/components/map-projects/MapProject.jsx | 2 +- .../__tests__/columnSuggestions.test.js | 20 ++++++++++++++++ .../map-projects/columnSuggestions.js | 23 ++++++++++++------- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/common/constants.js b/src/common/constants.js index 89d049e8..62afee14 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -73,7 +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/' +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/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 0241ea40..ef383592 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -866,7 +866,7 @@ const MapProject = () => { setAlgosSelected(response?.data?.algorithms || []) if (response?.data.columns?.length > 0) { // Saved mappings are the user's, so they are never re-suggested. - const _columns = response.data.columns.map(col => ({...omit(col, ['hidden', 'auto'])})) + const _columns = response.data.columns.map(col => ({...omit(col, ['hidden', 'auto', 'suggested'])})) setColumns(_columns) setTargetSourcesFromRows(getTargetSourcesFromRows(_columns, data)) let AIAssistantColVisibility = {} diff --git a/src/components/map-projects/__tests__/columnSuggestions.test.js b/src/components/map-projects/__tests__/columnSuggestions.test.js index 28f79fd9..9222c11d 100644 --- a/src/components/map-projects/__tests__/columnSuggestions.test.js +++ b/src/components/map-projects/__tests__/columnSuggestions.test.js @@ -28,6 +28,13 @@ test('suggestColumnLabels: term, test and description headers suggest Name, once ) }) +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]) }) @@ -138,3 +145,16 @@ test('resuggestColumns: suggests Property: SYSTEM once the target loads it', () 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 index fecc2244..32ad230f 100644 --- a/src/components/map-projects/columnSuggestions.js +++ b/src/components/map-projects/columnSuggestions.js @@ -14,7 +14,7 @@ * MapProject's isValidColumnValue, so they get no suggestion here. */ -const CODE_TOKENS = ['code', 'codes', 'id', 'ids', 'uuid', 'identifier', 'external'] +const CODE_TOKENS = ['code', 'codes', 'id', 'ids', 'uuid', 'identifier'] const SUGGESTION_RULES = [ {label: 'Name', tokens: ['name', 'term', 'test', 'description']}, @@ -46,20 +46,27 @@ export const suggestColumnLabels = (headers, validColumns, reserved = []) => { taken.add(exactMatch.label) }) - return headers.map(header => { + 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))) - const target = rule && findValid(rule.label) - if(!target || taken.has(target.label)) - return null + return rule ? {idx, rule, rank: rule.tokens.findIndex(token => tokens.includes(token))} : null + }) - taken.add(target.label) - return target.label + // 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 } /** @@ -86,6 +93,6 @@ 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.original), validColumns, reserved) + 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) } From fb150ab633a74310d8ea750eae49b197ab3fd4f6 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Mon, 19 Oct 2026 07:09:47 +0530 Subject: [PATCH 5/5] OpenConceptLab/ocl_issues#2907 | review feedback --- src/components/map-projects/MapProject.jsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index ef383592..1958e5cc 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -1320,10 +1320,16 @@ const MapProject = () => { } const updateColumn = (position, newValue, key) => { - const cols = updateColumnState(columns, position, newValue, key) + 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 = () => {