diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 3f92f9c..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, 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' @@ -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]) @@ -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, diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index 88865fd..a7d7636 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, + buildMatchEntries, + restoredNote } from '../viewBuilders.js' // ---------- Helpers ---------- @@ -831,3 +833,30 @@ 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) ---------- + +// 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 { 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 411ce42..af49291 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -480,6 +480,25 @@ export const withSavedMatch = (target, savedMatch) => { } } +/** + * 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 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 + * 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