Skip to content

Commit 95d92f3

Browse files
authored
Merge pull request #9 from Kris-LIBIS/main
authors and affiliation lookup examples added
2 parents 6482ad2 + 5ec9a0a commit 95d92f3

6 files changed

Lines changed: 439 additions & 0 deletions

File tree

examples/affiliation.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
The affiliation example illustrates a lookup in ROR.org for the author affiliation field. As with the authors example, it is a simple lookup and fill-in solution. Do not expect changes in the ROR database to be propagated in the Dataset metadata.
2+
3+
Two files comprise this example:
4+
5+
- examples/config/affiliation.json : the configuration file that needs to be uploaded in the :CVocConf setting
6+
7+
- scripts/affiliation.js : the Javascript file that modifies standard Dataverse behaviour
8+
9+
How to install:
10+
11+
- load the affiliation.json file in the :CVocConf setting using the [Dataverse API](https://guides.dataverse.org/en/latest/installation/config.html?highlight=cvocconf). e.g. using curl: `curl -X PUT --upload-file affiliation.json http://localhost:8080/api/admin/settings/:CVocConf`.
12+
13+
- refresh your browser page. That's it. You should see a search icon next to the affiliation input box of the Dataset Metadata editor which pops up a search dialog. Type some text in the search box (e.g. `UCLA`) and hit the `ENTER` key. Click on an organization name to see the ROR page and select the `import` button to copy the name in the Dataset metadata form.

examples/authors.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
The authors example uses the external vocabularies mechanism to implement a simple lookup and copy-and-paste functionality for the authors fields (name, affiliation, indentification scheme and number). It is different from the other examples as it does not store URLs to the external vocabulary, but instead copies the data directly into the dataverse metadata fields. The link with the vocabulary URI is therefore not persisted.
2+
3+
There are several reasons why we implemented it like this:
4+
5+
- we did not want to expose the personel data unprotected to the public internet. By copying the data instead of the URL, we can open the vocabulary only to the IP ranges that need access to dataset metadata editing, which for our institution is esentially on-premis or via VPN. The vocabulary server is no longer needed when the dataset metadata is published and accessed publicly.
6+
7+
- the data in the vocabulary will rarely change, it will only be expanded. The risk of author information getting outdated is very low. And even if so, it is likely that it would be better to leave the old metadata alone.
8+
9+
- we implemented it as a prototype for other metadata fields where the vocabulary data will most certainly change and we do not want these changes to percollate into the metadata of existing datasets. One such field is our faculty or department field. Departments splitting and merging do occur fairly regularly and we want the metadata to reflect the organisational entity as it was when the dataset was created. While this can be achieved in external vocabularies with deprecated entries, the extra complexity of maintaining the external vocabulary for a fairly simple list is not worth it. We will just change the list as we go and leave the existing data alone.
10+
11+
To illustrate the example for our authors lookup there are 2 files provided:
12+
13+
- examples/config/authors.json : the configuration file that needs to be uploaded in the :CVocConf setting
14+
15+
- scripts/authors.js : the Javascript file that modifies standard Dataverse behaviour
16+
17+
The standard authors compound fields are used, so there is no need to modify the default citation metadata block.
18+
19+
Note that this is a trimmed-down version of de code that we use in production. Our production version can be found in our [github repository](https://github.com/libis/rdm-covoc_server). There you will also find the implementation of a small REST server that performs the search in the background.
20+
21+
The JSON configuration file has most fields empty, but as they are all required, they need to be present in the configuration file. The `field-name` and `term-uri-field` have both been set to `authorName`. Only the field `field-name` should be set for this to work, but as I found out the hard way, the external vocabulary code insits that the `term-uri-field` also points to a valid metadata field. Theoretically, this means that the external vocabulary code that looks up values externally could be triggered if an URI is filled in in the `authorName` field, but it is fairly safe to asume that that will not be the case during normal use.
22+
23+
The `js-url` field in the configuration file points to the location where the JavaScript file is located. Since we already use an Apache frontend server to enable the Shibboleth logins, we decided to reverse-proxy the JavaScript file there as well. The other fields are emtpy and have no influence on our code.
24+
25+
The JavaScript file contains three parts:
26+
27+
- first of all code that is triggered each time the page is loaded and each time an author compound field set is added or deleted. That piece of code is responsible for creating the HTML code for a modal dialog box with search box and table with results. It also puts a search button next to each authorName input field that will display the dialog box.
28+
29+
- the `authorsQuery` function performs a call to a server that will search the given string in the database and return the data for the matches found. The code asumes this to be a simeple REST server that returns JSON data. The server itself in our case uses a Solr index to query and retrieve the data. Again, for security reasons we did not want to expose the Solr server directly beyond the server itself, so a simple REST server in front of it sanitizes the queries and protects the Solr index from being abused.
30+
31+
- the `importAuthorData` function is triggered when a user selects one of the search results and is responsible for copying the JSON data into the input fields on the dataset metadata form. Most of it is pretty straightforward code looking for the input fields in the form and setting the values, except for the part where the `identifierScheme` has to be filled in. The dropdown box consists of multiple elements that have to be changed in sync and it took a while to figure that out. This is also the weakest point in the code, as translations could modify any displayed text and thus could break the code that relies on the text `ORCID` to be present in the dropdown box. In this case that is something we have full contol of, but be warned when porting this code to other fields.

examples/config/affiliation.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
{
3+
"field-name": "authorAffiliation",
4+
"term-uri-field": "authorAffiliation",
5+
"js-url": "https://gdcc.github.io/dataverse-external-vocab-support/scripts/affiliation.js",
6+
"protocol": "covoc-author",
7+
"retrieval-uri": "",
8+
"allow-free-text": true,
9+
"languages": "",
10+
"vocabs": "",
11+
"managed-fields": {},
12+
"retrieval-filtering": {}
13+
}
14+
]

examples/config/authors.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
{
3+
"field-name": "authorName",
4+
"term-uri-field": "authorName",
5+
"js-url": "/covoc/js/covoc.js",
6+
"protocol": "covoc-author",
7+
"retrieval-uri": "",
8+
"allow-free-text": true,
9+
"languages": "",
10+
"vocabs": "",
11+
"managed-fields": {},
12+
"retrieval-filtering": {}
13+
}
14+
]

scripts/affiliation.js

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/************************************************************************************************************
2+
* This JavaScript is responsible for the controlled vocabulary features in the browser. *
3+
* The search button opens a dialog in which the controlled vocabulary server can be queried. *
4+
* On each result line, a button allows to copy-and-paste the information automatically in the form fields. *
5+
* ******************************************************************************************************** *
6+
* Author: Kris Dekeyser @ KU Leuven (2022). MIT License *
7+
************************************************************************************************************/
8+
9+
/* DOM Element Identfiers
10+
* **********************
11+
* affiliation-modal: the modal for the dialog box
12+
* affiliation-modal-title: the dialog box title
13+
* affiliation-search-box: field in the dialog where search term can be entered
14+
* affiliation-search-results: location where the query results will be displayed
15+
* DOM Classes
16+
* ***********
17+
* search_added: class added when a search button has already been added
18+
*/
19+
20+
var affiliationModalId ='affiliation-modal';
21+
var affiliationSearchBoxId = 'affiliation-search-box';
22+
var affiliationSearchResultsID = 'affiliation-search-results';
23+
var elementIdAttribute = 'data-affiliation-element-id';
24+
25+
/* The browser will run this code the first time the editor is opened and each time a multiple field instance is
26+
* added or removed. This code is reposible for creating the HTML for the dialog box, adding a search button to
27+
* the affiliation name fields and creating the triggers for initializing the dialog box and the search action itself.
28+
*/
29+
$(document).ready(function() {
30+
// Create Dialog box, if necessary
31+
createAffiliationModal();
32+
33+
// Put a search button after each affiliation name field
34+
putAffiliationSearchIcon();
35+
});
36+
37+
function createAffiliationModal() {
38+
let affiliationModal = document.getElementById(affiliationModalId);
39+
if (!affiliationModal) {
40+
// Create modal dialog
41+
let dialog = document.createElement('div');
42+
document.body.appendChild(dialog);
43+
dialog.outerHTML =
44+
'<div id="' + affiliationModalId + '" class="modal fade in" tabindex="-1" aria-labelledby="' + affiliationModalId + '-title" role="dialog" style="margin-top: 5rem">' +
45+
'<div class="modal-dialog" role="document">' +
46+
'<div class="modal-content">' +
47+
'<div class="modal-header">' +
48+
'<button class="close" type="button" data-dismiss="modal" aria-label="close"><span aria-label="Close">X</span></button>' +
49+
'<h5 id="' + affiliationModalId + '-title" class="modal-title">Search for organization in ROR</h5>' +
50+
'</div>' +
51+
'<div class="modal-body">' +
52+
'<div style="display: flex;">' +
53+
'<input id="' + affiliationSearchBoxId + '" class="form-control" accesskey="s" type="text" placeholder="Search for organization name ...">' +
54+
'<span class="glyphicon glyphicon-question-sign tooltip-icon" data-toggle="tooltip" data-placement="auto right" data-original-title="Type text and hit the Enter key to search." style="margin-left: 5px;"></span>' +
55+
'</div>' +
56+
'<div style="overflow-y: auto; height: 20em;">' +
57+
'<table id="' + affiliationSearchResultsID + '" class="table table-striped table-hover table-condensed"><tbody/></table>' +
58+
'</div>' +
59+
'</div>' +
60+
'</div>' +
61+
'</div>' +
62+
'</div>';
63+
64+
// Before modal is opened, pull in the current value of the affiliation input field into the search box and launch a query for that value
65+
$('#' + affiliationModalId).on('show.bs.modal', function(e) {
66+
// Get the stored ID of the input field
67+
let inputID = e.relatedTarget.getAttribute(elementIdAttribute);
68+
let affiliationElement = document.getElementById(inputID);
69+
// Fill in the input field text in the searchBox ...
70+
let affiliationSearchBox = document.getElementById(affiliationSearchBoxId);
71+
affiliationSearchBox.value = affiliationElement.value;
72+
// ... and launch a query
73+
affiliationsQuery(affiliationElement.value);
74+
// Let the searchBox know where to write the data
75+
affiliationSearchBox.setAttribute(elementIdAttribute, inputID);
76+
});
77+
78+
// After model is opened, set focus on search box
79+
$('#' + affiliationModalId).on('shown.bs.modal', function(e) {
80+
// autofocus does not work with BS modal
81+
let affiliationSearchBox = document.getElementById(affiliationSearchBoxId);
82+
affiliationSearchBox.focus();
83+
affiliationSearchBox.select();
84+
});
85+
86+
// To minimize the load on the lookup service, we opted for an explicit enter to launch a query
87+
document.getElementById(affiliationSearchBoxId).addEventListener('keyup', function(e) {
88+
// Only if Enter key is pressed
89+
if (e.key === 'Enter') {
90+
// Get string from searchBox ...
91+
let str = this.value;
92+
// ... and launch query ...
93+
affiliationsQuery(this.value);
94+
// .. and prevent key to be added to the searchBox
95+
e.preventDefault();
96+
}
97+
});
98+
}
99+
}
100+
101+
// Lauches a query to the external vocabulary server and fills in the results in the table element of the dialog searchBox
102+
103+
/* arguments:
104+
* - str (String): text to search for
105+
*/
106+
107+
function affiliationsQuery(str) {
108+
// Vocabulary search REST call
109+
fetch("https://api.ror.org/organizations?query=" + str)
110+
.then(response => response.json())
111+
.then(data => {
112+
let table = document.querySelector('#' + affiliationSearchResultsID + ' tbody');
113+
// Clear table content
114+
table.innerHTML = ''
115+
// Get ID of target input element
116+
let id = document.getElementById(affiliationSearchBoxId).getAttribute(elementIdAttribute);
117+
// Iterate over results
118+
data.items.forEach((doc) => {
119+
let label = doc.labels.length > 0 ? doc.labels[0].label : '';
120+
let address = ( doc.addresses.length > 0 ? doc.addresses[0].city : '' ) + ', ' + doc.country.country_name;
121+
// Add a table row for the doc
122+
table.innerHTML +=
123+
'<tr title="' + label + '">' +
124+
'<td><a href="' + doc.id + '" target="_blank">' + doc.name + '</a></td>' +
125+
'<td>' + address + '</td>' +
126+
'<td>' +
127+
'<span ' +
128+
'class="btn btn-default btn-xs glyphicon glyphicon-import pull-right" title="import" ' +
129+
'onclick="importAffiliationData(\'' + id + '\', \'' + doc.name + '\');">' +
130+
'</span>' +
131+
'</td>' +
132+
'</tr>';
133+
});
134+
});
135+
}
136+
137+
// Selector for all the author compound fields
138+
var authorSelector = "div#metadata_author ~ div.dataset-field-values div.edit-compound-field";
139+
140+
// Adds a search button to all the affiliation input fields
141+
function putAffiliationSearchIcon() {
142+
// Iterate over compound elements
143+
document.querySelectorAll(authorSelector).forEach(element => {
144+
// 'search_added' class marks elements that have already been processed
145+
if (!element.classList.contains('search_added')) {
146+
element.classList.add('search_added');
147+
// Second child is element that encapsulates label and input of affiliation
148+
let affiliationField = element.children[1];
149+
// Input field within
150+
let affiliationInput = affiliationField.querySelector('input');
151+
// We create an bootstrap input group ...
152+
let wrapper = document.createElement('div');
153+
wrapper.className = 'input-group';
154+
wrapper.style.display = 'flex';
155+
// ... containing the input field ...
156+
wrapper.appendChild(affiliationField.querySelector('input'));
157+
// ... and a new seach button ...
158+
let searchButton = document.createElement('button');
159+
element.setAttribute('aria-describedby', searchButton.id);
160+
searchButton.className = 'btn btn-default btn-sm bootstrap-button-tooltip compound-field-btn';
161+
searchButton.setAttribute('type', 'button');
162+
searchButton.setAttribute('title', 'Search in ROR');
163+
searchButton.setAttribute('data-toggle', 'modal');
164+
searchButton.setAttribute('data-target', '#' + affiliationModalId);
165+
searchButton.setAttribute(elementIdAttribute, affiliationInput.id);
166+
let searchIcon = document.createElement('span');
167+
searchIcon.className = 'glyphicon glyphicon-search no-text';
168+
searchButton.appendChild(searchIcon);
169+
wrapper.appendChild(searchButton);
170+
// ... and add that to the encapsulating element.
171+
affiliationField.appendChild(wrapper);
172+
}
173+
})
174+
}
175+
176+
// Import the query result data into the metadata form
177+
// arguments:
178+
// - id (String): identifier of the affiliationName input field
179+
// - affiliation: affiliation name
180+
function importAffiliationData(id, affiliation) {
181+
// Get the affiliation input field
182+
let affiliationElement = document.getElementById(id);
183+
// Fill-in affiliation
184+
affiliationElement.value = affiliation;
185+
// Close the dialog box when the import is done
186+
$('#' + affiliationModalId).modal('hide');
187+
}

0 commit comments

Comments
 (0)