mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-23 15:57:23 -04:00
Merge pull request #3535 from alphagov/try-introducing-govuk-checkboxes-again
Try introducing govuk checkboxes again
This commit is contained in:
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
|
||||
from itertools import chain
|
||||
|
||||
import pytz
|
||||
from flask import request
|
||||
from flask import Markup, render_template, request
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm as Form
|
||||
from flask_wtf.file import FileAllowed
|
||||
@@ -17,6 +17,7 @@ from notifications_utils.recipients import (
|
||||
normalise_phone_number,
|
||||
validate_phone_number,
|
||||
)
|
||||
from werkzeug.utils import cached_property
|
||||
from wtforms import (
|
||||
BooleanField,
|
||||
DateField,
|
||||
@@ -37,7 +38,6 @@ from wtforms import (
|
||||
)
|
||||
from wtforms.fields.html5 import EmailField, SearchField, TelField
|
||||
from wtforms.validators import URL, DataRequired, Length, Optional, Regexp
|
||||
from wtforms.widgets import CheckboxInput, ListWidget
|
||||
|
||||
from app import format_thousands
|
||||
from app.main.validators import (
|
||||
@@ -117,11 +117,6 @@ def get_next_days_until(until):
|
||||
]
|
||||
|
||||
|
||||
class MultiCheckboxField(SelectMultipleField):
|
||||
widget = ListWidget(prefix_label=False)
|
||||
option_widget = CheckboxInput()
|
||||
|
||||
|
||||
class RadioField(WTFormsRadioField):
|
||||
|
||||
def __init__(
|
||||
@@ -322,13 +317,16 @@ class RadioFieldWithNoneOption(FieldWithNoneOption, RadioField):
|
||||
|
||||
|
||||
class NestedFieldMixin:
|
||||
|
||||
def children(self):
|
||||
|
||||
# start map with root option as a single child entry
|
||||
child_map = {None: [option for option in self
|
||||
if option.data == self.NONE_OPTION_VALUE]}
|
||||
|
||||
# add entries for all other children
|
||||
for option in self:
|
||||
# assign all options with a NONE_OPTION_VALUE (not always None) to the None key
|
||||
if option.data == self.NONE_OPTION_VALUE:
|
||||
child_ids = [
|
||||
folder['id'] for folder in self.all_template_folders
|
||||
@@ -344,6 +342,47 @@ class NestedFieldMixin:
|
||||
|
||||
return child_map
|
||||
|
||||
# to be used as the only version of .children once radios are converted
|
||||
@cached_property
|
||||
def _children(self):
|
||||
return self.children()
|
||||
|
||||
def get_items_from_options(self, field):
|
||||
items = []
|
||||
|
||||
for option in self._children[None]:
|
||||
item = self.get_item_from_option(option)
|
||||
if option.data in self._children:
|
||||
item['children'] = self.render_children(field.name, option.label.text, self._children[option.data])
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
def render_children(self, name, label, options):
|
||||
params = {
|
||||
"name": name,
|
||||
"fieldset": {
|
||||
"legend": {
|
||||
"text": label,
|
||||
"classes": "govuk-visually-hidden"
|
||||
}
|
||||
},
|
||||
"formGroup": {
|
||||
"classes": "govuk-form-group--nested"
|
||||
},
|
||||
"asList": True,
|
||||
"items": []
|
||||
}
|
||||
for option in options:
|
||||
item = self.get_item_from_option(option)
|
||||
|
||||
if len(self._children[option.data]):
|
||||
item['children'] = self.render_children(name, option.label.text, self._children[option.data])
|
||||
|
||||
params['items'].append(item)
|
||||
|
||||
return render_template('forms/fields/checkboxes/template.njk', params=params)
|
||||
|
||||
|
||||
class NestedRadioField(RadioFieldWithNoneOption, NestedFieldMixin):
|
||||
pass
|
||||
@@ -492,11 +531,239 @@ class RegisterUserFromOrgInviteForm(StripWhitespaceForm):
|
||||
auth_type = HiddenField('auth_type', validators=[DataRequired()])
|
||||
|
||||
|
||||
class govukCheckboxesMixin:
|
||||
|
||||
def extend_params(self, params, extensions):
|
||||
items = None
|
||||
param_items = len(params['items']) if 'items' in params else 0
|
||||
|
||||
# split items off from params to make it a pure dict
|
||||
if 'items' in extensions:
|
||||
items = extensions['items']
|
||||
del extensions['items']
|
||||
|
||||
# merge dicts
|
||||
params.update(extensions)
|
||||
|
||||
# merge items
|
||||
if items:
|
||||
if 'items' not in params:
|
||||
params['items'] = items
|
||||
else:
|
||||
for idx, _item in enumerate(items):
|
||||
if idx >= param_items:
|
||||
params['items'].append(items[idx])
|
||||
else:
|
||||
params['items'][idx].update(items[idx])
|
||||
|
||||
|
||||
class govukCheckboxField(govukCheckboxesMixin, BooleanField):
|
||||
|
||||
def __init__(self, label='', validators=None, param_extensions=None, **kwargs):
|
||||
super(govukCheckboxField, self).__init__(label, validators, false_values=None, **kwargs)
|
||||
self.param_extensions = param_extensions
|
||||
|
||||
# self.__call__ renders the HTML for the field by:
|
||||
# 1. delegating to self.meta.render_field which
|
||||
# 2. calls field.widget
|
||||
# this bypasses that by making self.widget a method with the same interface as widget.__call__
|
||||
def widget(self, field, param_extensions=None, **kwargs):
|
||||
|
||||
# error messages
|
||||
error_message = None
|
||||
if field.errors:
|
||||
error_message = {
|
||||
"attributes": {
|
||||
"data-module": "track-error",
|
||||
"data-error-type": field.errors[0],
|
||||
"data-error-label": field.name
|
||||
},
|
||||
"text": " ".join(field.errors).strip()
|
||||
}
|
||||
|
||||
params = {
|
||||
'name': field.name,
|
||||
'errorMessage': error_message,
|
||||
'items': [
|
||||
{
|
||||
"name": field.name,
|
||||
"id": field.id,
|
||||
"text": field.label.text,
|
||||
"value": 'y',
|
||||
"checked": field.data
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
# extend default params with any sent in during instantiation
|
||||
if self.param_extensions:
|
||||
self.extend_params(params, self.param_extensions)
|
||||
|
||||
# add any sent in though use in templates
|
||||
if param_extensions:
|
||||
self.extend_params(params, param_extensions)
|
||||
|
||||
return Markup(
|
||||
render_template('forms/fields/checkboxes/macro.njk', params=params))
|
||||
|
||||
|
||||
# based on work done by @richardjpope: https://github.com/richardjpope/recourse/blob/master/recourse/forms.py#L6
|
||||
class govukCheckboxesField(govukCheckboxesMixin, SelectMultipleField):
|
||||
|
||||
render_as_list = False
|
||||
|
||||
def __init__(self, label='', validators=None, param_extensions=None, **kwargs):
|
||||
super(govukCheckboxesField, self).__init__(label, validators, **kwargs)
|
||||
self.param_extensions = param_extensions
|
||||
|
||||
def get_item_from_option(self, option):
|
||||
return {
|
||||
"name": option.name,
|
||||
"id": option.id,
|
||||
"text": option.label.text,
|
||||
"value": str(option.data), # to protect against non-string types like uuids
|
||||
"checked": option.checked
|
||||
}
|
||||
|
||||
def get_items_from_options(self, field):
|
||||
return [self.get_item_from_option(option) for option in field]
|
||||
|
||||
def extend_params(self, params, extensions):
|
||||
items = None
|
||||
param_items = len(params['items']) if 'items' in params else 0
|
||||
|
||||
# split items off from params to make it a pure dict
|
||||
if 'items' in extensions:
|
||||
items = extensions['items']
|
||||
del extensions['items']
|
||||
|
||||
# merge dicts
|
||||
params.update(extensions)
|
||||
|
||||
# merge items
|
||||
if items:
|
||||
if 'items' not in params:
|
||||
params['items'] = items
|
||||
else:
|
||||
for idx, _item in enumerate(items):
|
||||
if idx >= param_items:
|
||||
params['items'].append(items[idx])
|
||||
else:
|
||||
params['items'][idx].update(items[idx])
|
||||
|
||||
# self.__call__ renders the HTML for the field by:
|
||||
# 1. delegating to self.meta.render_field which
|
||||
# 2. calls field.widget
|
||||
# this bypasses that by making self.widget a method with the same interface as widget.__call__
|
||||
def widget(self, field, param_extensions=None, **kwargs):
|
||||
|
||||
# error messages
|
||||
error_message = None
|
||||
if field.errors:
|
||||
error_message = {
|
||||
"attributes": {
|
||||
"data-module": "track-error",
|
||||
"data-error-type": field.errors[0],
|
||||
"data-error-label": field.name
|
||||
},
|
||||
"text": " ".join(field.errors).strip()
|
||||
}
|
||||
|
||||
# returns either a list or a hierarchy of lists
|
||||
# depending on how get_items_from_options is implemented
|
||||
items = self.get_items_from_options(field)
|
||||
|
||||
params = {
|
||||
'name': field.name,
|
||||
"fieldset": {
|
||||
"attributes": {"id": field.name},
|
||||
"legend": {
|
||||
"text": field.label.text,
|
||||
"classes": "govuk-fieldset__legend--s"
|
||||
}
|
||||
},
|
||||
"asList": self.render_as_list,
|
||||
'errorMessage': error_message,
|
||||
'items': items
|
||||
}
|
||||
|
||||
# extend default params with any sent in during instantiation
|
||||
if self.param_extensions:
|
||||
self.extend_params(params, self.param_extensions)
|
||||
|
||||
# add any sent in though use in templates
|
||||
if param_extensions:
|
||||
self.extend_params(params, param_extensions)
|
||||
|
||||
return Markup(
|
||||
render_template('forms/fields/checkboxes/macro.njk', params=params))
|
||||
|
||||
|
||||
# Extends fields using the govukCheckboxesField interface to wrap their render in HTML needed by the collapsible JS
|
||||
class govukCollapsibleCheckboxesMixin:
|
||||
def __init__(self, label='', validators=None, field_label='', param_extensions=None, **kwargs):
|
||||
|
||||
super(govukCollapsibleCheckboxesMixin, self).__init__(label, validators, param_extensions, **kwargs)
|
||||
self.field_label = field_label
|
||||
|
||||
def widget(self, field, **kwargs):
|
||||
|
||||
# add a blank hint to act as an ARIA live-region
|
||||
if self.param_extensions is not None:
|
||||
self.param_extensions.update(
|
||||
{"hint": {"html": "<div class=\"selection-summary\" role=\"region\" aria-live=\"polite\"></div>"}})
|
||||
else:
|
||||
self.param_extensions = \
|
||||
{"hint": {"html": "<div class=\"selection-summary\" role=\"region\" aria-live=\"polite\"></div>"}}
|
||||
|
||||
# wrap the checkboxes HTML in the HTML needed by the collapisble JS
|
||||
return Markup(
|
||||
f'<div class="selection-wrapper"'
|
||||
f' data-module="collapsible-checkboxes"'
|
||||
f' data-field-label="{self.field_label}">'
|
||||
f' {super(govukCollapsibleCheckboxesMixin, self).widget(field, **kwargs)}'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
|
||||
class govukCollapsibleCheckboxesField(govukCollapsibleCheckboxesMixin, govukCheckboxesField):
|
||||
pass
|
||||
|
||||
|
||||
# govukCollapsibleCheckboxesMixin adds an ARIA live-region to the hint and wraps the render in HTML needed by the
|
||||
# collapsible JS
|
||||
# NestedFieldMixin puts the items into a tree hierarchy, pre-rendering the sub-trees of the top-level items
|
||||
class govukCollapsibleNestedCheckboxesField(govukCollapsibleCheckboxesMixin, NestedFieldMixin, govukCheckboxesField):
|
||||
NONE_OPTION_VALUE = None
|
||||
render_as_list = True
|
||||
|
||||
|
||||
# guard against data entries that aren't a role in permissions
|
||||
def filter_by_permissions(valuelist):
|
||||
if valuelist is None:
|
||||
return None
|
||||
else:
|
||||
return [entry for entry in valuelist if any(entry in role for role in permissions)]
|
||||
|
||||
|
||||
# guard against data entries that aren't a role in broadcast_permissions
|
||||
def filter_by_broadcast_permissions(valuelist):
|
||||
if valuelist is None:
|
||||
return None
|
||||
else:
|
||||
return [entry for entry in valuelist if any(entry in role for role in broadcast_permissions)]
|
||||
|
||||
|
||||
# Included to support both versions of how user permissions are handled in permissions forms
|
||||
# Remove when changeover to new version (permissions_field) is complete
|
||||
PermissionsAbstract = type("PermissionsAbstract", (StripWhitespaceForm,), {
|
||||
permission: BooleanField(label) for permission, label in permissions
|
||||
})
|
||||
|
||||
|
||||
# Included to support both versions of how user permissions are handled in permissions forms
|
||||
# Remove when changeover to new version (permissions_field) is complete
|
||||
BroadcastPermissionsAbstract = type("BroadcastPermissionsAbstract", (StripWhitespaceForm,), {
|
||||
permission: BooleanField(label) for permission, label in broadcast_permissions
|
||||
})
|
||||
@@ -512,7 +779,9 @@ class BasePermissionsForm(StripWhitespaceForm):
|
||||
(item['id'], item['name']) for item in ([{'name': 'Templates', 'id': None}] + all_template_folders)
|
||||
]
|
||||
|
||||
folder_permissions = NestedCheckboxesField('Folders this team member can see')
|
||||
folder_permissions = govukCollapsibleNestedCheckboxesField(
|
||||
'Folders this team member can see',
|
||||
field_label='folder')
|
||||
|
||||
login_authentication = RadioField(
|
||||
'Sign in using',
|
||||
@@ -524,10 +793,30 @@ class BasePermissionsForm(StripWhitespaceForm):
|
||||
validators=[DataRequired()]
|
||||
)
|
||||
|
||||
permissions_field = govukCheckboxesField(
|
||||
'Permssions',
|
||||
filters=[filter_by_permissions],
|
||||
choices=[
|
||||
(value, label) for value, label in permissions
|
||||
],
|
||||
param_extensions={
|
||||
"hint": {"text": "All team members can see sent messages."}
|
||||
}
|
||||
)
|
||||
|
||||
# Modified to support both versions of how user permissions are handled in permissions forms
|
||||
# Remove when changeover to new version (permissions_field) is complete
|
||||
@property
|
||||
def permissions(self):
|
||||
return {field.id for field in self.permissions_fields if field.data is True}
|
||||
permissions_field_data = set(self.permissions_field.data)
|
||||
permissions_fields_data = {field.id for field in self.permissions_fields if field.data is True}
|
||||
if len(permissions_field_data) == 0 and len(permissions_fields_data) != 0:
|
||||
return permissions_fields_data
|
||||
else:
|
||||
return permissions_field_data
|
||||
|
||||
# Included to support both versions of how user permissions are handled in permissions forms
|
||||
# Remove when changeover to new version (permissions_field) is complete
|
||||
@property
|
||||
def permissions_fields(self):
|
||||
return (
|
||||
@@ -540,19 +829,34 @@ class BasePermissionsForm(StripWhitespaceForm):
|
||||
return cls(
|
||||
**kwargs,
|
||||
**{
|
||||
role: user.has_permission_for_service(service_id, role)
|
||||
for role in roles.keys()
|
||||
"permissions_field": [
|
||||
role for role in roles.keys() if user.has_permission_for_service(service_id, role)]
|
||||
},
|
||||
login_authentication=user.auth_type
|
||||
)
|
||||
|
||||
|
||||
# Included to support both versions of how user permissions are handled in permissions forms
|
||||
# Remove when changeover to new version (permissions_field) is complete
|
||||
class PermissionsForm(PermissionsAbstract, BasePermissionsForm):
|
||||
pass
|
||||
|
||||
|
||||
# Included to support both versions of how user permissions are handled in permissions forms
|
||||
# Remove when changeover to new version (permissions_field) is complete
|
||||
class BroadcastPermissionsForm(BroadcastPermissionsAbstract, BasePermissionsForm):
|
||||
|
||||
permissions_field = govukCheckboxesField(
|
||||
'Permssions',
|
||||
choices=[
|
||||
(value, label) for value, label in broadcast_permissions
|
||||
],
|
||||
filters=[filter_by_broadcast_permissions],
|
||||
param_extensions={
|
||||
"hint": {"text": "All team members can see sent messages."}
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def permissions(self):
|
||||
return {'view_activity'} | super().permissions
|
||||
@@ -1155,7 +1459,7 @@ class ServiceContactDetailsForm(StripWhitespaceForm):
|
||||
|
||||
class ServiceReplyToEmailForm(StripWhitespaceForm):
|
||||
email_address = email_address(label='Reply-to email address', gov_user=False)
|
||||
is_default = BooleanField("Make this email address the default")
|
||||
is_default = govukCheckboxField("Make this email address the default")
|
||||
|
||||
|
||||
class ServiceSmsSenderForm(StripWhitespaceForm):
|
||||
@@ -1169,11 +1473,11 @@ class ServiceSmsSenderForm(StripWhitespaceForm):
|
||||
DoesNotStartWithDoubleZero(),
|
||||
]
|
||||
)
|
||||
is_default = BooleanField("Make this text message sender the default")
|
||||
is_default = govukCheckboxField("Make this text message sender the default")
|
||||
|
||||
|
||||
class ServiceEditInboundNumberForm(StripWhitespaceForm):
|
||||
is_default = BooleanField("Make this text message sender the default")
|
||||
is_default = govukCheckboxField("Make this text message sender the default")
|
||||
|
||||
|
||||
class ServiceLetterContactBlockForm(StripWhitespaceForm):
|
||||
@@ -1183,7 +1487,7 @@ class ServiceLetterContactBlockForm(StripWhitespaceForm):
|
||||
NoCommasInPlaceHolders()
|
||||
]
|
||||
)
|
||||
is_default = BooleanField("Set as your default address")
|
||||
is_default = govukCheckboxField("Set as your default address")
|
||||
|
||||
def validate_letter_contact_block(self, field):
|
||||
line_count = field.data.strip().count('\n')
|
||||
@@ -1349,7 +1653,7 @@ class GuestList(StripWhitespaceForm):
|
||||
class DateFilterForm(StripWhitespaceForm):
|
||||
start_date = DateField("Start Date", [validators.optional()])
|
||||
end_date = DateField("End Date", [validators.optional()])
|
||||
include_from_test_key = BooleanField("Include test keys", default="checked", false_values={"N"})
|
||||
include_from_test_key = govukCheckboxField("Include test keys")
|
||||
|
||||
|
||||
class RequiredDateFilterForm(StripWhitespaceForm):
|
||||
@@ -1661,7 +1965,9 @@ class TemplateFolderForm(StripWhitespaceForm):
|
||||
(item.id, item.name) for item in all_service_users
|
||||
]
|
||||
|
||||
users_with_permission = MultiCheckboxField('Team members who can see this folder')
|
||||
users_with_permission = govukCollapsibleCheckboxesField(
|
||||
'Team members who can see this folder',
|
||||
field_label='folder')
|
||||
name = StringField('Folder name', validators=[DataRequired(message='Cannot be empty')])
|
||||
|
||||
|
||||
@@ -1766,9 +2072,18 @@ class TemplateAndFoldersSelectionForm(Form):
|
||||
return self.move_to_new_folder_name.data
|
||||
return None
|
||||
|
||||
templates_and_folders = MultiCheckboxField('Choose templates or folders', validators=[
|
||||
required_for_ops('move-to-new-folder', 'move-to-existing-folder')
|
||||
])
|
||||
templates_and_folders = govukCheckboxesField(
|
||||
'Choose templates or folders',
|
||||
validators=[required_for_ops('move-to-new-folder', 'move-to-existing-folder')],
|
||||
choices=[], # added to keep order of arguments, added properly in __init__
|
||||
param_extensions={
|
||||
"fieldset": {
|
||||
"legend": {
|
||||
"classes": "govuk-visually-hidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
# if no default set, it is set to None, which process_data transforms to '__NONE__'
|
||||
# this means '__NONE__' (self.ALL_TEMPLATES option) is selected when no form data has been submitted
|
||||
# set default to empty string so process_data method doesn't perform any transformation
|
||||
@@ -1867,11 +2182,13 @@ class AcceptAgreementForm(StripWhitespaceForm):
|
||||
|
||||
class BroadcastAreaForm(StripWhitespaceForm):
|
||||
|
||||
areas = MultiCheckboxField('Choose areas to broadcast to')
|
||||
areas = govukCheckboxesField('Choose areas to broadcast to')
|
||||
|
||||
def __init__(self, choices, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.areas.choices = choices
|
||||
self.areas.render_as_list = True
|
||||
self.areas.param_extensions = {'fieldset': {'legend': {'classes': 'govuk-visually-hidden'}}}
|
||||
|
||||
@classmethod
|
||||
def from_library(cls, library):
|
||||
|
||||
Reference in New Issue
Block a user