alliance-platform-ui 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alliance_platform_ui-0.0.1/PKG-INFO +31 -0
- alliance_platform_ui-0.0.1/README.md +0 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/__init__.py +0 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/apps.py +11 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/forms/__init__.py +0 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/forms/renderers.py +50 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/py.typed +0 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/settings.py +18 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templates/alliance_platform/ui/labeled_input_base.html +6 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/__init__.py +0 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/button.py +35 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/date_picker.py +52 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/form.py +396 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/icon.py +63 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/inline_alert.py +42 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/labeled_input.py +37 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/menubar.py +59 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/misc.py +24 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/pagination.py +46 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/table.py +88 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/time_input.py +34 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/ui.py +159 -0
- alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/utils.py +32 -0
- alliance_platform_ui-0.0.1/pyproject.toml +92 -0
- alliance_platform_ui-0.0.1/tests/__init__.py +0 -0
- alliance_platform_ui-0.0.1/tests/fixtures/build_test/manifest.json +84 -0
- alliance_platform_ui-0.0.1/tests/fixtures/development-css-mappings/login_css_ts.json +3 -0
- alliance_platform_ui-0.0.1/tests/fixtures/production-css-mappings/login_css_ts.json +3 -0
- alliance_platform_ui-0.0.1/tests/fixtures/server_build_test/manifest.json +23 -0
- alliance_platform_ui-0.0.1/tests/test_alliance_ui_form.py +77 -0
- alliance_platform_ui-0.0.1/tests/test_alliance_ui_templatetags.py +208 -0
- alliance_platform_ui-0.0.1/tests/test_utils/__init__.py +18 -0
- alliance_platform_ui-0.0.1/tests/test_utils/bundler.py +46 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: alliance-platform-ui
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Django integration for frontend templatetags
|
|
5
|
+
Keywords: django,alliance,alliancesoftware
|
|
6
|
+
Author-Email: Alliance Software <support@alliancesoftware.com.au>
|
|
7
|
+
License: BSD-2-Clause
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Environment :: Web Environment
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Framework :: Django :: 4.2
|
|
12
|
+
Classifier: Framework :: Django :: 5.0
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Project-URL: issues, https://github.com/AllianceSoftware/alliance-platform-py/issues
|
|
21
|
+
Project-URL: homepage, https://github.com/AllianceSoftware/alliance-platform-py/packages/ap-ui
|
|
22
|
+
Project-URL: repository, https://github.com/AllianceSoftware/alliance-platform-py
|
|
23
|
+
Requires-Python: <4.0,>=3.11
|
|
24
|
+
Requires-Dist: alliance-platform-frontend
|
|
25
|
+
Requires-Dist: alliance-platform-codegen
|
|
26
|
+
Requires-Dist: alliance-platform-core
|
|
27
|
+
Requires-Dist: Django>=4.2.11
|
|
28
|
+
Requires-Dist: django-allianceutils>=4.0.1
|
|
29
|
+
Requires-Dist: typing-extensions>=4.5.0
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from alliance_platform.ui.settings import ap_ui_settings
|
|
2
|
+
from django.apps.config import AppConfig
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AlliancePlatformUIConfig(AppConfig):
|
|
6
|
+
name = "alliance_platform.ui"
|
|
7
|
+
verbose_name = "Alliance Platform UI"
|
|
8
|
+
label = "alliance_platform_ui"
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
ap_ui_settings.check_settings()
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from alliance_platform.frontend.util import SSRExclusionMarker
|
|
2
|
+
from django.forms.renderers import TemplatesSetting
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class form_input_context_key(SSRExclusionMarker):
|
|
6
|
+
"""This class serves as a unique sentinel value in a form's widget attributes dictionary
|
|
7
|
+
to set special Alliance Platform context values. It serves no purpose except
|
|
8
|
+
as a dictionary key with special handling.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FormInputContextRenderer(TemplatesSetting):
|
|
15
|
+
"""Renderer to accommodate passing extra context variables to widgets
|
|
16
|
+
|
|
17
|
+
Django widget interface provides no way to pass extra context to widget
|
|
18
|
+
templates short of extending the widget class, or adding it to widget.attrs.
|
|
19
|
+
The problem with passing it in widget.attrs it that it's typically passed
|
|
20
|
+
directly through to the widget. Polluting ``attrs`` with extra context is
|
|
21
|
+
problematic both because it's unexpected and because you either just have to
|
|
22
|
+
accept all the values and hope it causes no issues, or filter them out
|
|
23
|
+
which is inconvenient in templates.
|
|
24
|
+
|
|
25
|
+
This renderer works with :ttag:`form_input` to pass extra context to widgets
|
|
26
|
+
by setting a special key in the ``widget.attrs`` dictionary. The renderer
|
|
27
|
+
then pops this value and adds the contents to the ``context`` that is then
|
|
28
|
+
used to render the template.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
form_input_context_key = form_input_context_key
|
|
32
|
+
|
|
33
|
+
def render(self, template_name, context, request=None):
|
|
34
|
+
from alliance_platform.frontend.templatetags.react import NestedComponentPropAccumulator
|
|
35
|
+
|
|
36
|
+
if "widget" in context and "attrs" in context["widget"]:
|
|
37
|
+
# in order to support nested components, ``form_input`` will set the accumulator (which indicates
|
|
38
|
+
# that the widget is a nested component) in the widget.attrs. This renderer will pop it out and
|
|
39
|
+
# pass it through in the root ``context`` that is used by the widget template. This allows the
|
|
40
|
+
# ``component`` to detect that it is a nested component and handle accordingly.
|
|
41
|
+
accumulator = context["widget"]["attrs"].pop(NestedComponentPropAccumulator.context_key, None)
|
|
42
|
+
extra_context = context["widget"]["attrs"].pop(form_input_context_key, {})
|
|
43
|
+
if accumulator:
|
|
44
|
+
extra_context[NestedComponentPropAccumulator.context_key] = accumulator
|
|
45
|
+
# setting a default means templates can assume the value always exists - makes
|
|
46
|
+
# usage with merge_props etc easier
|
|
47
|
+
if "extra_widget_props" not in extra_context:
|
|
48
|
+
extra_context["extra_widget_props"] = {}
|
|
49
|
+
context.update(extra_context)
|
|
50
|
+
return super().render(template_name, context, request)
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import TypedDict
|
|
2
|
+
|
|
3
|
+
from alliance_platform.base_settings import AlliancePlatformSettingsBase
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AlliancePlatformUISettingsType(TypedDict, total=False):
|
|
7
|
+
"""The type of the settings for the UI of the Alliance Platform.
|
|
8
|
+
|
|
9
|
+
Currently just a placeholder as the UI package doesn't yet need
|
|
10
|
+
any settings of its own.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AlliancePlatformUISettings(AlliancePlatformSettingsBase):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
ap_ui_settings = AlliancePlatformUISettings("UI")
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from django import template
|
|
2
|
+
from django.template import Library
|
|
3
|
+
|
|
4
|
+
from alliance_platform.frontend.templatetags.react import parse_component_tag
|
|
5
|
+
|
|
6
|
+
from .utils import get_module_import_source
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def button(parser: template.base.Parser, token: template.base.Token):
|
|
10
|
+
"""
|
|
11
|
+
Render a ``Button`` component from the Alliance UI React library with the specified props.
|
|
12
|
+
|
|
13
|
+
If used for a link, the :function:`~alliance_platform.templatetags.alliance_ui.url_with_perms_filter`
|
|
14
|
+
or :function:`~alliance_platform.frontend.templatetags.alliance_ui.url_filter` can be used to handle
|
|
15
|
+
url args, or hide if the link is not available.
|
|
16
|
+
"""
|
|
17
|
+
return parse_component_tag(
|
|
18
|
+
parser,
|
|
19
|
+
token,
|
|
20
|
+
asset_source=get_module_import_source("@alliancesoftware/ui", "Button", False, parser.origin),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def button_group(parser: template.base.Parser, token: template.base.Token):
|
|
25
|
+
"""Render a ``ButtonGroup`` component from the Alliance UI React library with the specified props."""
|
|
26
|
+
return parse_component_tag(
|
|
27
|
+
parser,
|
|
28
|
+
token,
|
|
29
|
+
asset_source=get_module_import_source("@alliancesoftware/ui", "ButtonGroup", False, parser.origin),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register_button(register: Library):
|
|
34
|
+
register.tag("Button")(button)
|
|
35
|
+
register.tag("ButtonGroup")(button_group)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
|
|
3
|
+
from django import template
|
|
4
|
+
from django.template import Context
|
|
5
|
+
from django.template import Library
|
|
6
|
+
from django.utils.dateparse import parse_date
|
|
7
|
+
from django.utils.dateparse import parse_datetime
|
|
8
|
+
|
|
9
|
+
from alliance_platform.frontend.templatetags.react import ComponentNode
|
|
10
|
+
from alliance_platform.frontend.templatetags.react import ComponentProps
|
|
11
|
+
from alliance_platform.frontend.templatetags.react import parse_component_tag
|
|
12
|
+
|
|
13
|
+
from .utils import get_module_import_source
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DatePickerNode(ComponentNode):
|
|
17
|
+
def resolve_props(self, context: Context) -> ComponentProps:
|
|
18
|
+
values = super().resolve_props(context)
|
|
19
|
+
granularity = values.props.get("granularity", "day")
|
|
20
|
+
if granularity not in {"day", "hour", "minute", "second"}:
|
|
21
|
+
warnings.warn(
|
|
22
|
+
f"Invalid granularity '{granularity}' passed to DatePicker. Defaulting to 'day'. If this is a datetime picker this will break."
|
|
23
|
+
)
|
|
24
|
+
granularity = "day"
|
|
25
|
+
values.update({"granularity": self.resolve_prop(granularity, context)})
|
|
26
|
+
|
|
27
|
+
if values.has_prop("defaultValue") and isinstance(values.props["defaultValue"], str):
|
|
28
|
+
values.update(
|
|
29
|
+
{
|
|
30
|
+
"defaultValue": self.resolve_prop(
|
|
31
|
+
(parse_date if granularity == "day" else parse_datetime)(
|
|
32
|
+
values.props["defaultValue"]
|
|
33
|
+
),
|
|
34
|
+
context,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
return values
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def date_picker(parser: template.base.Parser, token: template.base.Token):
|
|
42
|
+
"""Render a ``DatePicker`` component from the Alliance UI React library with the specified props."""
|
|
43
|
+
return parse_component_tag(
|
|
44
|
+
parser,
|
|
45
|
+
token,
|
|
46
|
+
asset_source=get_module_import_source("@alliancesoftware/ui", "DatePicker", False, parser.origin),
|
|
47
|
+
node_class=DatePickerNode,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def register_date_picker(register: Library):
|
|
52
|
+
register.tag("DatePicker")(date_picker)
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import field
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
from allianceutils.template import parse_tag_arguments
|
|
8
|
+
from allianceutils.template import resolve
|
|
9
|
+
from django import template
|
|
10
|
+
from django.forms import BaseForm
|
|
11
|
+
from django.forms import BoundField
|
|
12
|
+
from django.template import Context
|
|
13
|
+
from django.template import Node
|
|
14
|
+
from django.template import NodeList
|
|
15
|
+
from django.template import Origin
|
|
16
|
+
from django.template import TemplateSyntaxError
|
|
17
|
+
from django.template.base import UNKNOWN_SOURCE
|
|
18
|
+
from django.template.base import FilterExpression
|
|
19
|
+
|
|
20
|
+
from alliance_platform.frontend.templatetags.react import ComponentNode
|
|
21
|
+
from alliance_platform.frontend.templatetags.react import NestedComponentProp
|
|
22
|
+
from alliance_platform.frontend.templatetags.react import NestedComponentPropAccumulator
|
|
23
|
+
from alliance_platform.frontend.templatetags.react import convert_html_string
|
|
24
|
+
|
|
25
|
+
from ...forms.renderers import FormInputContextRenderer
|
|
26
|
+
from .labeled_input import LabeledInputNode
|
|
27
|
+
from .utils import get_module_import_source
|
|
28
|
+
|
|
29
|
+
register = template.Library()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _NOT_PROVIDED:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FormInputNode(template.Node):
|
|
37
|
+
origin: Origin
|
|
38
|
+
show_valid_state: FilterExpression | bool
|
|
39
|
+
help_text: FilterExpression | str | type[_NOT_PROVIDED]
|
|
40
|
+
label: FilterExpression | str | type[_NOT_PROVIDED]
|
|
41
|
+
is_required: FilterExpression | bool
|
|
42
|
+
extra_attrs: dict[str, FilterExpression]
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
field: FilterExpression,
|
|
47
|
+
origin: Origin | None,
|
|
48
|
+
help_text=_NOT_PROVIDED,
|
|
49
|
+
label=_NOT_PROVIDED,
|
|
50
|
+
is_required=None,
|
|
51
|
+
show_valid_state=True,
|
|
52
|
+
non_standard_widget=False,
|
|
53
|
+
**extra_attrs,
|
|
54
|
+
):
|
|
55
|
+
self.origin = origin or Origin(UNKNOWN_SOURCE)
|
|
56
|
+
self.show_valid_state = show_valid_state
|
|
57
|
+
self.field = field
|
|
58
|
+
self.help_text = help_text
|
|
59
|
+
self.label = label
|
|
60
|
+
self.required = is_required
|
|
61
|
+
self.extra_attrs = extra_attrs or {}
|
|
62
|
+
self.non_standard_widget = non_standard_widget
|
|
63
|
+
|
|
64
|
+
def render(self, context: template.Context):
|
|
65
|
+
field: BoundField = self.field.resolve(context)
|
|
66
|
+
form_context = FormContext.get_current(context)
|
|
67
|
+
extra_attrs = {}
|
|
68
|
+
if not isinstance(field.form.renderer, FormInputContextRenderer):
|
|
69
|
+
warnings.warn("form_input tag should only be used with 'FormInputContextRenderer'")
|
|
70
|
+
else:
|
|
71
|
+
help_text = (
|
|
72
|
+
resolve(self.help_text, context) if self.help_text is not _NOT_PROVIDED else field.help_text
|
|
73
|
+
)
|
|
74
|
+
if help_text:
|
|
75
|
+
# Help text can be HTML and django docs make it clear this value is not HTML-escaped.
|
|
76
|
+
try:
|
|
77
|
+
# this may return an empty list if the HTML is invalid
|
|
78
|
+
help_text = convert_html_string(help_text, self.origin)[0]
|
|
79
|
+
except IndexError:
|
|
80
|
+
help_text = ""
|
|
81
|
+
warnings.warn(f"Bad help text on field, likely invalid HTML: {help_text}")
|
|
82
|
+
extra_attrs[field.form.renderer.form_input_context_key] = {
|
|
83
|
+
"raw_value": field.value(),
|
|
84
|
+
"extra_widget_props": {
|
|
85
|
+
"label": resolve(self.label, context) if self.label is not _NOT_PROVIDED else field.label,
|
|
86
|
+
"errorMessage": ", ".join(str(e) for e in field.errors),
|
|
87
|
+
"validationState": (
|
|
88
|
+
"invalid"
|
|
89
|
+
if field.errors
|
|
90
|
+
else (
|
|
91
|
+
"valid"
|
|
92
|
+
if field.form.is_bound and resolve(self.show_valid_state, context)
|
|
93
|
+
else None
|
|
94
|
+
)
|
|
95
|
+
),
|
|
96
|
+
"description": help_text,
|
|
97
|
+
"isRequired": (
|
|
98
|
+
resolve(self.required, context) if self.required is not None else field.field.required
|
|
99
|
+
),
|
|
100
|
+
**{key: resolve(value, context) for key, value in self.extra_attrs.items()},
|
|
101
|
+
**form_context.get_extra_props(field),
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
form_context.mark_processed(field.name)
|
|
105
|
+
if self.non_standard_widget:
|
|
106
|
+
node = LabeledInputNode(
|
|
107
|
+
self.origin,
|
|
108
|
+
get_module_import_source("@alliancesoftware/ui", "LabeledInput", False, self.origin),
|
|
109
|
+
{
|
|
110
|
+
**extra_attrs[field.form.renderer.form_input_context_key]["extra_widget_props"],
|
|
111
|
+
"children": [],
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
node.props.update({"children": NodeList([NestedComponentFormWidgetNode(field, self.origin)])})
|
|
115
|
+
return node.render(context)
|
|
116
|
+
if NestedComponentPropAccumulator.context_key in context:
|
|
117
|
+
# If this is used within another React component, we need to defer rendering of the widget so that
|
|
118
|
+
# it has access to the NestedComponentPropAccumulator
|
|
119
|
+
return NestedComponentFormWidgetNode(field, self.origin, extra_attrs).render(context)
|
|
120
|
+
return field.as_widget(attrs=extra_attrs) # type: ignore[arg-type] # dict type in form says on str|bool but other types seem to work fine
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _replace_nodes_with_str(items: list[str | ComponentNode], separator: str):
|
|
124
|
+
replacements: dict[str, ComponentNode] = {}
|
|
125
|
+
final_items: list[str] = []
|
|
126
|
+
for item in items:
|
|
127
|
+
if isinstance(item, ComponentNode):
|
|
128
|
+
placeholder = f"__form_widget_placeholder({len(replacements)})__"
|
|
129
|
+
replacements[placeholder] = item
|
|
130
|
+
final_items.append(placeholder)
|
|
131
|
+
else:
|
|
132
|
+
final_items.append(item)
|
|
133
|
+
return separator.join(final_items), replacements
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class NestedComponentFormWidgetNode(Node):
|
|
137
|
+
field: BoundField
|
|
138
|
+
origin: Origin
|
|
139
|
+
attrs: dict
|
|
140
|
+
|
|
141
|
+
def __init__(self, field: BoundField, origin: Origin, attrs: dict | None = None):
|
|
142
|
+
self.field = field
|
|
143
|
+
self.origin = origin
|
|
144
|
+
self.attrs = attrs or {}
|
|
145
|
+
|
|
146
|
+
def render(self, context: Context):
|
|
147
|
+
"""
|
|
148
|
+
This is a bit of a hack, so some explanation is needed.
|
|
149
|
+
|
|
150
|
+
The problem we are solving is that widgets are rendered as standalone templates. This means that
|
|
151
|
+
the contents is not treated as a component child, even though in this case we are treating it as such.
|
|
152
|
+
|
|
153
|
+
temp_accumulator is a temporary accumulator that is used to collect the children of the widget as
|
|
154
|
+
it is rendered. Note that we have to pass this through under ``attrs`` to the ``as_widget`` method which
|
|
155
|
+
makes it appear under ``widget.attrs`` in the final widget template context. This needs to be at the top
|
|
156
|
+
level however, so we rely on ``FormInputContextRenderer`` to pop this value and insert it into the context.
|
|
157
|
+
This is the only way to manipulate the final context a widget template gets rendered with, otherwise you
|
|
158
|
+
can only change ``widget.attrs``. ``FormInputContextRenderer`` is checked in ``FormInputNode`` so it's
|
|
159
|
+
guaranteed to be used if this code path gets executed.
|
|
160
|
+
|
|
161
|
+
This is necessary because the widget may contain nested components that need to be
|
|
162
|
+
rendered within the context of the parent component (i.e. we don't want the <script> tags etc, we
|
|
163
|
+
want it to get added to the ``accumulator`` above.
|
|
164
|
+
|
|
165
|
+
However, there could be other HTML mixed in with those components so we have to do it in two passes.
|
|
166
|
+
The first pass renders the widgets, collects any nested components and replaces them with placeholders.
|
|
167
|
+
The second passes the first output through ``convert_html_string`` to generate a list of components (where
|
|
168
|
+
HTML tags are encountered), or strings otherwise.
|
|
169
|
+
|
|
170
|
+
Take for example this template. It has a mixture of component nodes that will be handled in first pass,
|
|
171
|
+
HTML handled in second pass, and plain strings that require no transformation:
|
|
172
|
+
|
|
173
|
+
{% component "@alliancesoftware/ui" "TextInput" props=widget.attrs %}{% endcomponent %}
|
|
174
|
+
Plain text <strong>Nested HTML</strong> More text <span>Extra</span>
|
|
175
|
+
{% component "i" %}Another Component{% endcomponent %}
|
|
176
|
+
End
|
|
177
|
+
|
|
178
|
+
``widget_html`` will be:
|
|
179
|
+
|
|
180
|
+
__NestedComponentPropAccumulator__prop__0
|
|
181
|
+
Plain text <strong>Nested HTML</strong> More text <span>Extra</span>
|
|
182
|
+
__NestedComponentPropAccumulator__prop__1
|
|
183
|
+
End
|
|
184
|
+
|
|
185
|
+
``convert_html_string`` will return:
|
|
186
|
+
|
|
187
|
+
[
|
|
188
|
+
'__NestedComponentPropAccumulator__prop__0\nPlain text ',
|
|
189
|
+
ComponentNode(CommonComponentSource(name='strong'), {'children': ['Nested HTML']}),
|
|
190
|
+
' More text ',
|
|
191
|
+
ComponentNode(CommonComponentSource(name='span'), {'children': ['Extra']}),
|
|
192
|
+
'\n__NestedComponentPropAccumulator__prop__1\nEnd'
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
Next we replace any `ComponentNode` with a placeholder (__form_widget_placeholder(0)__) using ``_replace_nodes_with_str``
|
|
196
|
+
which generates a string like:
|
|
197
|
+
|
|
198
|
+
__NestedComponentPropAccumulator__prop__0
|
|
199
|
+
Plain text __form_widget_placeholder(0)__ More text __form_widget_placeholder(1)__
|
|
200
|
+
__NestedComponentPropAccumulator__prop__1
|
|
201
|
+
End
|
|
202
|
+
|
|
203
|
+
This gets passed through ``temp_accumulator.apply`` which will replace any of the placeholders from first
|
|
204
|
+
pass ("__NestedComponentPropAccumulator__prop__0" in the example). This results in:
|
|
205
|
+
|
|
206
|
+
[
|
|
207
|
+
NestedComponentProp(ImportComponentSource(TextInput), ComponentProps({..})),
|
|
208
|
+
'\nPlain text __form_widget_placeholder(0)__ More text __form_widget_placeholder(1)__\n',
|
|
209
|
+
NestedComponentProp(CommonComponentSource(name='i'), ComponentProps({'children': 'Another Component'})),
|
|
210
|
+
'\nEnd'
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
Finally, we iterate over this list. If a `NestedComponentProp` is encountered, it's added as a child to the
|
|
214
|
+
original parent component with ``accumulator.add`` (this returns a string placeholder). If a string is
|
|
215
|
+
encountered we have to replace any of the __form_widget_placeholder(i)__ placeholders. We do this by getting
|
|
216
|
+
the original component for that placeholder, add it with ``accumulator.add`` and use the placeholder returned
|
|
217
|
+
in place of the original. This is repeated until all placeholders are replaced. This gives us a final
|
|
218
|
+
string like:
|
|
219
|
+
|
|
220
|
+
__NestedComponentPropAccumulator__prop__0
|
|
221
|
+
Plain text __NestedComponentPropAccumulator__prop__1 More text __NestedComponentPropAccumulator__prop__2
|
|
222
|
+
__NestedComponentPropAccumulator__prop__3
|
|
223
|
+
End
|
|
224
|
+
|
|
225
|
+
This is what we return, and the parent component will replace any of the remaining placeholders as part of
|
|
226
|
+
the normal ``ComponentNode`` rendering.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
# This is the parent component accumulator we are rendered within. This ``render`` method is being called
|
|
230
|
+
# from within ``ComponentNode`` within an active NestedComponentPropAccumulator context.
|
|
231
|
+
accumulator = context.get(NestedComponentPropAccumulator.context_key)
|
|
232
|
+
if not accumulator:
|
|
233
|
+
raise ValueError("Unexpected: NestedComponentPropAccumulator not found in context")
|
|
234
|
+
|
|
235
|
+
with NestedComponentPropAccumulator(context, accumulator.origin_node) as temp_accumulator:
|
|
236
|
+
widget_html = self.field.as_widget(
|
|
237
|
+
attrs={**self.attrs, NestedComponentPropAccumulator.context_key: temp_accumulator}
|
|
238
|
+
)
|
|
239
|
+
final = []
|
|
240
|
+
# if this string appears in the string then it's going to be lost in the final output, but
|
|
241
|
+
# considering they aren't visible characters it likely doesn't matter.
|
|
242
|
+
# Zero-width Space + Information Separator Four
|
|
243
|
+
separator = "\u200b\u001c"
|
|
244
|
+
combined_str, replacements = _replace_nodes_with_str(
|
|
245
|
+
convert_html_string(widget_html, self.origin), separator
|
|
246
|
+
)
|
|
247
|
+
for item in temp_accumulator.apply(combined_str):
|
|
248
|
+
if isinstance(item, str):
|
|
249
|
+
for part in item.split(separator):
|
|
250
|
+
if part in replacements:
|
|
251
|
+
prop = NestedComponentProp(replacements[part], accumulator.origin_node, context)
|
|
252
|
+
final.append(accumulator.add(prop))
|
|
253
|
+
else:
|
|
254
|
+
final.append(part)
|
|
255
|
+
else:
|
|
256
|
+
final.append(accumulator.add(item))
|
|
257
|
+
return "".join(final)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@register.tag("form_input")
|
|
261
|
+
def form_input(parser: template.base.Parser, token: template.base.Token):
|
|
262
|
+
"""Renders a form input with additional props supported by alliance-ui
|
|
263
|
+
|
|
264
|
+
This sets `label`, `errorMessage`, `validationState`, `description` and `isRequired`. In addition, it may
|
|
265
|
+
set `autoFocus` based on the `auto_focus` setting on the parent `form` tag.
|
|
266
|
+
|
|
267
|
+
The following options can be passed to the tag to override defaults:
|
|
268
|
+
|
|
269
|
+
- `label` - set the label for the input. If not specified will use ``field.label``.
|
|
270
|
+
- `help_text` - help text to show below the input. If not specified will use ``field.help_text``.
|
|
271
|
+
- `show_valid_state` - if true, will show the 'valid' (i.e. success) state of the input. If not specified will use
|
|
272
|
+
``False``. For most components in alliance-ui this results in it showing a tick icon and/or rendering green. If
|
|
273
|
+
this is ``False`` only error states will be shown.
|
|
274
|
+
- `is_required` - if true, will show the input as required. If not specified will use the model field ``required``
|
|
275
|
+
setting.
|
|
276
|
+
|
|
277
|
+
In addition, you can pass through any extra attributes that should be set on the input. For example, to set an
|
|
278
|
+
addon for an alliance-ui ``TextInput`` you could do the following::
|
|
279
|
+
|
|
280
|
+
{% form_input field addonBefore="$" %}
|
|
281
|
+
|
|
282
|
+
Note that the attributes supported here depend entirely on the widget. If the widget is a React component, you
|
|
283
|
+
can also pass react components to the tag::
|
|
284
|
+
|
|
285
|
+
{% component "core-ui/icons" "Search" as search_icon %}${% endcomponent %}
|
|
286
|
+
{% form_input field addonBefore=search_icon %}
|
|
287
|
+
|
|
288
|
+
The additional props are added to the key ``extra_widget_props`` - so the relevant widget template needs to include
|
|
289
|
+
this for the props to be passed through::
|
|
290
|
+
|
|
291
|
+
{% component "@alliancesoftware/ui" "TextInput" props=widget.attrs|merge_props:extra_widget_props|html_attr_to_jsx type=widget.type name=widget.name default_value=widget.value %}
|
|
292
|
+
{% endcomponent %}
|
|
293
|
+
"""
|
|
294
|
+
tag_name = token.split_contents()[0]
|
|
295
|
+
args, kwargs, target_var = parse_tag_arguments(parser, token, supports_as=True)
|
|
296
|
+
if not len(args) == 1:
|
|
297
|
+
raise TemplateSyntaxError(f"{tag_name} must be passed the form field to render input for")
|
|
298
|
+
|
|
299
|
+
return FormInputNode(
|
|
300
|
+
args[0],
|
|
301
|
+
parser.origin,
|
|
302
|
+
**kwargs,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@dataclass
|
|
307
|
+
class FormContext:
|
|
308
|
+
form: BaseForm
|
|
309
|
+
#: If true, the first field with errors will be focused or the first field if form not submitted
|
|
310
|
+
auto_focus: bool = False
|
|
311
|
+
processed_fields: list[str] = field(default_factory=list)
|
|
312
|
+
|
|
313
|
+
def mark_processed(self, field_name: str):
|
|
314
|
+
self.processed_fields.append(field_name)
|
|
315
|
+
|
|
316
|
+
@classmethod
|
|
317
|
+
def get_current(cls, context: template.Context) -> FormContext:
|
|
318
|
+
form_context = context.get("form_context", None)
|
|
319
|
+
if not form_context:
|
|
320
|
+
raise ValueError("form_input tag must be used within a form tag")
|
|
321
|
+
return form_context
|
|
322
|
+
|
|
323
|
+
_focused_field: BoundField | None = None
|
|
324
|
+
|
|
325
|
+
def get_extra_props(self, field: BoundField):
|
|
326
|
+
extra_props = {}
|
|
327
|
+
if self.auto_focus:
|
|
328
|
+
if self.form.is_bound and field.errors and not self._focused_field:
|
|
329
|
+
self._focused_field = field
|
|
330
|
+
extra_props["auto_focus"] = True
|
|
331
|
+
elif not self.form.is_bound and not self.processed_fields:
|
|
332
|
+
# focus first field
|
|
333
|
+
extra_props["auto_focus"] = True
|
|
334
|
+
return extra_props
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class FormNode(template.Node):
|
|
338
|
+
origin: Origin
|
|
339
|
+
form: FilterExpression
|
|
340
|
+
nodelist: NodeList
|
|
341
|
+
auto_focus: FilterExpression | bool
|
|
342
|
+
|
|
343
|
+
def __init__(self, form: FilterExpression, nodelist: NodeList, origin: Origin | None, auto_focus=False):
|
|
344
|
+
self.origin = origin or Origin(UNKNOWN_SOURCE)
|
|
345
|
+
self.form = form
|
|
346
|
+
self.nodelist = nodelist
|
|
347
|
+
self.auto_focus = auto_focus
|
|
348
|
+
|
|
349
|
+
def render(self, context: template.Context):
|
|
350
|
+
form: BaseForm = resolve(self.form, context)
|
|
351
|
+
with context.push(form_context=FormContext(form, resolve(self.auto_focus, context))):
|
|
352
|
+
return self.nodelist.render(context)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@register.tag("form")
|
|
356
|
+
def form(parser: template.base.Parser, token: template.base.Token):
|
|
357
|
+
"""Tag to setup a form context for form_input tags
|
|
358
|
+
|
|
359
|
+
This tag doesn't render anything itself, it just sets up context for ``form_input`` tags. This is to support
|
|
360
|
+
the ``auto_focus`` behaviour. This works by adding an ``auto_focus`` prop to the first field with errors, or the
|
|
361
|
+
first rendered field if no errors are present.
|
|
362
|
+
|
|
363
|
+
Usage::
|
|
364
|
+
|
|
365
|
+
{% load form %}
|
|
366
|
+
|
|
367
|
+
{% form form auto_focus=True %}
|
|
368
|
+
<form method="post>
|
|
369
|
+
{% for field in form.visible_fields %}
|
|
370
|
+
{% form_input field %}
|
|
371
|
+
{% endfor %}
|
|
372
|
+
</form>
|
|
373
|
+
{% endform %}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
.. note::
|
|
377
|
+
|
|
378
|
+
Usage of this tag requires the following settings to be set::
|
|
379
|
+
|
|
380
|
+
FORM_RENDERER = "alliance_platform.ui.forms.renderers.FormInputContextRenderer"
|
|
381
|
+
|
|
382
|
+
"""
|
|
383
|
+
tag_name = token.split_contents()[0]
|
|
384
|
+
args, kwargs, target_var = parse_tag_arguments(parser, token, supports_as=True)
|
|
385
|
+
if not len(args) == 1:
|
|
386
|
+
raise TemplateSyntaxError(f"{tag_name} must be passed the django form instance")
|
|
387
|
+
|
|
388
|
+
nodelist = parser.parse((f"end{tag_name}",))
|
|
389
|
+
parser.delete_first_token()
|
|
390
|
+
|
|
391
|
+
return FormNode(
|
|
392
|
+
args[0],
|
|
393
|
+
nodelist,
|
|
394
|
+
parser.origin,
|
|
395
|
+
**kwargs,
|
|
396
|
+
)
|