Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactoring #108

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
161 changes: 87 additions & 74 deletions govuk_frontend_wtf/gov_form_base.py
Original file line number Diff line number Diff line change
@@ -1,124 +1,137 @@
from typing import Any, Dict

from flask import render_template
from markupsafe import Markup
from wtforms import Field, FieldList

from govuk_frontend_wtf.main import merger


class GovFormBase(object):
"""Collection of helpers

These are mixed into the WTForms classes which we are subclassing
to provide extra functionality.
class GovFormBase:
"""
Base class for rendering GOV.UK Frontend components using WTForms fields.

Some of our subclasses then extend these base utilities for their
specific use cases
This class provides common functionality for mapping WTForms field parameters
to the parameters expected by GOV.UK Frontend macros. Subclasses should
define the `template` attribute to specify the Jinja2 template to render.
"""

def __call__(self, field, **kwargs):
return self.render(self.map_gov_params(field, **kwargs))
template: str # Template filename for rendering the GOV.UK component

def __call__(self, field: Field, **kwargs: Any) -> Markup:
"""
Renders the GOV.UK Frontend component for the given WTForms field.

def map_gov_params(self, field, **kwargs):
"""Map WTForms' html params to govuk macros
Args:
field: The WTForms field to render.
**kwargs: Additional keyword arguments to pass to the template.

Taking WTForms' output, we need to map it to a params dict
which matches the structure that the govuk macros are expecting
Returns:
A Markup object containing the rendered HTML.
"""
params = {
"id": kwargs["id"],
"name": field.name,
"label": {"text": field.label.text},
"attributes": {},
"hint": {"text": field.description} if field.description else None,
}
return self.render(self.map_gov_params(field, **kwargs))

if "value" in kwargs:
params["value"] = kwargs["value"]
del kwargs["value"]
def map_gov_params(self, field: Field, **kwargs: Any) -> Dict[str, Any]:
"""
Maps WTForms field parameters to GOV.UK Frontend macro parameters.

# Not all form elements have a type so guard against it not existing
if "type" in kwargs:
params["type"] = kwargs["type"]
del kwargs["type"]
This method handles the translation of common WTForms attributes (like
`label`, `description`, `errors`) into the structure expected by the
GOV.UK Frontend macros. It also merges additional keyword arguments
provided to the `__call__` method.

# Remove items that we've already used from the kwargs
del kwargs["id"]
if "items" in kwargs:
del kwargs["items"]
Args:
field: The WTForms field.
**kwargs: Additional keyword arguments.

# Merge in any extra params passed in from the template layer
if "params" in kwargs:
params = self.merge_params(params, kwargs["params"])
Returns:
A dictionary containing the parameters for the GOV.UK Frontend macro.
"""
params: Dict[str, Any] = {
"id": kwargs.pop("id", None), # Extract 'id' if present, otherwise None
"name": field.name, # Use the field's name attribute
"label": {"text": field.label.text}, # Create label dict
"attributes": {}, # Initialize attributes dictionary
"hint": (
{"text": field.description} if field.description else None
), # Create hint dict if description exists
}

params["value"] = kwargs.pop("value", None) # Extract 'value'
params["type"] = kwargs.pop("type", None) # Extract 'type'

kwargs.pop("items", None) # Remove 'items' if present

# And then remove it, to make sure it doesn't make it's way into the attributes below
del kwargs["params"]
params = self.merge_params(params, kwargs.pop("params", {})) # Merge any extra parameters

# Map error messages
if field.errors:
params["errorMessage"] = {"text": field.errors[0]}
params["errorMessage"] = {"text": field.errors[0]} # Add error message

# And then Merge any remaining attributes directly to the attributes param
# This catches anything set in the more traditional WTForms manner
# i.e. directly as kwargs passed into the field when it's rendered
params["attributes"] = self.merge_params(params["attributes"], kwargs)
params["attributes"].update(kwargs) # Merge remaining kwargs into attributes

# Map attributes such as required="True" to required="required"
# Efficiently set boolean attributes. If value is True, use key as string
for key, value in params["attributes"].items():
if value is True:
params["attributes"][key] = key

return params

def merge_params(self, a, b):
def merge_params(self, a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
"""Merges two dictionaries using the govuk_frontend_wtf merger."""
return merger.merge(a, b)

def render(self, params):
def render(self, params: Dict[str, Any]) -> Markup:
"""Renders the GOV.UK Frontend template with the provided parameters."""
return Markup(render_template(self.template, params=params))


class GovIterableBase(GovFormBase):
def __call__(self, field, **kwargs):
kwargs.setdefault("id", field.id)

if "required" not in kwargs and "required" in getattr(field, "flags", []):
kwargs["required"] = True
"""
Base class for rendering iterable GOV.UK Frontend components (e.g., checkboxes, radio buttons).

kwargs["items"] = []
Extends `GovFormBase` to handle WTForms `FieldList` objects, mapping them
to the GOV.UK Frontend's item-based components.
"""

# This field is constructed as an iterable of subfields
for subfield in field:
item = {"text": subfield.label.text, "value": subfield._value()}
def __call__(self, field: FieldList, **kwargs: Any) -> Markup:
"""Renders the GOV.UK Frontend iterable component for the given FieldList."""
kwargs.setdefault("id", field.id) # Set default id

if getattr(subfield, "checked", subfield.data):
item["checked"] = True
# Safely get flags, handle case where flags attribute might not exist
kwargs["required"] = kwargs.get(
"required", "required" in getattr(field, "flags", [])
) # Check for 'required' flag

kwargs["items"].append(item)
kwargs["items"] = [
{
"text": subfield.label.text, # Text for item
"value": subfield._value(), # Value for item
"checked": getattr(subfield, "checked", subfield.data), # Checked status
}
for subfield in field # Iterate through subfields
]

return super().__call__(field, **kwargs)

def map_gov_params(self, field, **kwargs):
"""Completely override the params mapping for this input type

It bears little resemblance to that of a normal field
because these fields are effectively collections of
fields wrapped in an iterable
def map_gov_params(self, field: FieldList, **kwargs: Any) -> Dict[str, Any]:
"""
Maps parameters for iterable fields to GOV.UK Frontend macro parameters.

params = {
Handles merging of additional parameters passed via the 'params' keyword argument.
"""
params: Dict[str, Any] = {
"name": field.name,
"items": kwargs["items"],
"items": kwargs["items"], # type: ignore[typeddict-item]
"hint": {"text": field.description},
}

# Merge in any extra params passed in from the template layer
if "params" in kwargs:
# Merge items individually as otherwise the merge will append new ones
if "items" in kwargs["params"]:
for index, item in enumerate(kwargs["params"]["items"]):
item = self.merge_params(params["items"][index], item)

del kwargs["params"]["items"]

params = self.merge_params(params, kwargs["params"])
extra_params: Dict[str, Any] = kwargs["params"]
if "items" in extra_params:
for i, item in enumerate(extra_params["items"]): # type: ignore[typeddict-item]
params["items"][i] = self.merge_params(params["items"][i], item) # type: ignore[typeddict-item]
del extra_params["items"]
params = self.merge_params(params, extra_params)

if field.errors:
params["errorMessage"] = {"text": field.errors[0]}
Expand Down
104 changes: 78 additions & 26 deletions govuk_frontend_wtf/main.py
Original file line number Diff line number Diff line change
@@ -1,52 +1,104 @@
from typing import Any, Dict, List, Union

from deepmerge import Merger
from wtforms import Form, ValidationError


class WTFormsHelpers(object):
"""WTForms helpers
class WTFormsHelpers:
"""
Provides helper functions for integrating WTForms with GOV.UK Frontend templates.

Register some template helpers to allow developers to
map WTForms elements to the GOV.UK jinja macros
This class registers a template global function (`wtforms_errors`) that simplifies
the process of displaying WTForms errors within GOV.UK Frontend error summaries.
"""

def __init__(self, app=None):
def __init__(self, app: Any = None) -> None:
"""Initializes the WTFormsHelpers instance.

Args:
app: The Flask application instance (optional). If provided, the helper
function is registered as a template global.
"""
self.app = app
if app is not None:
self.init_app(app)

def init_app(self, app):
def init_app(self, app: Any) -> None:
"""Registers the `wtforms_errors` function as a template global."""
app.add_template_global(wtforms_errors)


def wtforms_errors(form, params={}):
wtforms_params = {"titleText": "There is a problem", "errorList": []}
def wtforms_errors(form: Form, params: Dict[str, Any] = {}) -> Dict[str, Any]:
"""
Generates a dictionary of WTForms errors formatted for GOV.UK Frontend error summaries.

This function takes a WTForms form instance and processes its errors to create a
dictionary suitable for use with the GOV.UK Frontend error summary macro. It
includes functionality to map error messages to specific field IDs.

Args:
form: The WTForms form containing errors.
params: Optional additional parameters to merge into the result.

Returns:
A dictionary containing the formatted error information, ready to be used
in a GOV.UK Frontend template.
"""
wtforms_params: Dict[str, Any] = {
"titleText": "There is a problem", # Default title for error summary
"errorList": [], # List to hold individual error messages
}

id_map = {}
for field_name in form._fields.keys():
field = getattr(form, field_name, None)
if field and hasattr(field, "id"):
id_map[field_name] = field.id
id_map: Dict[str, str] = {} # Map field names to their IDs
for field_name, field in form._fields.items():
if hasattr(field, "id"):
id_map[field_name] = field.id # type: ignore[assignment]

wtforms_params["errorList"] = flatten_errors(form.errors, id_map=id_map)

return merger.merge(wtforms_params, params)
return merger.merge(wtforms_params, params) # Merge with additional parameters


def flatten_errors(
errors: Union[List[Any], Dict[str, Any], ValidationError],
prefix: str = "",
id_map: Dict[str, str] = {},
) -> List[Dict[str, str]]:
"""
Recursively flattens a nested WTForms error structure into a list of dictionaries.

This function processes the potentially nested structure of WTForms errors,
creating a flat list of dictionaries where each dictionary represents a single
error message with its associated field ID (or a generic error if no field is
specified).

def flatten_errors(errors, prefix="", id_map={}):
"""Return list of errors from form errors."""
error_list = []
Args:
errors: The WTForms error structure (can be a dictionary, list, or ValidationError).
prefix: A prefix string to prepend to field names (used for recursive calls).
id_map: A dictionary mapping field names to their corresponding IDs.

Returns:
A list of dictionaries, where each dictionary contains 'text' (the error message)
and 'href' (a link to the field with the error).
"""
error_list: List[Dict[str, str]] = []
if isinstance(errors, dict):
for key, value in errors.items():
# Recurse to handle subforms.
if key in id_map:
key = id_map[key]
error_list += flatten_errors(value, prefix=f"{prefix}{key}-", id_map=id_map)
elif isinstance(errors, list) and isinstance(errors[0], dict):
for idx, error in enumerate(errors):
error_list += flatten_errors(error, prefix=f"{prefix}{idx}-", id_map=id_map)
key_with_id = id_map.get(key, key)
prefix_new = f"{prefix}{key_with_id}-"
error_list.extend(flatten_errors(value, prefix=prefix_new, id_map=id_map))
elif isinstance(errors, list):
error_list.append({"text": errors[0], "href": "#{}".format(prefix.rstrip("-"))})
if isinstance(errors[0], dict):
for idx, error in enumerate(errors):
prefix_new = f"{prefix}{idx}-"
error_list.extend(flatten_errors(error, prefix=prefix_new, id_map=id_map))
else:
error_list.append({"text": str(errors[0]), "href": f"#{prefix.rstrip('-')}"})
elif isinstance(errors, ValidationError):
error_list.append({"text": str(errors), "href": f"#{prefix.rstrip('-')}"})
else:
error_list.append({"text": errors, "href": "#{}".format(prefix.rstrip("-"))})
error_list.append({"text": str(errors), "href": f"#{prefix.rstrip('-')}"})

return error_list


Expand Down
Loading
Loading