From 9e860bf3115dff301853643f6d02d8ba353480b8 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Mon, 2 Nov 2026 11:07:21 +0530 Subject: [PATCH 1/2] OpenConceptLab/ocl_issues#2984 | A review note on an unmapped Mapper row is saved --- src/components/map-projects/MapProject.jsx | 5 +++-- .../map-projects/__tests__/views.test.js | 18 +++++++++++++++++- src/components/map-projects/viewBuilders.js | 16 ++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 3f92f9c..66872e0 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -124,7 +124,7 @@ import { createLookupSession, LOOKUP_NOT_FOUND } from './lookupSession' import { parseConceptKey } from './conceptKey' import { normalizeLookupConfig } from './lookupConfigFields' import { getDeclaredLocales, getDefaultTargetRepoVersion, getProjectTargetRepoVersion, getSavedTargetRepoVersion, getTargetRepoVersionFromUrl, getTargetRepoVersionId } from './projectTargetRepo' -import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, withCachedConcept, withCandidateNames, withOwnNames, withSavedMatch } from './viewBuilders.js' +import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, withCachedConcept, withCandidateNames, restoredNote, unmappedNoteMatches, withOwnNames, withSavedMatch } from './viewBuilders.js' import { formatClockTime, getCapacityWaitLabel, mergeCapacityWaits } from './rowProgress.js' import './MapProject.scss' @@ -1563,7 +1563,7 @@ const MapProject = () => { _states[state] = _states[state] || [] _states[state].push(index) _decisions[index] = (data['__Decision__'] || data['__row_decision__']) === t('map_project.none') ? undefined : (data['__Decision__'] || data['__row_decision__']) - _notes[index] = data['__Note__'] + _notes[index] = restoredNote(data['__Note__'], savedMatches[index]) _mapTypes[index] = data['__Map Type__'] || data['__map_type__'] _proposed[index] = (data['__Proposed__'] || data['__proposed__']) ? JSON.parse(data['__Proposed__'] || data['__proposed__']) : undefined data = omit(data, [...reservedKeys, ...optionalReservedKeys]) @@ -1730,6 +1730,7 @@ const MapProject = () => { concept: getConcept(data) } }) + selected.push(...unmappedNoteMatches(map(data, row => row.__index), mapSelected, notes)) // v2 wire format: rowMatchState + conceptCache (see serializeCandidates). const candidates = serializeCandidates(rowMatchStateRef.current, conceptCacheRef.current) const formData = new FormData(); diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index 88865fd..3a51b01 100644 --- a/src/components/map-projects/__tests__/views.test.js +++ b/src/components/map-projects/__tests__/views.test.js @@ -28,7 +28,9 @@ import { withCachedConcept, withCandidateNames, withOwnNames, - withSavedMatch + withSavedMatch, + unmappedNoteMatches, + restoredNote } from '../viewBuilders.js' // ---------- Helpers ---------- @@ -831,3 +833,17 @@ test('withOwnNames: a mapped target keeps its own names and highlight over a can assert.equal(withOwnNames(withNames, searchedTarget).names, glucoseNames) assert.deepEqual(withOwnNames(withNames, searchedTarget).search_meta.search_highlight, { name: ['x'] }) }) + +// ---------- unmapped row notes (OpenConceptLab/ocl_issues#2984) ---------- + +test('a note on an unmapped row survives save and reopen', () => { + const mapSelected = {0: searchedTarget} + const notes = {0: 'mapped note', 1: 'needs a clinician', 2: ''} + // Save: mapped rows send their own entry, unmapped rows with a note get one. + const sent = JSON.parse(JSON.stringify(unmappedNoteMatches([0, 1, 2], mapSelected, notes))) + assert.deepEqual(sent, [{rowIndex: 1, note: 'needs a clinician'}]) + // Reopen: the CSV carries no note for that row, the saved entry does. + assert.equal(restoredNote(undefined, sent[0]), 'needs a clinician') + assert.equal(restoredNote('csv note', sent[0]), 'csv note') + assert.equal(restoredNote(undefined, undefined), undefined) +}) diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 411ce42..dc4e416 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -480,6 +480,22 @@ export const withSavedMatch = (target, savedMatch) => { } } +/** + * The `matches` entries a save sends for rows with no mapping: a row's review + * note is kept only in its entry, so one without a mapped target still needs + * one (OpenConceptLab/ocl_issues#2984). Rows without a note get none. + */ +export const unmappedNoteMatches = (rowIndexes, mapSelected, notes) => + rowIndexes + .filter(index => !mapSelected[index] && notes[index]) + .map(index => ({rowIndex: index, note: notes[index]})) + +/** + * A row's review note on reopen: the one its CSV row holds, else the one its + * saved `matches` entry holds (OpenConceptLab/ocl_issues#2984). + */ +export const restoredNote = (csvNote, savedMatch) => csvNote || savedMatch?.note || undefined + /** * 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 From e403fac0f6e35a89697a0f9264102d769c889f7e Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Mon, 2 Nov 2026 11:13:45 +0530 Subject: [PATCH 2/2] OpenConceptLab/ocl_issues#2984 | Send one matches entry per row, cleared rows included --- src/components/map-projects/MapProject.jsx | 5 ++- .../map-projects/__tests__/views.test.js | 33 +++++++++++++------ src/components/map-projects/viewBuilders.js | 15 +++++---- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 66872e0..851fc6e 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -124,7 +124,7 @@ import { createLookupSession, LOOKUP_NOT_FOUND } from './lookupSession' import { parseConceptKey } from './conceptKey' import { normalizeLookupConfig } from './lookupConfigFields' import { getDeclaredLocales, getDefaultTargetRepoVersion, getProjectTargetRepoVersion, getSavedTargetRepoVersion, getTargetRepoVersionFromUrl, getTargetRepoVersionId } from './projectTargetRepo' -import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, withCachedConcept, withCandidateNames, restoredNote, unmappedNoteMatches, withOwnNames, withSavedMatch } from './viewBuilders.js' +import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, withCachedConcept, withCandidateNames, buildMatchEntries, restoredNote, withOwnNames, withSavedMatch } from './viewBuilders.js' import { formatClockTime, getCapacityWaitLabel, mergeCapacityWaits } from './rowProgress.js' import './MapProject.scss' @@ -1717,7 +1717,7 @@ const MapProject = () => { const projectLogsForSave = options.projectLogs || projectLogsRef.current setIsSaving(true) const f = getFileObjectFromRows() - const selected = map(mapSelected, (data, i) => { + const selected = buildMatchEntries(map(data, row => row.__index), mapSelected, notes, (data, i) => { return { url: data?.url, repoURL: data?.repo?.version_url || data?.repo?.url, @@ -1730,7 +1730,6 @@ const MapProject = () => { concept: getConcept(data) } }) - selected.push(...unmappedNoteMatches(map(data, row => row.__index), mapSelected, notes)) // v2 wire format: rowMatchState + conceptCache (see serializeCandidates). const candidates = serializeCandidates(rowMatchStateRef.current, conceptCacheRef.current) const formData = new FormData(); diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index 3a51b01..a7d7636 100644 --- a/src/components/map-projects/__tests__/views.test.js +++ b/src/components/map-projects/__tests__/views.test.js @@ -29,7 +29,7 @@ import { withCandidateNames, withOwnNames, withSavedMatch, - unmappedNoteMatches, + buildMatchEntries, restoredNote } from '../viewBuilders.js' @@ -836,14 +836,27 @@ test('withOwnNames: a mapped target keeps its own names and highlight over a can // ---------- unmapped row notes (OpenConceptLab/ocl_issues#2984) ---------- +// The save's entry for a row `mapSelected` holds, as onSave builds it. +const entryFor = (target, index) => ({rowIndex: index, url: target?.url, note: notesFixture[index] || undefined}) +let notesFixture = {} + +// Save, serialise, reopen: what MapProject sends and what it restores. +const roundTrip = (mapSelected, notes, rows) => { + notesFixture = notes + const sent = JSON.parse(JSON.stringify(buildMatchEntries(rows, mapSelected, notes, entryFor))) + const saved = Object.fromEntries(sent.map(entry => [entry.rowIndex, entry])) + return {sent, restored: Object.fromEntries(rows.map(index => [index, restoredNote(undefined, saved[index])]))} +} + test('a note on an unmapped row survives save and reopen', () => { - const mapSelected = {0: searchedTarget} - const notes = {0: 'mapped note', 1: 'needs a clinician', 2: ''} - // Save: mapped rows send their own entry, unmapped rows with a note get one. - const sent = JSON.parse(JSON.stringify(unmappedNoteMatches([0, 1, 2], mapSelected, notes))) - assert.deepEqual(sent, [{rowIndex: 1, note: 'needs a clinician'}]) - // Reopen: the CSV carries no note for that row, the saved entry does. - assert.equal(restoredNote(undefined, sent[0]), 'needs a clinician') - assert.equal(restoredNote('csv note', sent[0]), 'csv note') - assert.equal(restoredNote(undefined, undefined), undefined) + const { sent, restored } = roundTrip({0: searchedTarget}, {0: 'mapped note', 1: 'needs a clinician', 2: ''}, [0, 1, 2]) + assert.deepEqual(sent.map(entry => entry.rowIndex), ['0', 1]) + assert.deepEqual(restored, {0: 'mapped note', 1: 'needs a clinician', 2: undefined}) + assert.equal(restoredNote('csv note', sent[1]), 'csv note') +}) + +test('a cleared row (null in mapSelected) with a note is sent once and keeps its note', () => { + const { sent, restored } = roundTrip({0: null}, {0: 'rejected: wrong unit'}, [0]) + assert.equal(sent.length, 1) + assert.equal(restored[0], 'rejected: wrong unit') }) diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index dc4e416..af49291 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -481,14 +481,17 @@ export const withSavedMatch = (target, savedMatch) => { } /** - * The `matches` entries a save sends for rows with no mapping: a row's review - * note is kept only in its entry, so one without a mapped target still needs - * one (OpenConceptLab/ocl_issues#2984). Rows without a note get none. + * The `matches` a save sends: one entry per row. `entryFor` builds the entry + * of each row `mapSelected` holds, a cleared one (a null) included. A row it + * holds no key for, with a review note, gets a note-only entry, as the note is + * kept only in its entry (OpenConceptLab/ocl_issues#2984). */ -export const unmappedNoteMatches = (rowIndexes, mapSelected, notes) => - rowIndexes - .filter(index => !mapSelected[index] && notes[index]) +export const buildMatchEntries = (rowIndexes, mapSelected, notes, entryFor) => [ + ...Object.entries(mapSelected).map(([index, target]) => entryFor(target, index)), + ...rowIndexes + .filter(index => !Object.prototype.hasOwnProperty.call(mapSelected, index) && notes[index]) .map(index => ({rowIndex: index, note: notes[index]})) +] /** * A row's review note on reopen: the one its CSV row holds, else the one its