Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 23 additions & 16 deletions hypha/apply/categories/blocks.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django import forms
from django.db.models import BLANK_CHOICE_DASH
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from wagtail.blocks import (
Expand All @@ -9,8 +10,13 @@
)
from wagtail.coreutils import resolve_model_string

from hypha.apply.funds.widgets import ChoicesSelectMultipleWidget, ChoicesSelectWidget
from hypha.apply.stream_forms.blocks import OptionalFormFieldBlock

# At this many options a radio/checkbox list stops being usable, so fall back
# to a searchable Choices.js select.
SEARCHABLE_SELECT_THRESHOLD = 32


class ModelChooserBlock(ChoiceBlock):
# Implement this block as it's referenced in the old migrations.
Expand Down Expand Up @@ -61,28 +67,29 @@ def use_defaults_from_category(self, kwargs, category):

return kwargs

def widget_for_choices(self, struct_value, choices):
# Pick widget according to number of options to maintain good usability.
many_options = len(choices) >= SEARCHABLE_SELECT_THRESHOLD
if struct_value["multi"]:
return (
ChoicesSelectMultipleWidget
if many_options
else forms.CheckboxSelectMultiple
)
return ChoicesSelectWidget if many_options else forms.RadioSelect

def get_field_kwargs(self, struct_value):
kwargs = super().get_field_kwargs(struct_value)
category = self.get_instance(id=struct_value["category"])
kwargs = self.use_defaults_from_category(kwargs, category)
choices = category.options.values_list("id", "value")
kwargs.update({"choices": choices})
choices = list(category.options.values_list("id", "value"))
widget = self.widget_for_choices(struct_value, choices)
if widget is ChoicesSelectWidget:
# A <select> auto-selects its first option, so offer an empty one.
choices.insert(0, BLANK_CHOICE_DASH[0])
kwargs.update({"choices": choices, "widget": widget})
return kwargs

def get_widget(self, struct_value):
if struct_value["multi"]:
category = self.get_instance(id=struct_value["category"])
category_size = category.options.count()
# Pick widget according to number of options to maintain good usability.
if category_size < 32:
return forms.CheckboxSelectMultiple
else:
from hypha.apply.funds.tables import MultiCheckboxesWidget

return MultiCheckboxesWidget
else:
return forms.RadioSelect

def prepare_data(self, value, data, serialize):
if not data:
return data
Expand Down
33 changes: 32 additions & 1 deletion hypha/apply/categories/tests/test_blocks.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from django import forms
from django.db.models import BLANK_CHOICE_DASH
from django.test import TestCase

from hypha.apply.categories.blocks import CategoryQuestionBlock
from hypha.apply.categories.blocks import (
SEARCHABLE_SELECT_THRESHOLD,
CategoryQuestionBlock,
)
from hypha.apply.funds.widgets import ChoicesSelectMultipleWidget, ChoicesSelectWidget

from .factories import CategoryFactory, OptionFactory

Expand Down Expand Up @@ -57,3 +62,29 @@ def test_options_included_in_choices(self):
def test_can_render_if_no_response(self):
display = self.block.render({"category": self.category}, {"data": None})
self.assertIn(self.block.no_response()[0], display)

def test_few_options_use_radio_and_checkbox_widgets(self):
OptionFactory.create_batch(
SEARCHABLE_SELECT_THRESHOLD - 1, category=self.category
)
self.assertIsInstance(self.get_field(multi=False).widget, forms.RadioSelect)
self.assertIsInstance(
self.get_field(multi=True).widget, forms.CheckboxSelectMultiple
)

def test_many_options_use_choices_js_select_widgets(self):
OptionFactory.create_batch(SEARCHABLE_SELECT_THRESHOLD, category=self.category)
self.assertIsInstance(self.get_field(multi=False).widget, ChoicesSelectWidget)
self.assertIsInstance(
self.get_field(multi=True).widget, ChoicesSelectMultipleWidget
)

def test_select_widget_offers_an_empty_choice(self):
# A <select> auto-selects its first option, so a single select must be
# able to start out unanswered.
OptionFactory.create_batch(SEARCHABLE_SELECT_THRESHOLD, category=self.category)

field = self.get_field(multi=False)

self.assertEqual(field.choices[0], BLANK_CHOICE_DASH[0])
self.assertNotIn(BLANK_CHOICE_DASH[0], self.get_field(multi=True).choices)
6 changes: 3 additions & 3 deletions hypha/apply/funds/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from .models.co_applicants import CoApplicantProjectPermission, CoApplicantRole
from .permissions import can_change_external_reviewers
from .utils import model_form_initial, render_icon
from .widgets import MultiCheckboxesWidget
from .widgets import ChoicesSelectMultipleWidget


class ApplicationSubmissionModelForm(forms.ModelForm):
Expand Down Expand Up @@ -239,7 +239,7 @@ class BatchUpdateReviewersForm(forms.Form):
)
external_reviewers = forms.ModelMultipleChoiceField(
queryset=User.objects.reviewers().only("pk", "full_name"),
widget=MultiCheckboxesWidget(attrs={"data-placeholder": _("Select...")}),
widget=ChoicesSelectMultipleWidget(attrs={"data-placeholder": _("Select...")}),
label=_("External Reviewers"),
required=False,
)
Expand Down Expand Up @@ -386,7 +386,7 @@ def __init__(self, *args, choices_groupby, **kwargs):
class UpdateMetaTermsForm(ApplicationSubmissionModelForm):
meta_terms = GroupedModelMultipleChoiceField(
queryset=None, # updated in init method
widget=MultiCheckboxesWidget(attrs={"data-placeholder": _("Select...")}),
widget=ChoicesSelectMultipleWidget(attrs={"data-placeholder": _("Select...")}),
label=_("Tags"),
choices_groupby="get_parent",
required=False,
Expand Down
4 changes: 2 additions & 2 deletions hypha/apply/funds/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from hypha.core.tables import RelativeTimeColumn

from .models import ApplicationSubmission, Round, ScreeningStatus
from .widgets import MultiCheckboxesWidget
from .widgets import ChoicesSelectMultipleWidget
from .workflows import STATUS_SLUGS, STATUSES

User = get_user_model()
Expand Down Expand Up @@ -192,7 +192,7 @@ class MultiCheckboxesMixin(filters.Filter):
def __init__(self, *args, **kwargs):
label = kwargs.get("label")
kwargs.setdefault(
"widget", MultiCheckboxesWidget(attrs={"data-placeholder": label})
"widget", ChoicesSelectMultipleWidget(attrs={"data-placeholder": label})
)
super().__init__(*args, **kwargs)

Expand Down
27 changes: 20 additions & 7 deletions hypha/apply/funds/widgets.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
from django import forms


class MultiCheckboxesWidget(forms.SelectMultiple):
class ChoicesJSMixin:
"""
Custom widget for Choices.js. Adds the required attributes.
Adds the attributes required to initialise Choices.js on a select.
"""

def __init__(self, *args, **kwargs):
attrs = kwargs.get("attrs", {})
# Add the date attribute for Choices.js initialization
def __init__(self, attrs=None, *args, **kwargs):
attrs = dict(attrs) if attrs else {}
# Add the data attributes for Choices.js initialization
attrs.setdefault("data-js-choices", "")
attrs.setdefault("data-placeholder", "")
kwargs["attrs"] = attrs
super().__init__(*args, **kwargs)
super().__init__(attrs, *args, **kwargs)

def use_required_attribute(self, initial):
# Choices.js conceals the underlying <select>, and browsers refuse to
# submit a form with a hidden required control, without showing the user
# an error. Required is still enforced server side by the form field.
return False


class ChoicesSelectWidget(ChoicesJSMixin, forms.Select):
pass


class ChoicesSelectMultipleWidget(ChoicesJSMixin, forms.SelectMultiple):
pass
4 changes: 2 additions & 2 deletions hypha/apply/projects/forms/invoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from django.utils.translation import gettext_lazy as _
from django_file_form.forms import FileFormMixin

from hypha.apply.funds.widgets import MultiCheckboxesWidget
from hypha.apply.funds.widgets import ChoicesSelectMultipleWidget
from hypha.apply.stream_forms.fields import MultiFileField, SingleFileField

from ..models.invoice import (
Expand Down Expand Up @@ -233,7 +233,7 @@ def clean_invoices(self):
class InvoiceTagsForm(forms.ModelForm):
tags = forms.ModelMultipleChoiceField(
queryset=InvoiceTag.objects.all(),
widget=MultiCheckboxesWidget,
widget=ChoicesSelectMultipleWidget,
required=False,
label=_("Tags"),
)
Expand Down