Skip to content

Commit

Permalink
♻️ [#1068] -- refactor custom formio field types to use dynamic confi…
Browse files Browse the repository at this point in the history
…g approach

Now it's just another flavour of mutating an existing component on
the fly, with the added functionality of making some network calls to
retrieve the relevant data.

This essentially does away with the whole special treatment of custom
field types. The use case of adding one or more components and
rebuilding the component tree is removed, as there has not been any
demand for this.

We do keep in mind it may at some point be needed to inject components
at other locations in the tree or do more advanced operations, but
the current implementation does not sit in the way of that approach.

Additionally, this fixes the bug where only top-level npFamilyMembers
components could function correctly, which is now properly recursive
for nested components.
  • Loading branch information
sergei-maertens committed Nov 24, 2022
1 parent faa7667 commit 4242b88
Show file tree
Hide file tree
Showing 10 changed files with 151 additions and 181 deletions.
8 changes: 8 additions & 0 deletions src/openforms/custom_field_types/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import warnings

warnings.warn(
"The 'openforms.custom_field_types' is deprecated and will be removed when "
"the functionality to query 'arbitrary' data sources is finished. For more info, "
"see https://github.com/open-formulieren/open-forms/issues/2169.",
DeprecationWarning,
)
8 changes: 0 additions & 8 deletions src/openforms/custom_field_types/apps.py

This file was deleted.

58 changes: 0 additions & 58 deletions src/openforms/custom_field_types/family_members.py

This file was deleted.

41 changes: 22 additions & 19 deletions src/openforms/custom_field_types/tests/test_family_members.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from openforms.authentication.constants import AuthAttribute
from openforms.contrib.brp.models import BRPConfig
from openforms.forms.custom_field_types import handle_custom_types
from openforms.formio.service import get_dynamic_configuration
from openforms.prefill.contrib.haalcentraal.tests.test_plugin import load_binary_mock
from openforms.registrations.contrib.zgw_apis.tests.factories import ServiceFactory
from openforms.submissions.tests.factories import SubmissionFactory
Expand All @@ -24,47 +24,50 @@


class FamilyMembersCustomFieldTypeTest(TestCase):
@patch(
"openforms.custom_field_types.family_members.get_np_children_haal_centraal",
return_value=[("222333444", "Billy Doe"), ("333444555", "Jane Doe")],
)
def test_get_values_for_custom_field(self, m):
configuration = {
"display": "form",
"components": [
@patch("openforms.formio.components.custom.NPFamilyMembers._get_handler")
def test_get_values_for_custom_field(self, mock_get_handler):
mock_get_handler.return_value.return_value = [
("222333444", "Billy Doe"),
("333444555", "Jane Doe"),
]
submission = SubmissionFactory.from_components(
[
{
"key": "npFamilyMembers",
"type": "npFamilyMembers",
"label": "FamilyMembers",
"values": [{"label": "", "value": ""}],
},
],
}
submission = SubmissionFactory.create(
auth_info__attribute=AuthAttribute.bsn,
auth_info__value="111222333",
form__generate_minimal_setup=True,
form__formstep__form_definition__configuration=configuration,
)
config = FamilyMembersTypeConfig.get_solo()
config.data_api = FamilyMembersDataAPIChoices.haal_centraal
config.save()
formio_wrapper = (
submission.submissionstep_set.get().form_step.form_definition.configuration_wrapper
)

rewritten_configuration = handle_custom_types(configuration, submission)
updated_config_wrapper = get_dynamic_configuration(
formio_wrapper,
request=None,
submission=submission,
)

rewritten_component = rewritten_configuration["components"][0]
rewritten_component = updated_config_wrapper["npFamilyMembers"]
self.assertEqual("selectboxes", rewritten_component["type"])
self.assertFalse(rewritten_component["fieldSet"])
self.assertFalse(rewritten_component["inline"])
self.assertEqual("checkbox", rewritten_component["inputType"])
self.assertEqual(2, len(rewritten_component["values"]))
self.assertDictEqual(
{"value": "222333444", "label": "Billy Doe"},
self.assertEqual(
rewritten_component["values"][0],
{"value": "222333444", "label": "Billy Doe"},
)
self.assertDictEqual(
{"value": "333444555", "label": "Jane Doe"},
self.assertEqual(
rewritten_component["values"][1],
{"value": "333444555", "label": "Jane Doe"},
)

def test_get_children_haal_centraal(self):
Expand Down
77 changes: 75 additions & 2 deletions src/openforms/formio/components/custom.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import logging
from typing import Callable

from openforms.authentication.constants import AuthAttribute
from openforms.submissions.models import Submission
from openforms.typing import DataMapping
from openforms.utils.date import format_date_value

from ..dynamic_config.date import FormioDateComponent, mutate as mutate_date
from ..formatters.custom import DateFormatter, MapFormatter
from ..formatters.formio import TextFieldFormatter
from ..formatters.formio import DefaultFormatter, TextFieldFormatter
from ..registry import BasePlugin, register
from ..typing import Component
from ..utils import conform_to_mask
Expand All @@ -22,7 +25,7 @@ def normalizer(component: FormioDateComponent, value: str) -> str:
return format_date_value(value)

def mutate_config_dynamically(
self, component: FormioDateComponent, data: DataMapping
self, component: FormioDateComponent, submission: Submission, data: DataMapping
) -> None:
"""
Implement the behaviour for our custom date component options.
Expand Down Expand Up @@ -58,3 +61,73 @@ def normalizer(component: Component, value: str) -> str:
"Could not conform value '%s' to input mask '%s', returning original value."
)
return value


@register("npFamilyMembers")
class NPFamilyMembers(BasePlugin):
# not actually relevant, as we transform the component into a different type
formatter = DefaultFormatter

@staticmethod
def _get_handler() -> Callable[[str], list[tuple[str, str]]]:
# TODO: move these into a subpackage of openforms.formio
from openforms.custom_field_types.constants import FamilyMembersDataAPIChoices
from openforms.custom_field_types.handlers.haal_centraal import (
get_np_children_haal_centraal,
)
from openforms.custom_field_types.handlers.stuf_bg import (
get_np_children_stuf_bg,
)
from openforms.custom_field_types.models import FamilyMembersTypeConfig

handlers = {
FamilyMembersDataAPIChoices.haal_centraal: get_np_children_haal_centraal,
FamilyMembersDataAPIChoices.stuf_bg: get_np_children_stuf_bg,
}
config = FamilyMembersTypeConfig.get_solo()
return handlers[config.data_api]

@classmethod
def mutate_config_dynamically(
cls, component: Component, submission: Submission, data: DataMapping
) -> None:
# Check authentication details/status before proceeding
if not submission.is_authenticated:
raise RuntimeError("No authenticated person!")
if submission.auth_info.attribute != AuthAttribute.bsn:
raise RuntimeError("No BSN found in the authentication details.")

bsn = submission.auth_info.value

component.update(
{
"type": "selectboxes",
"fieldSet": False,
"inline": False,
"inputType": "checkbox",
}
)

if "mask" in component:
del component["mask"]

existing_values = component.get("values", [])
empty_option = {
"label": "",
"value": "",
}
if not existing_values or existing_values[0] == empty_option:
handler = cls._get_handler()
# make the API call
# TODO: this should eventually be replaced with logic rules/variables that
# retrieve data from an "arbitrary source", which will cause the data to
# become available in the ``data`` argument instead.
child_choices = handler(bsn)

component["values"] = [
{
"label": label,
"value": value,
}
for value, label in child_choices
]
4 changes: 3 additions & 1 deletion src/openforms/formio/dynamic_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from rest_framework.request import Request

from openforms.submissions.models import Submission
from openforms.typing import DataMapping

from ..datastructures import FormioConfigurationWrapper
Expand All @@ -17,6 +18,7 @@

def rewrite_formio_components(
configuration_wrapper: FormioConfigurationWrapper,
submission: Submission,
data: Optional[DataMapping] = None,
) -> FormioConfigurationWrapper:
"""
Expand All @@ -33,7 +35,7 @@ def rewrite_formio_components(
"""
data = data or {} # normalize
for component in configuration_wrapper:
register.update_config(component, data=data)
register.update_config(component, submission=submission, data=data)
return configuration_wrapper


Expand Down
29 changes: 8 additions & 21 deletions src/openforms/formio/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
from openforms.plugins.registry import BaseRegistry
from openforms.typing import DataMapping

from .datastructures import FormioConfigurationWrapper
from .typing import Component

if TYPE_CHECKING:
Expand Down Expand Up @@ -74,7 +73,7 @@ def verbose_name(self):
return _("{type} component").format(type=self.identifier.capitalize())

def mutate_config_dynamically(
self, component: Component, data: DataMapping
self, component: Component, submission: "Submission", data: DataMapping
) -> None: # pragma: nocover
...

Expand Down Expand Up @@ -109,7 +108,10 @@ def format(self, component: Component, value: Any, as_html=False):
return formatter(component, value)

def update_config(
self, component: Component, data: DataMapping | None = None
self,
component: Component,
submission: "Submission",
data: DataMapping | None = None,
) -> None:
"""
Mutate the component configuration in place.
Expand All @@ -118,19 +120,19 @@ def update_config(
for example) to work.
"""
# if there is no plugin registered for the component, return the input
if (component_type := component["type"]) not in register:
if (component_type := component["type"]) not in self:
return

# invoke plugin if exists
plugin = self[component_type]
plugin.mutate_config_dynamically(component, data)
plugin.mutate_config_dynamically(component, submission, data)

def update_config_for_request(self, component: Component, request: Request) -> None:
"""
Mutate the component in place for the given request context.
"""
# if there is no plugin registered for the component, return the input
if (component_type := component["type"]) not in register:
if (component_type := component["type"]) not in self:
return

# invoke plugin if exists
Expand All @@ -140,21 +142,6 @@ def update_config_for_request(self, component: Component, request: Request) -> N

rewriter(component, request)

def handle_custom_types(
self,
configuration: FormioConfigurationWrapper,
submission: "Submission",
):
"""
Process custom backend-only formio types.
Formio types can be transformed in the context of a given
:class:`openforms.submissions.models.Submission` and ultimately manifest as
modified or standard Formio types, essentially performing some sort of "rewrite"
of the Formio configuration object.
"""
raise NotImplementedError()


# Sentinel to provide the default registry. You can easily instantiate another
# :class:`Registry` object to use as dependency injection in tests.
Expand Down
8 changes: 1 addition & 7 deletions src/openforms/formio/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import elasticapm
from rest_framework.request import Request

from openforms.forms.custom_field_types import handle_custom_types
from openforms.prefill import inject_prefill
from openforms.submissions.models import Submission
from openforms.typing import DataMapping
Expand Down Expand Up @@ -62,12 +61,7 @@ def get_dynamic_configuration(
The configuration is modified in the context of the provided :arg:`submission`.
"""
# TODO: see if we can make the config wrapper smart enough to deal with this
config_wrapper.configuration = handle_custom_types(
config_wrapper.configuration, submission=submission
)

rewrite_formio_components(config_wrapper, data=data)
rewrite_formio_components(config_wrapper, submission=submission, data=data)

# prefill is still 'special' even though it uses variables, as we specifically
# set the `defaultValue` key to the resulting variable.
Expand Down
Loading

0 comments on commit 4242b88

Please sign in to comment.