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 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": "字段",