Skip to content
69 changes: 48 additions & 21 deletions js/mappingXmlToInputFields.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
* in the dropdown based on the visible text matching the `resourceTypeGeneral` attribute.
*
* @param {Document} xmlDoc - The XML document containing the resourceType element.
* @param {Function} resolver - The namespace resolver function.
*/
function processResourceType(xmlDoc) {
// Extract the resourceType element
const resourceNode = xmlDoc.querySelector("resourceType");
function processResourceType(xmlDoc, resolver) {
// Extract the resourceType element using XPath (querySelector fails on namespaced XML)
const result = xmlDoc.evaluate(".//ns:resourceType", xmlDoc, resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
const resourceNode = result.singleNodeValue;
if (!resourceNode) {
console.error("No resourceType element found in XML");
return;
Expand Down Expand Up @@ -390,8 +392,11 @@ function processContactPersons(xmlDoc) {
.first();

if ($row.length === 0) {
console.warn(`No matching author row found for contact person: ${givenName} ${familyName}`);
continue;
// No matching author found — add a new author row for the contact person
$("#button-author-add").click();
$row = $("div[data-creator-row]").last();
$row.find('input[name="familynames[]"]').val(familyName);
$row.find('input[name="givennames[]"]').val(givenName);
}

// Mark the row as contact person
Expand Down Expand Up @@ -451,7 +456,13 @@ function processContactPersonsFromDataCite(xmlDoc) {
})
.first();

if ($row.length === 0) continue;
if ($row.length === 0) {
// No matching author found — add a new author row for the contact person
$("#button-author-add").click();
$row = $("div[data-creator-row]").last();
$row.find('input[name="familynames[]"]').val(familyName);
$row.find('input[name="givennames[]"]').val(givenName);
}

$row.find('input[name="contacts[]"]').prop("checked", true);
$row.find(".contact-person-input").show();
Expand Down Expand Up @@ -914,26 +925,38 @@ function parseTemporalData(dateNode) {
/**
* Extract spatial coordinates and description from a geoLocation node.
* @param {Element} node - The geoLocation XML element.
* @param {Document} xmlDoc - The XML document (needed for XPath evaluation).
* @param {Function} resolver - The namespace resolver function.
* @returns {Object} Parsed location data.
*/
function getGeoLocationData(node) {
const place = node.querySelector("geoLocationPlace")?.textContent || "";
const boxNode = node.querySelector("geoLocationBox");
const pointNode = node.querySelector("geoLocationPoint");
function getGeoLocationData(node, xmlDoc, resolver) {
function getText(contextNode, localName) {
const result = xmlDoc.evaluate("ns:" + localName, contextNode, resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
return result.singleNodeValue?.textContent || "";
}

function getNode(contextNode, localName) {
const result = xmlDoc.evaluate("ns:" + localName, contextNode, resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
return result.singleNodeValue;
}

const place = getText(node, "geoLocationPlace");
const boxNode = getNode(node, "geoLocationBox");
const pointNode = getNode(node, "geoLocationPoint");

if (boxNode) {
return {
place,
latitudeMin: boxNode.querySelector("southBoundLatitude")?.textContent || "",
latitudeMax: boxNode.querySelector("northBoundLatitude")?.textContent || "",
longitudeMin: boxNode.querySelector("westBoundLongitude")?.textContent || "",
longitudeMax: boxNode.querySelector("eastBoundLongitude")?.textContent || "",
latitudeMin: getText(boxNode, "southBoundLatitude"),
latitudeMax: getText(boxNode, "northBoundLatitude"),
longitudeMin: getText(boxNode, "westBoundLongitude"),
longitudeMax: getText(boxNode, "eastBoundLongitude"),
};
}

if (pointNode) {
const lat = pointNode.querySelector("pointLatitude")?.textContent || "";
const lon = pointNode.querySelector("pointLongitude")?.textContent || "";
const lat = getText(pointNode, "pointLatitude");
const lon = getText(pointNode, "pointLongitude");
return {
place,
latitudeMin: lat,
Expand Down Expand Up @@ -1004,7 +1027,7 @@ function processSpatialTemporalCoverages(xmlDoc, resolver) {
const dateNodes = xmlDoc.evaluate('//ns:dates/ns:date[@dateType="Coverage" or @dateType="Collected"]', xmlDoc, resolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);

for (let i = 0; i < geoLocationNodes.snapshotLength; i++) {
const geoData = getGeoLocationData(geoLocationNodes.snapshotItem(i));
const geoData = getGeoLocationData(geoLocationNodes.snapshotItem(i), xmlDoc, resolver);
const temporalData = parseTemporalData(dateNodes.snapshotItem(i));

const $lastRow = $('textarea[name="tscDescription[]"]').last().closest("[tsc-row]");
Expand Down Expand Up @@ -1286,8 +1309,9 @@ function processFunders(xmlDoc, resolver) {
const funderNode = funderNodes.snapshotItem(i);
// Extract data from XML
const funderName = getNodeText(funderNode, "ns:funderName", xmlDoc, resolver);
const funderId = getNodeText(funderNode, "ns:funderIdentifier", xmlDoc, resolver);
const funderIdTyp = funderNode.querySelector("funderIdentifier")?.getAttribute("funderIdentifierType") || "";
const funderIdNode = xmlDoc.evaluate("ns:funderIdentifier", funderNode, resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
const funderId = funderIdNode ? funderIdNode.textContent.trim() : "";
const funderIdTyp = funderIdNode?.getAttribute("funderIdentifierType") || "";
const awardTitle = getNodeText(funderNode, "ns:awardTitle", xmlDoc, resolver);
const awardNumberNode = xmlDoc.evaluate("ns:awardNumber", funderNode, resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
const awardNumber = awardNumberNode ? awardNumberNode.textContent.trim() : "";
Expand Down Expand Up @@ -1410,7 +1434,7 @@ async function loadXmlToForm(xmlDoc) {
}
}

processResourceType(xmlDoc);
processResourceType(xmlDoc, resolver);
// Process titles
processTitles(xmlDoc, resolver, titleTypeMapping);
// Processing Creators
Expand Down Expand Up @@ -1469,6 +1493,9 @@ if (typeof module !== 'undefined' && module.exports) {
parseTemporalData,
getGeoLocationData,
fillSpatialFields,
processUsedInstruments
processUsedInstruments,
processDescriptions,
processRelatedWorks,
processFunders
};
}
76 changes: 67 additions & 9 deletions tests/js/contactPersonLoading.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,29 @@ describe('processContactPersons (ISO)', () => {
<input type="text" name="cpOnlineResource[]" value="" />
</div>
</div>
<button type="button" id="button-author-add"></button>
</div>
`;

// Wire up click handler to simulate adding a new author row
$('#button-author-add').on('click', function () {
const $newRow = $(`
<div class="row" data-creator-row>
<input type="checkbox" name="contacts[]" />
<input type="text" name="familynames[]" value="" />
<input type="text" name="givennames[]" value="" />
<input type="text" name="orcids[]" value="" />
<div class="contact-person-input" style="display: none;">
<input type="email" name="cpEmail[]" value="" />
</div>
<div class="contact-person-input" style="display: none;">
<input type="text" name="cpOnlineResource[]" value="" />
</div>
</div>
`);
$('#group-author').append($newRow);
});

global.Tagify = jest.fn().mockImplementation(() => ({
addTags: jest.fn(),
settings: { whitelist: [] },
Expand Down Expand Up @@ -175,7 +195,7 @@ describe('processContactPersons (ISO)', () => {
expect($firstRow.find('input[name="contacts[]"]').prop('checked')).toBe(true);
});

test('does not match when names differ completely', () => {
test('creates new author row when names differ completely', () => {
const xmlDoc = makeIsoXml({
familyName: 'Unknown',
givenName: 'Person',
Expand All @@ -184,10 +204,20 @@ describe('processContactPersons (ISO)', () => {

mappingModule.processContactPersons(xmlDoc);

// Neither row should be marked as CP
$('div[data-creator-row]').each(function () {
expect($(this).find('input[name="contacts[]"]').prop('checked')).toBe(false);
});
// A third author row should have been created
const $rows = $('div[data-creator-row]');
expect($rows.length).toBe(3);

// Original rows should not be marked as CP
expect($rows.eq(0).find('input[name="contacts[]"]').prop('checked')).toBe(false);
expect($rows.eq(1).find('input[name="contacts[]"]').prop('checked')).toBe(false);

// New row should be marked as CP with correct data
const $newRow = $rows.eq(2);
expect($newRow.find('input[name="contacts[]"]').prop('checked')).toBe(true);
expect($newRow.find('input[name="familynames[]"]').val()).toBe('Unknown');
expect($newRow.find('input[name="givennames[]"]').val()).toBe('Person');
expect($newRow.find('input[name="cpEmail[]"]').val()).toBe('[email protected]');
});

test('handles XML with no pointOfContact gracefully', () => {
Expand Down Expand Up @@ -309,9 +339,28 @@ describe('processContactPersonsFromDataCite (fallback)', () => {
<input type="text" name="cpOnlineResource[]" value="" />
</div>
</div>
<button type="button" id="button-author-add"></button>
</div>
`;

// Wire up click handler to simulate adding a new author row
$('#button-author-add').on('click', function () {
const $newRow = $(`
<div class="row" data-creator-row>
<input type="checkbox" name="contacts[]" />
<input type="text" name="familynames[]" value="" />
<input type="text" name="givennames[]" value="" />
<div class="contact-person-input" style="display: none;">
<input type="email" name="cpEmail[]" value="" />
</div>
<div class="contact-person-input" style="display: none;">
<input type="text" name="cpOnlineResource[]" value="" />
</div>
</div>
`);
$('#group-author').append($newRow);
});

global.Tagify = jest.fn().mockImplementation(() => ({
addTags: jest.fn(),
settings: { whitelist: [] },
Expand Down Expand Up @@ -407,17 +456,26 @@ describe('processContactPersonsFromDataCite (fallback)', () => {
expect($firstRow.find('input[name="contacts[]"]').prop('checked')).toBe(true);
});

test('does nothing when no matching author found in DataCite fallback', () => {
test('creates new author row when no matching author found in DataCite fallback', () => {
const xmlDoc = makeDataCiteXml({
familyName: 'Unknown',
givenName: 'Person',
});

expect(() => mappingModule.processContactPersonsFromDataCite(xmlDoc)).not.toThrow();

$('div[data-creator-row]').each(function () {
expect($(this).find('input[name="contacts[]"]').prop('checked')).toBe(false);
});
// A second author row should have been created
const $rows = $('div[data-creator-row]');
expect($rows.length).toBe(2);

// Original row should not be marked as CP
expect($rows.eq(0).find('input[name="contacts[]"]').prop('checked')).toBe(false);

// New row should be marked as CP with correct data
const $newRow = $rows.eq(1);
expect($newRow.find('input[name="contacts[]"]').prop('checked')).toBe(true);
expect($newRow.find('input[name="familynames[]"]').val()).toBe('Unknown');
expect($newRow.find('input[name="givennames[]"]').val()).toBe('Person');
});

test('does nothing when DataCite XML has no ContactPerson contributor', () => {
Expand Down
65 changes: 36 additions & 29 deletions tests/js/mappingXmlToInputFields.module.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -438,42 +438,47 @@ describe('mappingXmlToInputFields module coverage', () => {
});

describe('getGeoLocationData', () => {
const nsResolver = (prefix) => prefix === 'ns' ? 'http://datacite.org/schema/kernel-4' : null;

test('returns empty data for node without location info', () => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString('<geoLocation></geoLocation>', 'text/xml');
const geoNode = xmlDoc.querySelector('geoLocation');
const xmlDoc = parser.parseFromString(
'<ns:geoLocation xmlns:ns="http://datacite.org/schema/kernel-4"></ns:geoLocation>',
'text/xml'
);
const geoNode = xmlDoc.documentElement;

const result = mappingModule.getGeoLocationData(geoNode);
const result = mappingModule.getGeoLocationData(geoNode, xmlDoc, nsResolver);
expect(result.latitudeMin).toBe('');
expect(result.longitudeMin).toBe('');
});

test('extracts place name', () => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(
'<geoLocation><geoLocationPlace>Berlin</geoLocationPlace></geoLocation>',
'<ns:geoLocation xmlns:ns="http://datacite.org/schema/kernel-4"><ns:geoLocationPlace>Berlin</ns:geoLocationPlace></ns:geoLocation>',
'text/xml'
);
const geoNode = xmlDoc.querySelector('geoLocation');
const geoNode = xmlDoc.documentElement;

const result = mappingModule.getGeoLocationData(geoNode);
const result = mappingModule.getGeoLocationData(geoNode, xmlDoc, nsResolver);
expect(result.place).toBe('Berlin');
});

test('extracts point coordinates', () => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(
`<geoLocation>
<geoLocationPoint>
<pointLatitude>52.5</pointLatitude>
<pointLongitude>13.4</pointLongitude>
</geoLocationPoint>
</geoLocation>`,
`<ns:geoLocation xmlns:ns="http://datacite.org/schema/kernel-4">
<ns:geoLocationPoint>
<ns:pointLatitude>52.5</ns:pointLatitude>
<ns:pointLongitude>13.4</ns:pointLongitude>
</ns:geoLocationPoint>
</ns:geoLocation>`,
'text/xml'
);
const geoNode = xmlDoc.querySelector('geoLocation');
const geoNode = xmlDoc.documentElement;

const result = mappingModule.getGeoLocationData(geoNode);
const result = mappingModule.getGeoLocationData(geoNode, xmlDoc, nsResolver);
// For point, lat/lon are duplicated to min and max
expect(result.latitudeMin).toBe('52.5');
expect(result.latitudeMax).toBe('52.5');
Expand All @@ -484,19 +489,19 @@ describe('mappingXmlToInputFields module coverage', () => {
test('extracts bounding box coordinates', () => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(
`<geoLocation>
<geoLocationBox>
<northBoundLatitude>53</northBoundLatitude>
<southBoundLatitude>52</southBoundLatitude>
<eastBoundLongitude>14</eastBoundLongitude>
<westBoundLongitude>13</westBoundLongitude>
</geoLocationBox>
</geoLocation>`,
`<ns:geoLocation xmlns:ns="http://datacite.org/schema/kernel-4">
<ns:geoLocationBox>
<ns:northBoundLatitude>53</ns:northBoundLatitude>
<ns:southBoundLatitude>52</ns:southBoundLatitude>
<ns:eastBoundLongitude>14</ns:eastBoundLongitude>
<ns:westBoundLongitude>13</ns:westBoundLongitude>
</ns:geoLocationBox>
</ns:geoLocation>`,
'text/xml'
);
const geoNode = xmlDoc.querySelector('geoLocation');
const geoNode = xmlDoc.documentElement;

const result = mappingModule.getGeoLocationData(geoNode);
const result = mappingModule.getGeoLocationData(geoNode, xmlDoc, nsResolver);
expect(result.latitudeMin).toBe('52');
expect(result.latitudeMax).toBe('53');
expect(result.longitudeMin).toBe('13');
Expand Down Expand Up @@ -541,36 +546,38 @@ describe('mappingXmlToInputFields module coverage', () => {
});

describe('processResourceType', () => {
const nsResolver = (prefix) => prefix === 'ns' ? 'http://datacite.org/schema/kernel-4' : null;

test('handles missing resourceType node', () => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString('<root></root>', 'text/xml');

// Should not throw
expect(() => {
mappingModule.processResourceType(xmlDoc);
mappingModule.processResourceType(xmlDoc, nsResolver);
}).not.toThrow();
});

test('handles missing resourceTypeGeneral attribute', () => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(
'<resource><resourceType>Research Data</resourceType></resource>',
'<ns:resource xmlns:ns="http://datacite.org/schema/kernel-4"><ns:resourceType>Research Data</ns:resourceType></ns:resource>',
'text/xml'
);

expect(() => {
mappingModule.processResourceType(xmlDoc);
mappingModule.processResourceType(xmlDoc, nsResolver);
}).not.toThrow();
});

test('selects matching option in dropdown', () => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(
'<resource><resourceType resourceTypeGeneral="Dataset">Research Data</resourceType></resource>',
'<ns:resource xmlns:ns="http://datacite.org/schema/kernel-4"><ns:resourceType resourceTypeGeneral="Dataset">Research Data</ns:resourceType></ns:resource>',
'text/xml'
);

mappingModule.processResourceType(xmlDoc);
mappingModule.processResourceType(xmlDoc, nsResolver);

const select = document.querySelector('#input-resourceinformation-resourcetype');
expect(select.options[0].selected).toBe(true);
Expand Down
Loading
Loading