Skip to content

Commit

Permalink
Turn display set list of cards into table (#3125)
Browse files Browse the repository at this point in the history
This PR turns the display set list of cards into a table and adds a
seperate update view.

New list view:

![image](https://github.com/comic/grand-challenge.org/assets/30069334/4089424b-3d4d-42bc-b6f4-8706b2473845)

Update view:

![image](https://github.com/comic/grand-challenge.org/assets/30069334/a25e6f2a-ada6-4b2d-8901-b0aa8da9e191)

Closes #3042
  • Loading branch information
amickan authored Jan 18, 2024
1 parent 14bdb2f commit a110e87
Show file tree
Hide file tree
Showing 30 changed files with 635 additions and 901 deletions.
5 changes: 5 additions & 0 deletions app/grandchallenge/cases/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def __init__(
help_text=None,
user=None,
current_value=None,
disabled=False,
**kwargs,
):
widgets = (
Expand All @@ -54,6 +55,7 @@ def __init__(
super().__init__(widgets)
self.attrs = {
"help_text": help_text,
"disabled": disabled,
"user": user,
"current_value": current_value,
"widget_choices": {
Expand Down Expand Up @@ -100,6 +102,7 @@ def __init__(
require_all_fields=False,
image_queryset=None,
upload_queryset=None,
disabled=False,
**kwargs,
):
list_fields = [
Expand All @@ -108,6 +111,8 @@ def __init__(
]
super().__init__(*args, fields=list_fields, **kwargs)
self.require_all_fields = require_all_fields
if disabled:
self.widget.disabled = True

def compress(self, values):
if values:
Expand Down
3 changes: 2 additions & 1 deletion app/grandchallenge/components/form_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ def __init__(
initial=None,
user=None,
required=None,
disabled=False,
help_text="",
):
kwargs = {"required": required}
kwargs = {"required": required, "disabled": disabled}

if initial is not None:
kwargs["initial"] = initial
Expand Down
157 changes: 156 additions & 1 deletion app/grandchallenge/components/forms.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
from dal import autocomplete
from dal.widgets import Select
from django.conf import settings
from django.contrib.auth import get_user_model
from django.forms import HiddenInput, ModelChoiceField, ModelForm
from django.forms import Form, HiddenInput, ModelChoiceField, ModelForm

from grandchallenge.algorithms.models import AlgorithmImage
from grandchallenge.components.form_fields import InterfaceFormField
from grandchallenge.components.models import (
ComponentInterface,
ComponentInterfaceValue,
)
from grandchallenge.core.forms import SaveFormInitMixin
from grandchallenge.core.guardian import get_objects_for_user
from grandchallenge.evaluation.models import Method
Expand Down Expand Up @@ -79,3 +86,151 @@ def save(self, *args, **kwargs):

class Meta:
fields = ("user_upload", "creator", "comment")


class MultipleCIVForm(Form):
_possible_widgets = {
*InterfaceFormField._possible_widgets,
}

def __init__(self, *args, instance, base_obj, user, **kwargs):
super().__init__(*args, **kwargs)
self.instance = instance
self.user = user
self.base_obj = base_obj

# add fields for all interfaces that already exist on
# other display sets / archive items
for slug, values in base_obj.values_for_interfaces.items():
current_value = None

if instance:
current_value = instance.values.filter(
interface__slug=slug
).first()

self.init_interface_field(
interface_slug=slug, current_value=current_value, values=values
)
# Add fields for dynamically added new interfaces:
# These are sent along as form data like all other fields, so we can't
# tell them apart from the form fields initialized above. Hence
# the check if they already have a corresponding field on the form or not.
for slug in self.data.keys():
if (
ComponentInterface.objects.filter(slug=slug).exists()
and slug not in self.fields.keys()
):
self.init_interface_field(
interface_slug=slug,
current_value=None,
values=[],
)

def init_interface_field(self, interface_slug, current_value, values):
interface = ComponentInterface.objects.get(slug=interface_slug)
if interface.is_image_kind:
self.fields[interface_slug] = self._get_image_field(
interface=interface,
current_value=current_value,
)
elif interface.requires_file:
self.fields[interface_slug] = self._get_file_field(
interface=interface,
values=values,
current_value=current_value,
)
else:
self.fields[interface_slug] = self._get_default_field(
interface=interface, current_value=current_value
)

def _get_image_field(self, *, interface, current_value):
return self._get_default_field(
interface=interface, current_value=current_value
)

def _get_file_field(self, *, interface, values, current_value):
return self._get_default_field(
interface=interface, current_value=current_value
)

def _get_default_field(self, *, interface, current_value):
if isinstance(current_value, ComponentInterfaceValue):
current_value = current_value.value
return InterfaceFormField(
instance=interface,
initial=current_value,
required=False,
user=self.user,
).field


class SingleCIVForm(Form):
_possible_widgets = {
*InterfaceFormField._possible_widgets,
autocomplete.ModelSelect2,
Select,
}

def __init__(
self, *args, pk, interface, base_obj, user, htmx_url, **kwargs
):
super().__init__(*args, **kwargs)
data = kwargs.get("data")
qs = ComponentInterface.objects.exclude(
slug__in=base_obj.values_for_interfaces.keys()
)

if interface:
selected_interface = ComponentInterface.objects.get(pk=interface)
elif data and data.get("interface"):
selected_interface = ComponentInterface.objects.get(
pk=data["interface"]
)
else:
selected_interface = None

widget_kwargs = {}
attrs = {
"hx-get": htmx_url,
"hx-trigger": "interfaceSelected",
"disabled": selected_interface is not None,
"hx-target": f"#form-{kwargs['auto_id']}",
"hx-swap": "outerHTML",
"hx-include": "this",
}

if selected_interface:
widget = Select
interface_field_name = "interface"
else:
widget = autocomplete.ModelSelect2
attrs.update(
{
"data-placeholder": "Search for an interface ...",
"data-minimum-input-length": 3,
"data-theme": settings.CRISPY_TEMPLATE_PACK,
"data-html": True,
}
)
widget_kwargs[
"url"
] = "components:component-interface-autocomplete"
interface_field_name = f"interface-{kwargs['auto_id']}"
widget_kwargs["forward"] = [interface_field_name]
widget_kwargs["attrs"] = attrs

self.fields[interface_field_name] = ModelChoiceField(
initial=selected_interface,
queryset=qs,
widget=widget(**widget_kwargs),
label="Interface",
)

if selected_interface is not None:
self.fields[selected_interface.slug] = InterfaceFormField(
instance=selected_interface,
user=user,
required=selected_interface.value_required,
).field
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
function updateRequestConfig (event) {
for (const [key, val] of Object.entries(event.detail.parameters)) {
if (key.startsWith('interface')) {
event.detail.parameters['interface'] = val
delete event.detail.parameters[key]
}
}
}

function processSelectElements () {
const selectElements = document.querySelectorAll('select[name^="interface"]')
selectElements.forEach(elem => {
const observer = new MutationObserver(function(mutationsList, observer) {
for(let mutation of mutationsList) {
if (mutation.target === elem) {
elem.addEventListener('htmx:configRequest', updateRequestConfig);
htmx.trigger(elem, 'interfaceSelected');
}
}
});
observer.observe(elem, {childList: true});
});
}

htmx.onLoad((elem) => {
processSelectElements();
const dalForwardConfScripts = document.querySelectorAll('.dal-forward-conf script');
dalForwardConfScripts.forEach(script => script.textContent = '');
let vals = [];
const selectOptions = document.querySelectorAll('select:disabled[name^="interface"] option:checked');
selectOptions.forEach(option => {
vals.push(option.value);
});

if (vals.length) {
vals = vals.map(val => `{"type": "const", "dst": "interface-${val}", "val": "${val}"}`);
}

const objectSlugVal = document.getElementById('objectSlug').dataset.slug;
vals.push(`{"type": "const", "dst": "object", "val": "${objectSlugVal}"}`);

dalForwardConfScripts.forEach(script => script.textContent = `[${vals.join(',')}]`);
});

processSelectElements();
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
document.addEventListener('DOMContentLoaded', () => {

htmx.onLoad( function() {
const removeFormButtons = document.querySelectorAll('.remove-form');
for (const button of removeFormButtons) {
button.addEventListener('click', (event) => {
event.preventDefault();
const form = event.currentTarget.closest("form.extra-interface-form");
if (form) {
form.remove();
}
});
};
});

});

// force client side field validation for extra interface form fields
// htmx post won't get sent otherwise
document.getElementById('obj-form').addEventListener('submit', function(event) {
var extraInterfaceForms = document.getElementsByClassName('extra-interface-form');

for (var i = 0; i < extraInterfaceForms.length; i++) {
var form = extraInterfaceForms[i];

if (!form.checkValidity()) {
form.reportValidity();
event.preventDefault();
}
}
});
75 changes: 72 additions & 3 deletions app/grandchallenge/components/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import uuid

from dal import autocomplete
from django.contrib.messages.views import SuccessMessageMixin
from django.db.models import Q, TextChoices
from django.forms import Media
from django.http import HttpResponse
from django.utils.html import format_html
from django.views.generic import ListView, TemplateView
from django_filters.rest_framework import DjangoFilterBackend
from guardian.mixins import LoginRequiredMixin
Expand All @@ -10,6 +16,7 @@
from grandchallenge.components.models import ComponentInterface, InterfaceKind
from grandchallenge.components.serializers import ComponentInterfaceSerializer
from grandchallenge.reader_studies.models import ReaderStudy
from grandchallenge.subdomains.utils import reverse


class ComponentInterfaceViewSet(ReadOnlyModelViewSet):
Expand Down Expand Up @@ -63,10 +70,10 @@ class ComponentInterfaceAutocomplete(
):
def get_queryset(self):
if self.forwarded:
reader_study_slug = self.forwarded.pop("reader-study")
reader_study = ReaderStudy.objects.get(slug=reader_study_slug)
object_slug = self.forwarded.pop("object")
object = ReaderStudy.objects.get(slug=object_slug)
qs = ComponentInterface.objects.exclude(
slug__in=reader_study.values_for_interfaces.keys()
slug__in=object.values_for_interfaces.keys()
).exclude(pk__in=self.forwarded.values())
else:
qs = ComponentInterface.objects.filter(
Expand All @@ -84,3 +91,65 @@ def get_queryset(self):

def get_result_label(self, result):
return result.title


class InterfaceProcessingMixin(SuccessMessageMixin):
def process_data_for_object(self, data):
raise NotImplementedError

def form_valid(self, form):
form.instance = self.process_data_for_object(form.cleaned_data)
response = super().form_valid(form)
return HttpResponse(
response.url,
status=302,
headers={
"HX-Redirect": response.url,
"HX-Refresh": True,
},
)

def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
if hasattr(self, "object"):
instance = self.object
else:
instance = None
kwargs.update(
{
"user": self.request.user,
"auto_id": f"id-{uuid.uuid4()}",
"base_obj": self.base_object,
"instance": instance,
}
)
return kwargs

def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
media = Media()
for form_class in self.included_form_classes:
for widget in form_class._possible_widgets:
media = media + widget().media
if hasattr(self, "object"):
object = self.object
else:
object = None
context.update(
{
"base_object": self.base_object,
"form_media": media,
"object": object,
}
)
return context

def get_success_message(self, cleaned_data):
return format_html(
"{success_message} "
"Image and file import jobs have been queued. "
"You will be notified about errors related to image and file imports "
"via a <a href={url}>notification</a>.",
success_message=self.success_message,
url=reverse("notifications:list"),
)
Loading

0 comments on commit a110e87

Please sign in to comment.