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.
Files changed (33) hide show
  1. alliance_platform_ui-0.0.1/PKG-INFO +31 -0
  2. alliance_platform_ui-0.0.1/README.md +0 -0
  3. alliance_platform_ui-0.0.1/alliance_platform/ui/__init__.py +0 -0
  4. alliance_platform_ui-0.0.1/alliance_platform/ui/apps.py +11 -0
  5. alliance_platform_ui-0.0.1/alliance_platform/ui/forms/__init__.py +0 -0
  6. alliance_platform_ui-0.0.1/alliance_platform/ui/forms/renderers.py +50 -0
  7. alliance_platform_ui-0.0.1/alliance_platform/ui/py.typed +0 -0
  8. alliance_platform_ui-0.0.1/alliance_platform/ui/settings.py +18 -0
  9. alliance_platform_ui-0.0.1/alliance_platform/ui/templates/alliance_platform/ui/labeled_input_base.html +6 -0
  10. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/__init__.py +0 -0
  11. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/button.py +35 -0
  12. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/date_picker.py +52 -0
  13. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/form.py +396 -0
  14. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/icon.py +63 -0
  15. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/inline_alert.py +42 -0
  16. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/labeled_input.py +37 -0
  17. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/menubar.py +59 -0
  18. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/misc.py +24 -0
  19. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/pagination.py +46 -0
  20. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/table.py +88 -0
  21. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/time_input.py +34 -0
  22. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/ui.py +159 -0
  23. alliance_platform_ui-0.0.1/alliance_platform/ui/templatetags/alliance_platform/utils.py +32 -0
  24. alliance_platform_ui-0.0.1/pyproject.toml +92 -0
  25. alliance_platform_ui-0.0.1/tests/__init__.py +0 -0
  26. alliance_platform_ui-0.0.1/tests/fixtures/build_test/manifest.json +84 -0
  27. alliance_platform_ui-0.0.1/tests/fixtures/development-css-mappings/login_css_ts.json +3 -0
  28. alliance_platform_ui-0.0.1/tests/fixtures/production-css-mappings/login_css_ts.json +3 -0
  29. alliance_platform_ui-0.0.1/tests/fixtures/server_build_test/manifest.json +23 -0
  30. alliance_platform_ui-0.0.1/tests/test_alliance_ui_form.py +77 -0
  31. alliance_platform_ui-0.0.1/tests/test_alliance_ui_templatetags.py +208 -0
  32. alliance_platform_ui-0.0.1/tests/test_utils/__init__.py +18 -0
  33. 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
@@ -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()
@@ -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)
@@ -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")
@@ -0,0 +1,6 @@
1
+ {% load alliance_platform.ui %}
2
+ {% load react %}
3
+
4
+ {% LabeledInput props=extra_widget_props %}
5
+ {% block input %}{% endblock %}
6
+ {% endLabeledInput %}
@@ -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
+ )