diff --git a/hypha/apply/categories/blocks.py b/hypha/apply/categories/blocks.py index 9eb4731505..4140866a0c 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. @@ -61,27 +67,33 @@ 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 , 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 diff --git a/hypha/apply/projects/forms/invoice.py b/hypha/apply/projects/forms/invoice.py index 872c12e62e..8c93cb4fd1 100644 --- a/hypha/apply/projects/forms/invoice.py +++ b/hypha/apply/projects/forms/invoice.py @@ -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 ( @@ -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"), ) diff --git a/hypha/static_src/javascript/form-group-toggle.js b/hypha/static_src/javascript/form-group-toggle.js index bc9edd6c84..3ae297839f 100644 --- a/hypha/static_src/javascript/form-group-toggle.js +++ b/hypha/static_src/javascript/form-group-toggle.js @@ -16,6 +16,16 @@ return wrapper; } + /** + * Fields that can carry the `required` attribute. + * + * Choices.js controls are skipped: it conceals the original select and adds + * an always empty search input, either of which would block submission if + * marked required. Those fields are enforced server side instead. + */ + const REQUIRABLE_FIELDS = + "input:not([type='hidden']):not(.choices__input), select:not([data-choice]), textarea"; + /** * Add or remove `required` on form inputs within a group element. * Uses `data-required="True"` on each fieldset to know which fields @@ -26,11 +36,9 @@ wrapper .querySelectorAll("[data-required='True']") .forEach(function (fieldset) { - fieldset - .querySelectorAll("input:not([type='hidden']), select, textarea") - .forEach(function (el) { - el.setAttribute("required", ""); - }); + fieldset.querySelectorAll(REQUIRABLE_FIELDS).forEach(function (el) { + el.setAttribute("required", ""); + }); const labelSpan = fieldset.querySelector(".form__question span"); if (labelSpan && !labelSpan.querySelector("sup")) { const sup = document.createElement("sup"); @@ -39,11 +47,9 @@ } }); } else { - wrapper - .querySelectorAll("input:not([type='hidden']), select, textarea") - .forEach(function (el) { - el.removeAttribute("required"); - }); + wrapper.querySelectorAll(REQUIRABLE_FIELDS).forEach(function (el) { + el.removeAttribute("required"); + }); wrapper .querySelectorAll(".form__question span sup") .forEach(function (sup) { diff --git a/hypha/static_src/javascript/submission-form-copy.js b/hypha/static_src/javascript/submission-form-copy.js index 5ba159f85f..d0d7ddd0b8 100644 --- a/hypha/static_src/javascript/submission-form-copy.js +++ b/hypha/static_src/javascript/submission-form-copy.js @@ -49,6 +49,9 @@ } const listItems = el.querySelectorAll(".form__list > li"); + // Read the select before any input: Choices.js conceals the select + // and adds an always empty search input in front of it. + const selectEl = el.querySelector("select"); const inputEl = el.querySelector("input"); const richTextEl = el.querySelector(".tinymce4-editor"); @@ -60,6 +63,17 @@ return text; }); questionText += "\n\n" + itemTexts.join("\n"); + } else if (selectEl) { + const selected = Array.from(selectEl.selectedOptions) + .filter(function (option) { + return option.value; + }) + .map(function (option) { + return option.textContent.trim(); + }); + if (selected.length) { + questionText += "\n\n" + selected.join("\n"); + } } else if (inputEl && inputEl.value) { questionText += "\n\n" + strip(inputEl.value); } else if (richTextEl && richTextEl.value) { diff --git a/hypha/static_src/sass/components/_form.scss b/hypha/static_src/sass/components/_form.scss index aff352e2c6..ddebf5959a 100644 --- a/hypha/static_src/sass/components/_form.scss +++ b/hypha/static_src/sass/components/_form.scss @@ -78,6 +78,18 @@ input[type="datetime-local"] { --input-color: var(--color-error); } + + // Choices.js conceals the