From a110e8773c134f865f000a6612338b2b61992133 Mon Sep 17 00:00:00 2001 From: Anne Mickan Date: Thu, 18 Jan 2024 14:29:55 +0100 Subject: [PATCH] Turn display set list of cards into table (#3125) 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 --- app/grandchallenge/cases/widgets.py | 5 + app/grandchallenge/components/form_fields.py | 3 +- app/grandchallenge/components/forms.py | 157 ++++++++- .../components/js/autocomplete_htmx.mjs | 45 +++ .../components/js/create_extra_interfaces.mjs | 31 ++ app/grandchallenge/components/views.py | 75 ++++- .../core/static/js/jsoneditor_widget.mjs | 8 + app/grandchallenge/reader_studies/forms.py | 186 ++--------- app/grandchallenge/reader_studies/models.py | 10 - .../reader_studies/js/autocomplete_htmx.js | 23 -- .../reader_studies/js/display_set_create.js | 94 ------ .../static/reader_studies/js/images_list.mjs | 58 ++-- .../reader_studies/display_set_create.html | 19 +- .../reader_studies/display_set_detail.html | 54 --- .../display_set_files_update.html | 15 +- .../display_set_interface_create.html | 18 - ...images_list.html => display_set_list.html} | 23 +- .../display_set_new_interface_create.html | 4 +- .../reader_studies/display_set_row.html | 45 +++ .../reader_studies/display_set_update.html | 48 ++- .../readerstudy_add_object.html | 3 + .../reader_studies/readerstudy_detail.html | 7 +- .../readerstudy_display_sets_row.html | 19 -- .../reader_studies/select_upload_widget.html | 2 +- app/grandchallenge/reader_studies/urls.py | 10 +- app/grandchallenge/reader_studies/views.py | 315 +++++------------- app/tests/components_tests/test_views.py | 4 +- app/tests/reader_studies_tests/test_forms.py | 52 ++- app/tests/reader_studies_tests/test_models.py | 28 -- app/tests/reader_studies_tests/test_views.py | 175 ++-------- 30 files changed, 635 insertions(+), 901 deletions(-) create mode 100644 app/grandchallenge/components/static/components/js/autocomplete_htmx.mjs create mode 100644 app/grandchallenge/components/static/components/js/create_extra_interfaces.mjs delete mode 100644 app/grandchallenge/reader_studies/static/reader_studies/js/autocomplete_htmx.js delete mode 100644 app/grandchallenge/reader_studies/static/reader_studies/js/display_set_create.js delete mode 100644 app/grandchallenge/reader_studies/templates/reader_studies/display_set_detail.html delete mode 100644 app/grandchallenge/reader_studies/templates/reader_studies/display_set_interface_create.html rename app/grandchallenge/reader_studies/templates/reader_studies/{readerstudy_images_list.html => display_set_list.html} (83%) create mode 100644 app/grandchallenge/reader_studies/templates/reader_studies/display_set_row.html delete mode 100644 app/grandchallenge/reader_studies/templates/reader_studies/readerstudy_display_sets_row.html diff --git a/app/grandchallenge/cases/widgets.py b/app/grandchallenge/cases/widgets.py index 51211cd645..c2097df445 100644 --- a/app/grandchallenge/cases/widgets.py +++ b/app/grandchallenge/cases/widgets.py @@ -45,6 +45,7 @@ def __init__( help_text=None, user=None, current_value=None, + disabled=False, **kwargs, ): widgets = ( @@ -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": { @@ -100,6 +102,7 @@ def __init__( require_all_fields=False, image_queryset=None, upload_queryset=None, + disabled=False, **kwargs, ): list_fields = [ @@ -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: diff --git a/app/grandchallenge/components/form_fields.py b/app/grandchallenge/components/form_fields.py index 250f295abe..731eba7a59 100644 --- a/app/grandchallenge/components/form_fields.py +++ b/app/grandchallenge/components/form_fields.py @@ -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 diff --git a/app/grandchallenge/components/forms.py b/app/grandchallenge/components/forms.py index 5ebdb54d2d..af6af3df19 100644 --- a/app/grandchallenge/components/forms.py +++ b/app/grandchallenge/components/forms.py @@ -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 @@ -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 diff --git a/app/grandchallenge/components/static/components/js/autocomplete_htmx.mjs b/app/grandchallenge/components/static/components/js/autocomplete_htmx.mjs new file mode 100644 index 0000000000..d5baa07dd7 --- /dev/null +++ b/app/grandchallenge/components/static/components/js/autocomplete_htmx.mjs @@ -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(); diff --git a/app/grandchallenge/components/static/components/js/create_extra_interfaces.mjs b/app/grandchallenge/components/static/components/js/create_extra_interfaces.mjs new file mode 100644 index 0000000000..1c6f5d26b8 --- /dev/null +++ b/app/grandchallenge/components/static/components/js/create_extra_interfaces.mjs @@ -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(); + } + } +}); diff --git a/app/grandchallenge/components/views.py b/app/grandchallenge/components/views.py index 315b2a6003..4800bd73f2 100644 --- a/app/grandchallenge/components/views.py +++ b/app/grandchallenge/components/views.py @@ -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 @@ -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): @@ -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( @@ -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 notification.", + success_message=self.success_message, + url=reverse("notifications:list"), + ) diff --git a/app/grandchallenge/core/static/js/jsoneditor_widget.mjs b/app/grandchallenge/core/static/js/jsoneditor_widget.mjs index 47ed303162..51eebb5c98 100644 --- a/app/grandchallenge/core/static/js/jsoneditor_widget.mjs +++ b/app/grandchallenge/core/static/js/jsoneditor_widget.mjs @@ -47,4 +47,12 @@ document.addEventListener("DOMContentLoaded", function(event) { }); }); +// this is necessary for when an invalid form is returned through htmx (e.g. in display set views) +if (typeof htmx !== 'undefined') { + htmx.onLoad((elem) => { + if (elem.tagName.toLowerCase() === "body") { + search_for_jsoneditor_widgets(elem) + } + }); +}; search_for_jsoneditor_widgets(); diff --git a/app/grandchallenge/reader_studies/forms.py b/app/grandchallenge/reader_studies/forms.py index ee55721fe3..48a0145483 100644 --- a/app/grandchallenge/reader_studies/forms.py +++ b/app/grandchallenge/reader_studies/forms.py @@ -13,8 +13,6 @@ Layout, Submit, ) -from dal import autocomplete -from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.models import BLANK_CHOICE_DASH from django.forms import ( @@ -35,11 +33,7 @@ from django_select2.forms import Select2MultipleWidget from dynamic_forms import DynamicField, DynamicFormMixin -from grandchallenge.cases.widgets import ( - FlexibleImageField, - FlexibleImageWidget, -) -from grandchallenge.components.form_fields import InterfaceFormField +from grandchallenge.components.forms import MultipleCIVForm from grandchallenge.components.models import ( ComponentInterface, ComponentInterfaceValue, @@ -556,71 +550,18 @@ def clean_ground_truth(self): return values -class DisplaySetCreateForm(Form): - _possible_widgets = { - *InterfaceFormField._possible_widgets, - } - - def __init__(self, *args, instance, reader_study, user, **kwargs): +class DisplaySetCreateForm(MultipleCIVForm): + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.instance = instance - self.reader_study = reader_study - self.user = user - - for slug, values in reader_study.values_for_interfaces.items(): - current_value = None - - if instance: - current_value = instance.values.filter( - interface__slug=slug - ).first() - - interface = ComponentInterface.objects.get(slug=slug) - - if interface.is_image_kind: - self.fields[slug] = self._get_image_field( - interface=interface, - values=values, - current_value=current_value, - ) - elif interface.requires_file: - self.fields[slug] = self._get_file_field( - interface=interface, - values=values, - current_value=current_value, - ) - else: - self.fields[slug] = self._get_default_field( - interface=interface, current_value=current_value - ) - self.fields["order"] = IntegerField( initial=( - instance.order - if instance - else reader_study.next_display_set_order + self.instance.order + if self.instance + else self.base_obj.next_display_set_order ) ) - def _get_image_field(self, *, interface, values, 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): - return InterfaceFormField( - instance=interface, - initial=current_value.value if current_value else None, - required=False, - user=self.user, - ).field - class DisplaySetUpdateForm(DisplaySetCreateForm): _possible_widgets = { @@ -628,22 +569,21 @@ class DisplaySetUpdateForm(DisplaySetCreateForm): *DisplaySetCreateForm._possible_widgets, } - def _get_image_field(self, *, interface, values, current_value): - return FlexibleImageField( - image_queryset=get_objects_for_user(self.user, "cases.view_image"), - upload_queryset=get_objects_for_user( - self.user, "uploads.change_userupload" - ).filter(status=UserUpload.StatusChoices.COMPLETED), - widget=FlexibleImageWidget( - user=self.user, current_value=current_value - ), - required=False, - ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.instance.is_editable: + for _, field in self.fields.items(): + field.disabled = True def _get_file_field(self, *, interface, values, current_value): - return self._get_select_upload_widget_field( - interface=interface, values=values, current_value=current_value - ) + if current_value: + return self._get_select_upload_widget_field( + interface=interface, values=values, current_value=current_value + ) + else: + return super()._get_file_field( + interface=interface, values=values, current_value=current_value + ) def _get_select_upload_widget_field( self, *, interface, values, current_value @@ -654,7 +594,7 @@ def _get_select_upload_widget_field( required=False, widget=SelectUploadWidget( attrs={ - "reader_study_slug": self.reader_study.slug, + "reader_study_slug": self.base_obj.slug, "display_set_pk": self.instance.pk, "interface_slug": interface.slug, "interface_type": interface.super_kind, @@ -673,14 +613,12 @@ class FileForm(Form): } user_upload = ModelChoiceField( - label="File", queryset=None, ) - def __init__( - self, *args, user, display_set, interface, instance=None, **kwargs - ): + def __init__(self, *args, user, interface, **kwargs): super().__init__(*args, **kwargs) + self.fields["user_upload"].label = interface.title self.fields["user_upload"].widget = UserUploadSingleWidget( allowed_file_types=interface.file_mimetypes ) @@ -689,83 +627,3 @@ def __init__( "uploads.change_userupload", ).filter(status=UserUpload.StatusChoices.COMPLETED) self.interface = interface - self.display_set = display_set - - -class DisplaySetInterfacesCreateForm(Form): - _possible_widgets = { - *InterfaceFormField._possible_widgets, - autocomplete.ModelSelect2, - Select, - } - - def __init__(self, *args, pk, interface, reader_study, user, **kwargs): - super().__init__(*args, **kwargs) - selected_interface = None - if interface: - selected_interface = ComponentInterface.objects.get(pk=interface) - data = kwargs.get("data") - if data and data.get("interface"): - selected_interface = ComponentInterface.objects.get( - pk=data["interface"] - ) - qs = ComponentInterface.objects.exclude( - slug__in=reader_study.values_for_interfaces.keys() - ) - widget_kwargs = {} - attrs = {} - if pk is None and selected_interface is not None: - widget = Select - 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" - widget_kwargs["forward"] = ["interface"] - - if pk is not None: - attrs.update( - { - "hx-get": reverse_lazy( - "reader-studies:display-set-interfaces-create", - kwargs={"pk": pk, "slug": reader_study.slug}, - ), - "hx-target": f"#ds-content-{pk}", - "hx-trigger": "interfaceSelected", - } - ) - else: - attrs.update( - { - "hx-get": reverse_lazy( - "reader-studies:display-set-new-interfaces-create", - kwargs={"slug": reader_study.slug}, - ), - "hx-target": f"#form-{kwargs['auto_id'][:-3]}", - "hx-swap": "outerHTML", - "hx-trigger": "interfaceSelected", - "disabled": selected_interface is not None, - } - ) - widget_kwargs["attrs"] = attrs - - self.fields["interface"] = ModelChoiceField( - initial=selected_interface, - queryset=qs, - widget=widget(**widget_kwargs), - ) - - if selected_interface is not None: - self.fields[selected_interface.slug] = InterfaceFormField( - instance=selected_interface, - user=user, - required=selected_interface.value_required, - ).field diff --git a/app/grandchallenge/reader_studies/models.py b/app/grandchallenge/reader_studies/models.py index 48ba0b3177..1dcff0ec5a 100644 --- a/app/grandchallenge/reader_studies/models.py +++ b/app/grandchallenge/reader_studies/models.py @@ -911,16 +911,6 @@ def standard_index(self) -> int: ] ) - @cached_property - def main_image_title(self): - try: - interface_slug = self.reader_study.view_content["main"][0] - return self.values.filter( - interface__slug=interface_slug - ).values_list("image__name", flat=True)[0] - except (KeyError, IndexError): - return self.values.values_list("image__name", flat=True).first() - class DisplaySetUserObjectPermission(UserObjectPermissionBase): content_object = models.ForeignKey(DisplaySet, on_delete=models.CASCADE) diff --git a/app/grandchallenge/reader_studies/static/reader_studies/js/autocomplete_htmx.js b/app/grandchallenge/reader_studies/static/reader_studies/js/autocomplete_htmx.js deleted file mode 100644 index 2df9ddfd4b..0000000000 --- a/app/grandchallenge/reader_studies/static/reader_studies/js/autocomplete_htmx.js +++ /dev/null @@ -1,23 +0,0 @@ -$(document).ready(() => { - $(".dal-forward-conf script").text(""); - - $(document).on('change', 'select', (e) => { - const val = yl.getValueFromField(e.currentTarget); - $(e.currentTarget).find('option').attr('selected', false); - $(e.currentTarget).find(`option[value='${val}']`).attr("selected", true); - htmx.trigger(e.currentTarget, 'interfaceSelected'); - }); - - htmx.onLoad((elem) => { - let vals = []; - $("select:disabled[name='interface'] option:selected").each((i, option) => { - vals.push($(option).val()); - }); - if (vals.length) { - vals = vals.map(val => `{"type": "const", "dst": "interface-${val}", "val": "${val}"}`); - } - vals.push(`{"type": "const", "dst": "reader-study", "val": "${$('#readerStudySlug').data('slug')}"}`); - $(".dal-forward-conf script").text(`[${vals.join(',')}]`); - }); - -}); diff --git a/app/grandchallenge/reader_studies/static/reader_studies/js/display_set_create.js b/app/grandchallenge/reader_studies/static/reader_studies/js/display_set_create.js deleted file mode 100644 index 9f23b3e1ca..0000000000 --- a/app/grandchallenge/reader_studies/static/reader_studies/js/display_set_create.js +++ /dev/null @@ -1,94 +0,0 @@ -$.fn.serializeAll = function () { - const data = $(this).serializeArray(); - - $(':disabled[name]', this).each(function () { - data.push({ name: this.name, value: $(this).val() }); - }); - - return data; -} - -$(document).ready(() => { - $(document).on('click', '.remove-form', (e) => { - $(e.currentTarget).parents("form.extra-interface-form").remove(); - }); - - htmx.onLoad((elem) => { - $('form').not($("#form-new-ds")).each((i, form) => { - const selected = $("option:selected", form); - if (selected.val()) { - $('form').not($("#form-new-ds")).each((_, _form) => { - if (_form != form) { - $(_form).find(`option[value='${selected.val()}']`).remove(); - } - }); - } - }); - }); - $('#form-new-ds').on('submit', (e) => { - e.preventDefault(); - const target = $(e.currentTarget); - $(".is-invalid").removeClass("is-invalid"); - $(".invalid-feedback").remove(); - $("#form-error-message").remove(); - const formData = {}; - const interfaces = []; - $.each($(target).serializeArray(), (i, entry) =>{ - formData[entry.name] = entry.value - }); - $(".extra-interface-form").each( - (i, interfaceForm) => { - const data = {} - $.each($(interfaceForm).serializeAll(), (i, entry) =>{ - data[entry.name] = entry.value - }); - interfaces.push(data) - } - ) - formData.new_interfaces = interfaces; - $.ajax({ - type: 'POST', - url: target.attr("action"), - data: JSON.stringify(formData), - dataType: 'json', - contentType: 'application/json', - headers: { - 'X-CSRFToken': window.drf.csrfToken, - 'Content-Type': 'application/json' - }, - success: (response) => { - window.location.href = response.redirect; - }, - error: (response) => { - let message; - if (response.status == 400) { - const errors = response.responseJSON; - for (key in errors) { - if (parseInt(key) === NaN) { - input = $(`[name='${key}']`); - formGroup = input.parents(".form-group"); - input.addClass("is-invalid"); - formGroup.append(`
${errors[key].join('; ')}
`); - - } else { - form = $(`[name='interface'] option[value='${key}']:selected`).parents("form.extra-interface-form"); - form.find("input[name='value']").addClass("is-invalid"); - form.append(`
${errors[key].join('; ')}
`); - } - } - message = 'Please correct the errors below.' - } else message = 'Unexpected error.' - $("#messages").append( - '
' + - `${message}` + - '' + - '
' - ); - $("#messages")[0].scrollIntoView(); - } - }) - }); -}); diff --git a/app/grandchallenge/reader_studies/static/reader_studies/js/images_list.mjs b/app/grandchallenge/reader_studies/static/reader_studies/js/images_list.mjs index bbc0ed1a56..68989105c0 100644 --- a/app/grandchallenge/reader_studies/static/reader_studies/js/images_list.mjs +++ b/app/grandchallenge/reader_studies/static/reader_studies/js/images_list.mjs @@ -1,9 +1,16 @@ function removeCase(event) { - const url = event.target.dataset.displaySetUrl; + const url = event.currentTarget.dataset.displaySetUrl; $('#removeCase').data('case', url); $('#removeCaseModal').modal('show'); } +$('#ajaxDataTable').on('init.dt', function() { + var removeButtons = document.querySelectorAll(".remove-display-set") + removeButtons.forEach(function(elem) { + elem.addEventListener("click", removeCase); + }); +}); + $(document).ready(() => { $('#removeCase').on('click', (e) => { $.ajax({ @@ -14,42 +21,21 @@ $(document).ready(() => { 'X-CSRFToken': window.drf.csrftoken, 'Content-Type': 'application/json' }, - complete: (response) => { - window.location.replace(window.location.href); - } - }) - }); - - $(document).on('submit', '.ds-form', (e) => { - e.preventDefault(); - const target = $(e.currentTarget); - const formData = target.serialize(); - $.ajax({ - type: 'POST', - url: target.attr("action"), - data: formData, success: (response) => { - const elem = target.data("hx-target"); - $(elem).html(response); - htmx.process(elem); - } + window.location.replace(window.location.href); + }, + error: (response) => { + $('#removeCaseModal').modal('hide'); + $("#messages").append( + '
' + + `${response.responseJSON.detail}` + + '' + + '
' + ) + }, }) }); - - // Trigger htmx ajax request here, because using hx- attributes does not work in html loaded by datatables.js - // TODO: replace datatables.js with htmx? - $(document).on('click', '.ds-htmx', (e) => { - const target = $(e.currentTarget); - if (!target.data("loaded")) { - htmx.ajax('GET', target.data("hx-get"), {target: target.data("hx-target"), swap: target.data("hx-swap")}); - target.data("loaded", true); - } - }); - - document.body.addEventListener("htmx:afterSwap", function(evt) { - // Add the removeCase function to buttons swapped in by htmx - for (let elm of evt.target.getElementsByClassName("remove-display-set")) { - elm.addEventListener("click", removeCase); - } - }); }); diff --git a/app/grandchallenge/reader_studies/templates/reader_studies/display_set_create.html b/app/grandchallenge/reader_studies/templates/reader_studies/display_set_create.html index 53678e372b..72a787f1bf 100644 --- a/app/grandchallenge/reader_studies/templates/reader_studies/display_set_create.html +++ b/app/grandchallenge/reader_studies/templates/reader_studies/display_set_create.html @@ -11,7 +11,7 @@ @@ -20,16 +20,16 @@ {% block content %}

Add {{ type_to_add }} to {{ object }}

-
+
-
+ {% csrf_token %} {{ form|crispy }} -
- +
+
- Back + Back
@@ -39,12 +39,9 @@

Add {{ type_to_add }} to {{ object }}

{% block script %} {{ block.super }} {{ form_media }} - - + + - {# TODO: This might not be used #} - {% include 'grandchallenge/partials/drf_csrf.html' %} - {% endblock %} diff --git a/app/grandchallenge/reader_studies/templates/reader_studies/display_set_detail.html b/app/grandchallenge/reader_studies/templates/reader_studies/display_set_detail.html deleted file mode 100644 index 116864c956..0000000000 --- a/app/grandchallenge/reader_studies/templates/reader_studies/display_set_detail.html +++ /dev/null @@ -1,54 +0,0 @@ -{% load humanize %} -{% load profiles %} -{% load workstations %} -{% load pathlib %} -{% load reader_study_tags %} -{% load bleach %} -{% load crispy_forms_tags %} -{% load meta_attr %} - -
- - {% include "grandchallenge/partials/messages.html" %} - - - - - - - {% for value in object.values.all %} - - - - - {% endfor %} - - - - - - - - -
InterfaceName
{{ value.interface.slug }}:{{ value.title }}
Order:{{ object.order }}
Description:{{ object.description|clean }}
-
- - -
-
diff --git a/app/grandchallenge/reader_studies/templates/reader_studies/display_set_files_update.html b/app/grandchallenge/reader_studies/templates/reader_studies/display_set_files_update.html index 428b3f083b..cdb1c0baf8 100644 --- a/app/grandchallenge/reader_studies/templates/reader_studies/display_set_files_update.html +++ b/app/grandchallenge/reader_studies/templates/reader_studies/display_set_files_update.html @@ -1,13 +1,10 @@ {% load crispy_forms_tags %} - -
- - {% csrf_token %} - {{ form|crispy }} -
- + + {% csrf_token %} + {{ form|crispy }} +
+
- -
+ diff --git a/app/grandchallenge/reader_studies/templates/reader_studies/display_set_interface_create.html b/app/grandchallenge/reader_studies/templates/reader_studies/display_set_interface_create.html deleted file mode 100644 index be61261e0f..0000000000 --- a/app/grandchallenge/reader_studies/templates/reader_studies/display_set_interface_create.html +++ /dev/null @@ -1,18 +0,0 @@ -{% load crispy_forms_tags %} -{% load static %} - -
-
- {% csrf_token %} - {{ form|crispy }} -
-
-
- - -
-
-
-
- - diff --git a/app/grandchallenge/reader_studies/templates/reader_studies/readerstudy_images_list.html b/app/grandchallenge/reader_studies/templates/reader_studies/display_set_list.html similarity index 83% rename from app/grandchallenge/reader_studies/templates/reader_studies/readerstudy_images_list.html rename to app/grandchallenge/reader_studies/templates/reader_studies/display_set_list.html index 030e818609..4fef28434e 100644 --- a/app/grandchallenge/reader_studies/templates/reader_studies/readerstudy_images_list.html +++ b/app/grandchallenge/reader_studies/templates/reader_studies/display_set_list.html @@ -11,22 +11,22 @@ {% endblock %} {% block content %} -

{{ reader_study.title }} Cases

+

{{ reader_study.title }} Display Sets

Add Cases (batch) + href="{% url 'reader-studies:display-set-create' slug=reader_study.slug %}" + > Add Display Set (single) Add Case (single) + href="{% url 'reader-studies:display-sets-create' slug=reader_study.slug %}" + > Add Image-Only Display Sets (batch)

{{ block.super }} -
+