From afd7f5e8583b2e8ef261d6f07c711bc5c0bbc5dd Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Tue, 1 Sep 2026 18:32:29 +0200 Subject: [PATCH 1/5] Make category question widget render select input if more than 32 options for both single and multiselect fields. --- hypha/apply/categories/blocks.py | 31 ++++++++++++++++----------- hypha/apply/funds/forms.py | 6 +++--- hypha/apply/funds/tables.py | 4 ++-- hypha/apply/funds/widgets.py | 14 +++++++++--- hypha/apply/projects/forms/invoice.py | 4 ++-- 5 files changed, 37 insertions(+), 22 deletions(-) diff --git a/hypha/apply/categories/blocks.py b/hypha/apply/categories/blocks.py index 9eb4731505..9f9c1a05cb 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 +# Above 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. @@ -65,23 +71,24 @@ 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") + choices = list(category.options.values_list("id", "value")) + if not struct_value["multi"] and len(choices) >= SEARCHABLE_SELECT_THRESHOLD: + # A auto-selects its first option, so offer an empty one. choices.insert(0, BLANK_CHOICE_DASH[0]) - kwargs.update({"choices": choices}) + kwargs.update({"choices": choices, "widget": widget}) return kwargs def get_widget(self, struct_value): - category = self.get_instance(id=struct_value["category"]) - # Pick widget according to number of options to maintain good usability. - many_options = category.options.count() >= SEARCHABLE_SELECT_THRESHOLD - if struct_value["multi"]: - return ( - ChoicesSelectMultipleWidget - if many_options - else forms.CheckboxSelectMultiple - ) - return ChoicesSelectWidget if many_options else forms.RadioSelect + # The widget depends on the number of options, which `get_field_kwargs` + # already has to hand. Let it set the widget rather than query again. + return None def prepare_data(self, value, data, serialize): if not data: diff --git a/hypha/apply/funds/models/submissions.py b/hypha/apply/funds/models/submissions.py index 8cf67a6a65..a3bc515e4d 100644 --- a/hypha/apply/funds/models/submissions.py +++ b/hypha/apply/funds/models/submissions.py @@ -1225,6 +1225,9 @@ def from_submission( if isinstance(field.block, CategoryQuestionBlock) and ( category_option_ids := submission.form_data.get(field.id) ): + # Single select answers are stored as a bare id, not a list. + if isinstance(category_option_ids, str): + category_option_ids = [category_option_ids] anonymized.selected_category_options.add( *Option.objects.filter(id__in=category_option_ids) ) diff --git a/hypha/apply/funds/widgets.py b/hypha/apply/funds/widgets.py index 7d0540ee65..1c3bf1bc08 100644 --- a/hypha/apply/funds/widgets.py +++ b/hypha/apply/funds/widgets.py @@ -6,13 +6,18 @@ class ChoicesJSMixin: Adds the attributes required to initialise Choices.js on a select. """ - def __init__(self, *args, **kwargs): - attrs = kwargs.get("attrs", {}) + 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 , so mark up the box it renders instead. + // stylelint-disable-next-line selector-class-pattern + .choices__inner { + border-color: var(--color-error); + } + + // Radio and checkbox groups have no single element to outline, so flag the + // error on their legend. + legend { + color: var(--color-error); + } } &__error-text {