Skip to content

Commit

Permalink
feat: make institution field use ROR
Browse files Browse the repository at this point in the history
  • Loading branch information
wiwski committed Apr 1, 2024
1 parent 721f3d8 commit 4696462
Show file tree
Hide file tree
Showing 12 changed files with 253 additions and 57 deletions.
16 changes: 8 additions & 8 deletions lab/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ def __init__(
initial = {**(initial or {}), "user": instance.user.email}
super().__init__(initial=initial, instance=instance, **kwargs)
self.fields["user"].widget.attrs["placeholder"] = _("Email")
if instance:
self.fields["institution"].widget.instance = instance.institution

def try_populate_institution(self):
if not self.data.get(f"{self.prefix}-institution") and self.data.get(
Expand All @@ -43,6 +45,7 @@ def try_populate_institution(self):
) = models.Institution.objects.get_or_create(
name=self.data[f"{self.prefix}-institution__name"],
country=self.data.get(f"{self.prefix}-institution__country"),
ror_id=self.data.get(f"{self.prefix}-institution__ror_id"),
)
self.data[f"{self.prefix}-institution"] = institution.pk

Expand All @@ -59,6 +62,7 @@ def clean(self) -> dict[str, Any]:

def save(self, commit: bool = ...) -> models.Participation:
is_new = self.instance.pk is None
self.instance.is_leader = False
instance = super().save(commit=commit)
if is_new:
user: User = instance.user
Expand All @@ -71,13 +75,7 @@ def save(self, commit: bool = ...) -> models.Participation:
class Meta:
model = models.Participation
fields = ("user", "institution")
widgets = {
"institution": widgets.InstitutionAutoCompleteWidget(
choices=models.Institution.objects.values_list(
"id", "name", "country"
).order_by("-name")
)
}
widgets = {"institution": widgets.InstitutionAutoCompleteWidget()}
labels = {"institution": _("Institution")}


Expand All @@ -102,8 +100,10 @@ def __init__(self, **kwargs) -> None:
self.fields["user"].label = _("Leader")

def save(self, commit: bool = ...) -> models.Participation:
super().save(commit=False)
self.instance.is_leader = True
return super().save(commit=commit)
self.instance.save()
self._save_m2m()


class ChangeLeaderForm(Form):
Expand Down
24 changes: 24 additions & 0 deletions lab/migrations/0033_institution_ror_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 5.0.3 on 2024-04-01 09:34

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("lab", "0032_alter_participation_project"),
]

operations = [
migrations.AddField(
model_name="institution",
name="ror_id",
field=models.CharField(
blank=True,
help_text="Research Organization Registry ID",
max_length=255,
null=True,
verbose_name="ROR ID",
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 5.0.3 on 2024-04-01 09:56

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("lab", "0033_institution_ror_id"),
]

operations = [
migrations.AlterField(
model_name="institution",
name="country",
field=models.CharField(
blank=True, max_length=255, null=True, verbose_name="country"
),
),
migrations.AlterField(
model_name="institution",
name="name",
field=models.CharField(max_length=255, verbose_name="name"),
),
]
14 changes: 11 additions & 3 deletions lab/models/participation.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.db.models.constraints import UniqueConstraint
from django.utils.translation import gettext_lazy as _

from shared.models import LowerCharField, TimestampedModel
from shared.models import TimestampedModel


class Participation(TimestampedModel):
Expand Down Expand Up @@ -40,11 +40,19 @@ class Meta:


class Institution(models.Model):
name = LowerCharField(verbose_name=_("name"), max_length=255)
country = LowerCharField(
name = models.CharField(verbose_name=_("name"), max_length=255)
country = models.CharField(
verbose_name=_("country"), max_length=255, blank=True, null=True
)

ror_id = models.CharField(
verbose_name="ROR ID",
max_length=255,
blank=True,
null=True,
help_text="Research Organization Registry ID",
)

class Meta:
constraints = [
UniqueConstraint(
Expand Down
18 changes: 17 additions & 1 deletion lab/static/css/admin/project-admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ body.change-list {

#participation_set-group .field-user input {
max-width: unset;
width: 30rem;
width: 100%;
}

#participation_set-group td.delete input {
Expand All @@ -24,6 +24,22 @@ body.change-list {
min-width: 500px;
}

#participation_set-group .typeahead-field {
width: 60%;
}

#participation_set-group .autocomplete-input__country {
width: 40%;
}

#participation_set-group .delete {
vertical-align: middle;
}

#participation_set-group.inline-group {
overflow: inherit !important; /* for typeahead list to be absolute */
}

#participation_set-group tbody th {
text-transform: uppercase;
font-size: 0.6875rem;
Expand Down
23 changes: 23 additions & 0 deletions lab/static/css/widgets/institution-autocomplete-widget.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.typeahead-field {
position: relative;
display: inline-block;
}

.typeahead-field .typeahead-list {
background-color: var(--background-default-grey);
position: absolute;
z-index: 1000;
top: 41px;
width: 300px;
max-height: 500px;
overflow: scroll;
left: 0;
right: 0;
}

.typeahead-field .typeahead-list button {
text-align: left;
padding: .75rem 1rem;
width: 100%;
}

100 changes: 85 additions & 15 deletions lab/static/js/widgets/institution-autocomplete-widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,114 @@
(function () {
const baseSelector = ".autocomplete-input";

function onNameInput(event) {
let fetchTimeoutId = null; // for debouncing when fetching from ROR API

function onResultClicked(event, { country, name, rorId }) {
const parentField = event.target.closest(".field-institution");
parentField.querySelector(".autocomplete-input__name").value = name;
parentField.querySelector(".autocomplete-input__country").value = country;
parentField.querySelector(".autocomplete-input__ror_id").value = rorId;

parentField.querySelector(".typeahead-list").classList.add("hidden");
}

function onCountryOrNameInput(event) {
const institutionField = event.target.closest(".field-institution");

const idInput = institutionField.querySelector(".autocomplete-input__id");
if (idInput.value && idInput.value !== "") {
idInput.value = "";
}
}

function onCountryInput(event) {
onCountryOrNameInput(event);
}

async function onNameInput(event) {
// On institution name input, fetch institutions from ROR API
const parentElement = event.target.parentElement;
const options = parentElement.querySelectorAll("datalist option");
const index = Array.from(options).findIndex(
(o) => o.value === event.target.value,
);
if (index === -1) {
parentElement.querySelector(`${baseSelector}__id`).value = "";
} else {
const [name, country] = event.target.value.split(", ");
if (country) {
parentElement.querySelector(`${baseSelector}__country`).value = country;
}
parentElement.querySelector(`${baseSelector}__name`).value = name;

onCountryOrNameInput(event);

if (fetchTimeoutId) {
clearTimeout(fetchTimeoutId); // debounce
}

fetchTimeoutId = setTimeout(async () => {
const response = await fetch(
"https://api.ror.org/organizations?query=" +
encodeURIComponent(event.target.value),
);

if (!response.ok) {
return;
}

const results = (await response.json()).items;

const typeaheadList = parentElement.querySelector(".typeahead-list");

typeaheadList.classList.remove("hidden");

typeaheadList.querySelectorAll("button").forEach((o) => o.remove());
results.forEach((result) => {
const buttonElement = document.createElement("button");
buttonElement.textContent = `${result.name}, ${result.country?.country_name}`;
buttonElement.id = result.id;
buttonElement.type = "button";
buttonElement.addEventListener("click", (event) =>
onResultClicked(event, {
country: result.country?.country_name,
name: result.name,
rorId: result.id,
}),
);
parentElement
.querySelector(".typeahead-list")
.appendChild(buttonElement);
});
}, 500);
}

document.addEventListener("DOMContentLoaded", function () {
// Add event listeners to institution input elements
document.querySelectorAll(`${baseSelector}`).forEach((el) => {
el.querySelector(`${baseSelector}__name`).addEventListener(
"input",
onNameInput,
);
el.querySelector(`${baseSelector}__country`).addEventListener(
"input",
onCountryInput,
);
});

document.addEventListener("click", function (event) {
// Hide typeahead list when clicking outside
document.querySelectorAll(".typeahead-list").forEach((el) => {
const isInside = el.contains(event.target);
if (!isInside && !el.classList.contains("hidden")) {
el.classList.add("hidden");
}
});
});

setTimeout(() => {
// Add event listeners to new institution input elements
document
.querySelector("#participation_set-group .add-row a")
.addEventListener("click", function () {
console.log("clicked");
const elements = document.querySelectorAll(
".dynamic-participation_set",
);
const lastElement = elements[elements.length - 1];
console.log(lastElement);
lastElement
.querySelector(`${baseSelector}__name`)
.addEventListener("input", onNameInput);
lastElement
.querySelector(`${baseSelector}__country`)
.addEventListener("input", onCountryInput);
});
}, 300);
});
Expand Down
13 changes: 6 additions & 7 deletions lab/templates/widgets/institution_autocomplete_widget.html
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
{% load i18n %}

<div class="autocomplete-input flex-container">
<input name="{{ widget.name }}__name" {% if widget.instance %}value="{{ widget.instance.name }}"{% endif %} list="datalist-{{widget.name}}__choices" id="{{widget.id}}__name" class="fr-input autocomplete-input__name fr-mr-1w" placeholder="{% translate "Institution" %}">
<datalist id="datalist-{{widget.name}}__choices">
{% for choice in widget.choices %}
<option value="{{choice.1}}"></option>
{% endfor %}
</datalist>
<div class="typeahead-field fr-mr-1w">
<input name="{{ widget.name }}__name" {% if widget.instance %}value="{{ widget.instance.name }}"{% endif %} id="{{widget.id}}__name" class="fr-input autocomplete-input__name" placeholder="{% translate "Institution" %}" autocomplete="off">
<div class="typeahead-list"></div>
</div>
<input name="{{ widget.name }}__country" {% if widget.instance %}value="{{ widget.instance.country }}"{% endif %} id="{{widget.id}}__country" class="fr-input autocomplete-input__country" placeholder="{% translate "Country" %}">

<input type="hidden" {% if widget.value %}value="{{ widget.value }}"{% endif %} name="{{ widget.name }}" id="{{widget.id}}" class="fr-input autocomplete-input__id">
<input type="hidden" {% if widget.instance %}value="{{ widget.instance.ror_id }}"{% endif %} name="{{ widget.name }}__ror_id" id="{{widget.id}}__ror_id" class="fr-input autocomplete-input__ror_id">
</div>
Loading

0 comments on commit 4696462

Please sign in to comment.