diff --git a/hypha/apply/categories/blocks.py b/hypha/apply/categories/blocks.py index 9eb4731505..a6c0e25a80 100644 --- a/hypha/apply/categories/blocks.py +++ b/hypha/apply/categories/blocks.py @@ -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 ( @@ -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. @@ -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 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) diff --git a/hypha/apply/funds/forms.py b/hypha/apply/funds/forms.py index 6fbb2f3701..3e4d26e2bc 100644 --- a/hypha/apply/funds/forms.py +++ b/hypha/apply/funds/forms.py @@ -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): @@ -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, ) @@ -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, diff --git a/hypha/apply/funds/tables.py b/hypha/apply/funds/tables.py index d189e3d58c..19ac3452d1 100644 --- a/hypha/apply/funds/tables.py +++ b/hypha/apply/funds/tables.py @@ -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() @@ -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) diff --git a/hypha/apply/funds/widgets.py b/hypha/apply/funds/widgets.py index 4fbe82f358..1c3bf1bc08 100644 --- a/hypha/apply/funds/widgets.py +++ b/hypha/apply/funds/widgets.py @@ -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