mirror of
https://github.com/django-components/django-components.git
synced 2025-08-04 14:28:18 +00:00
feat: Support for HTML attributes and html_attrs
tag (#491)
This commit is contained in:
parent
ba86cee578
commit
610475353f
7 changed files with 1156 additions and 32 deletions
458
README.md
458
README.md
|
@ -20,6 +20,8 @@ Read on to learn about the details!
|
|||
|
||||
## Release notes
|
||||
|
||||
**Version 0.74** introduces `html_attrs` tag and `prefix:key=val` construct for passing dicts to components.
|
||||
|
||||
🚨📢 **Version 0.70**
|
||||
|
||||
- `{% if_filled "my_slot" %}` tags were replaced with `{{ component_vars.is_filled.my_slot }}` variables.
|
||||
|
@ -680,6 +682,8 @@ As seen above, you can pass arguments to components like so:
|
|||
|
||||
### Special characters
|
||||
|
||||
_New in version 0.71_:
|
||||
|
||||
Keyword arguments can contain special characters `# @ . - _`, so keywords like
|
||||
so are still valid:
|
||||
|
||||
|
@ -701,10 +705,462 @@ class Calendar(component.Component):
|
|||
return {
|
||||
"date": kwargs["my-date"],
|
||||
"id": kwargs["#some_id"],
|
||||
"@click.native": kwargs["@click.native"]
|
||||
"on_click": kwargs["@click.native"]
|
||||
}
|
||||
```
|
||||
|
||||
### Pass dictonary by its key-value pairs
|
||||
|
||||
_New in version 0.74_:
|
||||
|
||||
Sometimes, a component may expect a dictionary as one of its inputs.
|
||||
|
||||
Most commonly, this happens when a component accepts a dictionary
|
||||
of HTML attributes (usually called `attrs`) to pass to the underlying template.
|
||||
|
||||
In such cases, we may want to define some HTML attributes statically, and other dynamically.
|
||||
But for that, we need to define this dictionary on Python side:
|
||||
|
||||
```py
|
||||
@component.register("my_comp")
|
||||
class MyComp(component.Component):
|
||||
template = """
|
||||
{% component "other" attrs=attrs %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
|
||||
def get_context_data(self, some_id: str):
|
||||
attrs = {
|
||||
"class": "pa-4 flex",
|
||||
"data-some-id": some_id,
|
||||
"@click.stop": "onClickHandler",
|
||||
}
|
||||
return {"attrs": attrs}
|
||||
```
|
||||
|
||||
But as you can see in the case above, the event handler `@click.stop` and styling `pa-4 flex`
|
||||
are disconnected from the template. If the component grew in size and we moved the HTML
|
||||
to a separate file, we would have hard time reasoning about the component's template.
|
||||
|
||||
Luckily, there's a better way.
|
||||
|
||||
When we want to pass a dictionary to a component, we can define individual key-value pairs
|
||||
as component kwargs, so we can keep all the relevant information in the template. For that,
|
||||
we prefix the key with the name of the dict and `:`. So key `class` of input `attrs` becomes
|
||||
`attrs:class`. And our example becomes:
|
||||
|
||||
```py
|
||||
@component.register("my_comp")
|
||||
class MyComp(component.Component):
|
||||
template = """
|
||||
{% component "other"
|
||||
attrs:class="pa-4 flex"
|
||||
attrs:data-some-id=some_id
|
||||
attrs:@click.stop="onClickHandler"
|
||||
%}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
|
||||
def get_context_data(self, some_id: str):
|
||||
return {"some_id": some_id}
|
||||
```
|
||||
|
||||
Sweet! Now all the relevant HTML is inside the template, and we can move it to a separate file with confidence:
|
||||
|
||||
```django
|
||||
{% component "other"
|
||||
attrs:class="pa-4 flex"
|
||||
attrs:data-some-id=some_id
|
||||
attrs:@click.stop="onClickHandler"
|
||||
%}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
> Note: It is NOT possible to define nested dictionaries, so
|
||||
`attrs:my_key:two=2` would be interpreted as:
|
||||
>
|
||||
> ```py
|
||||
> {"attrs": {"my_key:two": 2}}
|
||||
> ```
|
||||
|
||||
## Rendering HTML attributes
|
||||
|
||||
_New in version 0.74_:
|
||||
|
||||
You can use the `html_attrs` tag to render HTML attributes, given a dictionary
|
||||
of values.
|
||||
|
||||
So if you have a template:
|
||||
|
||||
```django
|
||||
<div class="{{ classes }}" data-id="{{ my_id }}">
|
||||
</div>
|
||||
```
|
||||
|
||||
You can simplify it with `html_attrs` tag:
|
||||
|
||||
```django
|
||||
<div {% html_attrs attrs %}>
|
||||
</div>
|
||||
```
|
||||
|
||||
where `attrs` is:
|
||||
|
||||
```py
|
||||
attrs = {
|
||||
"class": classes,
|
||||
"data-id": my_id,
|
||||
}
|
||||
```
|
||||
|
||||
This feature is inspired by [`merge_attrs` tag of django-web-components](https://github.com/Xzya/django-web-components/tree/master?tab=readme-ov-file#default--merged-attributes) and
|
||||
["fallthrough attributes" feature of Vue](https://vuejs.org/guide/components/attrs).
|
||||
|
||||
### Removing atttributes
|
||||
|
||||
Attributes that are set to `None` or `False` are NOT rendered.
|
||||
|
||||
So given this input:
|
||||
|
||||
```py
|
||||
attrs = {
|
||||
"class": "text-green",
|
||||
"required": False,
|
||||
"data-id": None,
|
||||
}
|
||||
```
|
||||
|
||||
And template:
|
||||
|
||||
```django
|
||||
<div {% html_attrs attrs %}>
|
||||
</div>
|
||||
```
|
||||
|
||||
Then this renders:
|
||||
|
||||
```html
|
||||
<div class="text-green">
|
||||
</div>
|
||||
```
|
||||
|
||||
### Boolean attributes
|
||||
|
||||
In HTML, boolean attributes are usually rendered with no value. Consider the example below where the first button is disabled and the second is not:
|
||||
|
||||
```html
|
||||
<button disabled> Click me! </button>
|
||||
<button> Click me! </button>
|
||||
```
|
||||
|
||||
HTML rendering with `html_attrs` tag or `attributes_to_string` works the same way, where `key=True` is rendered simply as `key`, and `key=False` is not render at all.
|
||||
|
||||
So given this input:
|
||||
|
||||
```py
|
||||
attrs = {
|
||||
"disabled": True,
|
||||
"autofocus": False,
|
||||
}
|
||||
```
|
||||
|
||||
And template:
|
||||
|
||||
```django
|
||||
<div {% html_attrs attrs %}>
|
||||
</div>
|
||||
```
|
||||
|
||||
Then this renders:
|
||||
|
||||
```html
|
||||
<div disabled>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Default attributes
|
||||
|
||||
Sometimes you may want to specify default values for attributes. You can pass a second argument (or kwarg `defaults`) to set the defaults.
|
||||
|
||||
```django
|
||||
<div {% html_attrs attrs defaults %}>
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
In the example above, if `attrs` contains e.g. the `class` key, `html_attrs` will render:
|
||||
|
||||
`class="{{ attrs.class }}"`
|
||||
|
||||
Otherwise, `html_attrs` will render:
|
||||
|
||||
`class="{{ defaults.class }}"`
|
||||
|
||||
### Appending attributes
|
||||
|
||||
For the `class` HTML attribute, it's common that we want to _join_ multiple values,
|
||||
instead of overriding them. For example, if you're authoring a component, you may
|
||||
want to ensure that the component will ALWAYS have a specific class. Yet, you may
|
||||
want to allow users of your component to supply their own classes.
|
||||
|
||||
We can achieve this by adding extra kwargs. These values
|
||||
will be appended, instead of overwriting the previous value.
|
||||
|
||||
So if we have a variable `attrs`:
|
||||
```py
|
||||
attrs = {
|
||||
"class": "my-class pa-4",
|
||||
}
|
||||
```
|
||||
|
||||
And on `html_attrs` tag, we set the key `class`:
|
||||
|
||||
```django
|
||||
<div {% html_attrs attrs class="some-class" %}>
|
||||
</div>
|
||||
```
|
||||
|
||||
Then these will be merged and rendered as:
|
||||
|
||||
```html
|
||||
<div data-value="my-class pa-4 some-class">
|
||||
</div>
|
||||
```
|
||||
|
||||
To simplify merging of variables, you can supply the same key multiple times, and these will be all joined together:
|
||||
|
||||
```django
|
||||
{# my_var = "class-from-var text-red" #}
|
||||
<div {% html_attrs attrs class="some-class another-class" class=my_var %}>
|
||||
</div>
|
||||
```
|
||||
|
||||
Renders:
|
||||
|
||||
```html
|
||||
<div data-value="my-class pa-4 some-class another-class class-from-var text-red">
|
||||
</div>
|
||||
```
|
||||
|
||||
### Rules for `html_attrs`
|
||||
|
||||
1. Both `attrs` and `defaults` can be passed as positional args
|
||||
|
||||
`{% html_attrs attrs defaults key=val %}`
|
||||
|
||||
or as kwargs
|
||||
|
||||
`{% html_attrs key=val defaults=defaults attrs=attrs %}`
|
||||
|
||||
2. Both `attrs` and `defaults` are optional (can be omitted)
|
||||
|
||||
3. Both `attrs` and `defaults` are dictionaries, and we can define them the same way [we define dictionaries for the `component` tag](#pass-dictonary-by-its-key-value-pairs). So either as `attrs=attrs` or `attrs:key=value`.
|
||||
|
||||
4. All other kwargs are appended and can be repeated.
|
||||
|
||||
### Examples for `html_attrs`
|
||||
|
||||
Assuming that:
|
||||
|
||||
```py
|
||||
class_from_var = "from-var"
|
||||
|
||||
attrs = {
|
||||
"class": "from-attrs",
|
||||
"type": "submit",
|
||||
}
|
||||
|
||||
defaults = {
|
||||
"class": "from-defaults",
|
||||
"role": "button",
|
||||
}
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
- Empty tag <br/>
|
||||
`{% html_attr %}`
|
||||
|
||||
renders (empty string): <br/>
|
||||
` `
|
||||
|
||||
- Only kwargs <br/>
|
||||
`{% html_attr class="some-class" class=class_from_var data-id="123" %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="some-class from-var" data-id="123"`
|
||||
|
||||
- Only attrs <br/>
|
||||
`{% html_attr attrs %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-attrs" type="submit"`
|
||||
|
||||
- Attrs as kwarg <br/>
|
||||
`{% html_attr attrs=attrs %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-attrs" type="submit"`
|
||||
|
||||
- Only defaults (as kwarg) <br/>
|
||||
`{% html_attr defaults=defaults %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-defaults" role="button"`
|
||||
|
||||
- Attrs using the `prefix:key=value` construct <br/>
|
||||
`{% html_attr attrs:class="from-attrs" attrs:type="submit" %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-attrs" type="submit"`
|
||||
|
||||
- Defaults using the `prefix:key=value` construct <br/>
|
||||
`{% html_attr defaults:class="from-defaults" %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-defaults" role="button"`
|
||||
|
||||
- All together (1) - attrs and defaults as positional args: <br/>
|
||||
`{% html_attrs attrs defaults class="added_class" class=class_from_var data-id=123 %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-attrs added_class from-var" type="submit" role="button" data-id=123`
|
||||
|
||||
- All together (2) - attrs and defaults as kwargs args: <br/>
|
||||
`{% html_attrs class="added_class" class=class_from_var data-id=123 attrs=attrs defaults=defaults %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-attrs added_class from-var" type="submit" role="button" data-id=123`
|
||||
|
||||
- All together (3) - mixed: <br/>
|
||||
`{% html_attrs attrs defaults:class="default-class" class="added_class" class=class_from_var data-id=123 %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-attrs added_class from-var" type="submit" data-id=123`
|
||||
|
||||
### Full example for `html_attrs`
|
||||
|
||||
```py
|
||||
@component.register("my_comp")
|
||||
class MyComp(component.Component):
|
||||
template: t.django_html = """
|
||||
<div
|
||||
{% html_attrs attrs
|
||||
defaults:class="pa-4 text-red"
|
||||
class="my-comp-date"
|
||||
class=class_from_var
|
||||
data-id="123"
|
||||
%}
|
||||
>
|
||||
Today's date is <span>{{ date }}</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_context_data(self, date: Date, attrs: dict):
|
||||
return {
|
||||
"date": date,
|
||||
"attrs": attrs,
|
||||
"class_from_var": "extra-class"
|
||||
}
|
||||
|
||||
@component.register("parent")
|
||||
class Parent(component.Component):
|
||||
template: t.django_html = """
|
||||
{% component "my_comp"
|
||||
date=date
|
||||
attrs:class="pa-0 border-solid border-red"
|
||||
attrs:data-json=json_data
|
||||
attrs:@click="(e) => onClick(e, 'from_parent')"
|
||||
%}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
|
||||
def get_context_data(self, date: Date):
|
||||
return {
|
||||
"date": datetime.now(),
|
||||
"json_data": json.dumps({"value": 456})
|
||||
}
|
||||
```
|
||||
|
||||
Note: For readability, we've split the tags across multiple lines.
|
||||
|
||||
Inside `MyComp`, we defined a default attribute
|
||||
|
||||
`defaults:class="pa-4 text-red"`
|
||||
|
||||
So if `attrs` includes key `class`, the default above will be ignored.
|
||||
|
||||
`MyComp` also defines `class` key twice. It means that whether the `class`
|
||||
attribute is taken from `attrs` or `defaults`, the two `class` values
|
||||
will be appended to it.
|
||||
|
||||
So by default, `MyComp` renders:
|
||||
|
||||
```html
|
||||
<div class="pa-4 text-red my-comp-date extra-class" data-id="123">
|
||||
...
|
||||
```
|
||||
|
||||
Next, let's consider what will be rendered when we call `MyComp` from `Parent`
|
||||
component.
|
||||
|
||||
`MyComp` accepts a `attrs` dictionary, that is passed to `html_attrs`, so the
|
||||
contents of that dictionary are rendered as the HTML attributes.
|
||||
|
||||
In `Parent`, we make use of passing dictionary key-value pairs as kwargs to define
|
||||
individual attributes as if they were regular kwargs.
|
||||
|
||||
So all kwargs that start with `attrs:` will be collected into an `attrs` dict.
|
||||
|
||||
```django
|
||||
attrs:class="pa-0 border-solid border-red"
|
||||
attrs:data-json=json_data
|
||||
attrs:@click="(e) => onClick(e, 'from_parent')"
|
||||
```
|
||||
|
||||
And `get_context_data` of `MyComp` will receive `attrs` input with following keys:
|
||||
|
||||
```py
|
||||
attrs = {
|
||||
"class": "pa-0 border-solid",
|
||||
"data-json": '{"value": 456}',
|
||||
"@click": "(e) => onClick(e, 'from_parent')",
|
||||
}
|
||||
```
|
||||
|
||||
`attrs["class"]` overrides the default value for `class`, whereas other keys
|
||||
will be merged.
|
||||
|
||||
So in the end `MyComp` will render:
|
||||
```html
|
||||
<div
|
||||
class="pa-0 border-solid my-comp-date extra-class"
|
||||
data-id="123"
|
||||
data-json='{"value": 456}'
|
||||
@click="(e) => onClick(e, 'from_parent')"
|
||||
>
|
||||
...
|
||||
```
|
||||
|
||||
### Rendering HTML attributes outside of templates
|
||||
|
||||
If you need to use serialize HTML attributes outside of Django template and the `html_attrs` tag, you can use `attributes_to_string`:
|
||||
|
||||
```py
|
||||
from django_components.attributes import attributes_to_string
|
||||
|
||||
attrs = {
|
||||
"class": "my-class text-red pa-4",
|
||||
"data-id": 123,
|
||||
"required": True,
|
||||
"disabled": False,
|
||||
"ignored-attr": None,
|
||||
}
|
||||
|
||||
attributes_to_string(attrs)
|
||||
# 'class="my-class text-red pa-4" data-id="123" required'
|
||||
```
|
||||
|
||||
## Component context and scope
|
||||
|
||||
By default, components are ISOLATED and CANNOT access context variables from the parent template. This is useful if you want to make sure that components don't accidentally access the outer context.
|
||||
|
|
96
src/django_components/attributes.py
Normal file
96
src/django_components/attributes.py
Normal file
|
@ -0,0 +1,96 @@
|
|||
# Initial implementation based on attributes.py from django-web-components
|
||||
# See https://github.com/Xzya/django-web-components/blob/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/templatetags/components.py # noqa: E501
|
||||
# And https://github.com/Xzya/django-web-components/blob/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/attributes.py # noqa: E501
|
||||
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
from django.template import Context, Node
|
||||
from django.template.base import FilterExpression
|
||||
from django.utils.html import conditional_escape, format_html
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components.template_parser import process_aggregate_kwargs
|
||||
|
||||
|
||||
class HtmlAttrsNode(Node):
|
||||
def __init__(
|
||||
self,
|
||||
attributes: Optional[FilterExpression],
|
||||
default_attrs: Optional[FilterExpression],
|
||||
kwargs: List[Tuple[str, FilterExpression]],
|
||||
):
|
||||
self.attributes = attributes
|
||||
self.default_attrs = default_attrs
|
||||
self.kwargs = kwargs
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
append_attrs: List[Tuple[str, Any]] = []
|
||||
attrs_and_defaults_from_kwargs = {}
|
||||
|
||||
# Resolve kwargs, while also extracting attrs and defaults keys
|
||||
for key, value in self.kwargs:
|
||||
resolved_value = value.resolve(context)
|
||||
if key.startswith("attrs:") or key.startswith("defaults:"):
|
||||
attrs_and_defaults_from_kwargs[key] = resolved_value
|
||||
continue
|
||||
# NOTE: These were already extracted into separate variables, so
|
||||
# ignore them here.
|
||||
elif key == "attrs" or key == "defaults":
|
||||
continue
|
||||
|
||||
append_attrs.append((key, resolved_value))
|
||||
|
||||
# NOTE: Here we delegate validation to `process_aggregate_kwargs`, which should
|
||||
# raise error if the dict includes both `attrs` and `attrs:` keys.
|
||||
#
|
||||
# So by assigning the `attrs` and `defaults` keys, users are forced to use only
|
||||
# one approach or the other, but not both simultaneously.
|
||||
if self.attributes:
|
||||
attrs_and_defaults_from_kwargs["attrs"] = self.attributes.resolve(context)
|
||||
if self.default_attrs:
|
||||
attrs_and_defaults_from_kwargs["defaults"] = self.default_attrs.resolve(context)
|
||||
|
||||
# Turn `{"attrs:blabla": 1}` into `{"attrs": {"blabla": 1}}`
|
||||
attrs_and_defaults_from_kwargs = process_aggregate_kwargs(attrs_and_defaults_from_kwargs)
|
||||
|
||||
# NOTE: We want to allow to use `html_attrs` even without `attrs` or `defaults` params
|
||||
attrs = attrs_and_defaults_from_kwargs.get("attrs", {})
|
||||
default_attrs = attrs_and_defaults_from_kwargs.get("defualts", {})
|
||||
|
||||
final_attrs = {**default_attrs, **attrs}
|
||||
final_attrs = append_attributes(*final_attrs.items(), *append_attrs)
|
||||
|
||||
return attributes_to_string(final_attrs)
|
||||
|
||||
|
||||
def attributes_to_string(attributes: Mapping[str, Any]) -> str:
|
||||
"""Convert a dict of attributes to a string."""
|
||||
attr_list = []
|
||||
|
||||
for key, value in attributes.items():
|
||||
if value is None or value is False:
|
||||
continue
|
||||
if value is True:
|
||||
attr_list.append(conditional_escape(key))
|
||||
else:
|
||||
attr_list.append(format_html('{}="{}"', key, value))
|
||||
|
||||
return mark_safe(SafeString(" ").join(attr_list))
|
||||
|
||||
|
||||
def append_attributes(*args: Tuple[str, Any]) -> Dict:
|
||||
"""
|
||||
Merges the key-value pairs and returns a new dictionary.
|
||||
|
||||
If a key is present multiple times, its values are concatenated with a space
|
||||
character as separator in the final dictionary.
|
||||
"""
|
||||
result: Dict = {}
|
||||
|
||||
for key, value in args:
|
||||
if key in result:
|
||||
result[key] += " " + value
|
||||
else:
|
||||
result[key] = value
|
||||
|
||||
return result
|
|
@ -31,6 +31,7 @@ from django_components.context import (
|
|||
from django_components.logger import logger, trace_msg
|
||||
from django_components.middleware import is_dependency_middleware_active
|
||||
from django_components.slots import DEFAULT_SLOT_KEY, FillContent, FillNode, SlotName, resolve_slots
|
||||
from django_components.template_parser import process_aggregate_kwargs
|
||||
from django_components.utils import gen_id, search
|
||||
|
||||
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
|
||||
|
@ -253,11 +254,14 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
|||
f"Note: this attribute is not required if you are overriding the class's `get_template*()` methods."
|
||||
)
|
||||
|
||||
def render_from_input(self, context: Context, args: Union[List, Tuple], kwargs: Dict) -> str:
|
||||
def render_from_input(self, context: Context, args: Union[List, Tuple], kwargs: Dict[str, Any]) -> str:
|
||||
component_context: dict = self.get_context_data(*args, **kwargs)
|
||||
|
||||
with context.update(component_context):
|
||||
rendered_component = self.render(context, context_data=component_context)
|
||||
rendered_component = self.render(
|
||||
context=context,
|
||||
context_data=component_context,
|
||||
)
|
||||
|
||||
if is_dependency_middleware_active():
|
||||
output = RENDERED_COMMENT_TEMPLATE.format(name=self.registered_name) + rendered_component
|
||||
|
@ -394,6 +398,7 @@ class ComponentNode(Node):
|
|||
# to get values to insert into the context
|
||||
resolved_context_args = safe_resolve_list(self.context_args, context)
|
||||
resolved_context_kwargs = safe_resolve_dict(self.context_kwargs, context)
|
||||
resolved_context_kwargs = process_aggregate_kwargs(resolved_context_kwargs)
|
||||
|
||||
is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
|
||||
if is_default_slot:
|
||||
|
|
|
@ -5,7 +5,7 @@ Based on Django Slippers v0.6.2 - https://github.com/mixxorz/slippers/blob/main/
|
|||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple
|
||||
from typing import Any, Dict, List, Mapping, Tuple
|
||||
|
||||
from django.template.base import (
|
||||
FILTER_ARGUMENT_SEPARATOR,
|
||||
|
@ -154,7 +154,7 @@ def parse_bits(
|
|||
bits: List[str],
|
||||
params: List[str],
|
||||
name: str,
|
||||
) -> Tuple[List, Dict]:
|
||||
) -> Tuple[List[FilterExpression], List[Tuple[str, FilterExpression]]]:
|
||||
"""
|
||||
Parse bits for template tag helpers simple_tag and inclusion_tag, in
|
||||
particular by detecting syntax errors and by extracting positional and
|
||||
|
@ -162,9 +162,13 @@ def parse_bits(
|
|||
|
||||
This is a simplified version of `django.template.library.parse_bits`
|
||||
where we use custom regex to handle special characters in keyword names.
|
||||
|
||||
Furthermore, our version allows duplicate keys, and instead of return kwargs
|
||||
as a dict, we return it as a list of key-value pairs. So it is up to the
|
||||
user of this function to decide whether they support duplicate keys or not.
|
||||
"""
|
||||
args = []
|
||||
kwargs = {}
|
||||
args: List[FilterExpression] = []
|
||||
kwargs: List[Tuple[str, FilterExpression]] = []
|
||||
unhandled_params = list(params)
|
||||
for bit in bits:
|
||||
# First we try to extract a potential kwarg from the bit
|
||||
|
@ -172,16 +176,12 @@ def parse_bits(
|
|||
if kwarg:
|
||||
# The kwarg was successfully extracted
|
||||
param, value = kwarg.popitem()
|
||||
if param in kwargs:
|
||||
# The keyword argument has already been supplied once
|
||||
raise TemplateSyntaxError("'%s' received multiple values for keyword argument '%s'" % (name, param))
|
||||
else:
|
||||
# All good, record the keyword argument
|
||||
kwargs[str(param)] = value
|
||||
if param in unhandled_params:
|
||||
# If using the keyword syntax for a positional arg, then
|
||||
# consume it.
|
||||
unhandled_params.remove(param)
|
||||
# All good, record the keyword argument
|
||||
kwargs.append((str(param), value))
|
||||
if param in unhandled_params:
|
||||
# If using the keyword syntax for a positional arg, then
|
||||
# consume it.
|
||||
unhandled_params.remove(param)
|
||||
else:
|
||||
if kwargs:
|
||||
raise TemplateSyntaxError(
|
||||
|
@ -202,3 +202,82 @@ def parse_bits(
|
|||
% (name, ", ".join("'%s'" % p for p in unhandled_params))
|
||||
)
|
||||
return args, kwargs
|
||||
|
||||
|
||||
def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs
|
||||
start with some prefix delimited with `:` (e.g. `attrs:`).
|
||||
|
||||
Example:
|
||||
```py
|
||||
process_component_kwargs({"abc:one": 1, "abc:two": 2, "def:three": 3, "four": 4})
|
||||
# {"abc": {"one": 1, "two": 2}, "def": {"three": 3}, "four": 4}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
We want to support a use case similar to Vue's fallthrough attributes.
|
||||
In other words, where a component author can designate a prop (input)
|
||||
which is a dict and which will be rendered as HTML attributes.
|
||||
|
||||
This is useful for allowing component users to tweak styling or add
|
||||
event handling to the underlying HTML. E.g.:
|
||||
|
||||
`class="pa-4 d-flex text-black"` or `@click.stop="alert('clicked!')"`
|
||||
|
||||
So if the prop is `attrs`, and the component is called like so:
|
||||
```django
|
||||
{% component "my_comp" attrs=attrs %}
|
||||
```
|
||||
|
||||
then, if `attrs` is:
|
||||
```py
|
||||
{"class": "text-red pa-4", "@click": "dispatch('my_event', 123)"}
|
||||
```
|
||||
|
||||
and the component template is:
|
||||
```django
|
||||
<div {% html_attrs attrs add:class="extra-class" %}></div>
|
||||
```
|
||||
|
||||
Then this renders:
|
||||
```html
|
||||
<div class="text-red pa-4 extra-class" @click="dispatch('my_event', 123)" ></div>
|
||||
```
|
||||
|
||||
However, this way it is difficult for the component user to define the `attrs`
|
||||
variable, especially if they want to combine static and dynamic values. Because
|
||||
they will need to pre-process the `attrs` dict.
|
||||
|
||||
So, instead, we allow to "aggregate" props into a dict. So all props that start
|
||||
with `attrs:`, like `attrs:class="text-red"`, will be collected into a dict
|
||||
at key `attrs`.
|
||||
|
||||
This provides sufficient flexiblity to make it easy for component users to provide
|
||||
"fallthrough attributes", and sufficiently easy for component authors to process
|
||||
that input while still being able to provide their own keys.
|
||||
"""
|
||||
processed_kwargs = {}
|
||||
nested_kwargs: Dict[str, Dict[str, Any]] = {}
|
||||
for key, val in kwargs.items():
|
||||
if ":" not in key:
|
||||
processed_kwargs[key] = val
|
||||
continue
|
||||
|
||||
# NOTE: Trim off the prefix from keys
|
||||
prefix, sub_key = key.split(":", 1)
|
||||
if prefix not in nested_kwargs:
|
||||
nested_kwargs[prefix] = {}
|
||||
nested_kwargs[prefix][sub_key] = val
|
||||
|
||||
# Assign aggregated values into normal input
|
||||
for key, val in nested_kwargs.items():
|
||||
if key in processed_kwargs:
|
||||
raise TemplateSyntaxError(
|
||||
f"Received argument '{key}' both as a regular input ({key}=...)"
|
||||
f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two"
|
||||
)
|
||||
processed_kwargs[key] = val
|
||||
|
||||
return processed_kwargs
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from typing import TYPE_CHECKING, List, Mapping, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
import django.template
|
||||
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode, Token, TokenType
|
||||
|
@ -6,6 +6,7 @@ from django.template.exceptions import TemplateSyntaxError
|
|||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components.app_settings import ContextBehavior, app_settings
|
||||
from django_components.attributes import HtmlAttrsNode
|
||||
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
|
||||
from django_components.component_registry import ComponentRegistry
|
||||
from django_components.component_registry import registry as component_registry
|
||||
|
@ -248,6 +249,39 @@ def do_component(parser: Parser, token: Token) -> ComponentNode:
|
|||
return component_node
|
||||
|
||||
|
||||
@register.tag("html_attrs")
|
||||
def do_html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
|
||||
"""
|
||||
This tag takes:
|
||||
- Optional dictionary of attributes (`attrs`)
|
||||
- Optional dictionary of defaults (`defaults`)
|
||||
- Additional kwargs that are appended to the former two
|
||||
|
||||
The inputs are merged and resulting dict is rendered as HTML attributes
|
||||
(`key="value"`).
|
||||
|
||||
Rules:
|
||||
1. Both `attrs` and `defaults` can be passed as positional args or as kwargs
|
||||
2. Both `attrs` and `defaults` are optional (can be omitted)
|
||||
3. Both `attrs` and `defaults` are dictionaries, and we can define them the same way
|
||||
we define dictionaries for the `component` tag. So either as `attrs=attrs` or
|
||||
`attrs:key=value`.
|
||||
4. All other kwargs (`key=value`) are appended and can be repeated.
|
||||
|
||||
Normal kwargs (`key=value`) are concatenated to existing keys. So if e.g. key
|
||||
"class" is supplied with value "my-class", then adding `class="extra-class"`
|
||||
will result in `class="my-class extra-class".
|
||||
|
||||
Example:
|
||||
```django
|
||||
{% html_attrs attrs defaults:class="default-class" class="extra-class" data-id="123" %}
|
||||
```
|
||||
"""
|
||||
bits = token.split_contents()
|
||||
attributes, default_attrs, append_attrs = parse_html_attrs_args(parser, bits, "html_attrs")
|
||||
return HtmlAttrsNode(attributes, default_attrs, append_attrs)
|
||||
|
||||
|
||||
def is_whitespace_node(node: Node) -> bool:
|
||||
return isinstance(node, TextNode) and node.s.isspace()
|
||||
|
||||
|
@ -275,29 +309,86 @@ def check_for_isolated_context_keyword(bits: List[str]) -> Tuple[List[str], bool
|
|||
def parse_component_with_args(
|
||||
parser: Parser, bits: List[str], tag_name: str
|
||||
) -> Tuple[str, List[FilterExpression], Mapping[str, FilterExpression]]:
|
||||
tag_args, tag_kwargs = parse_bits(
|
||||
tag_args, tag_kwarg_pairs = parse_bits(
|
||||
parser=parser,
|
||||
bits=bits,
|
||||
params=["tag_name", "name"],
|
||||
name=tag_name,
|
||||
)
|
||||
|
||||
tag_kwargs = {}
|
||||
for key, val in tag_kwarg_pairs:
|
||||
if key in tag_kwargs:
|
||||
# The keyword argument has already been supplied once
|
||||
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
|
||||
tag_kwargs[key] = val
|
||||
|
||||
if tag_name != tag_args[0].token:
|
||||
raise RuntimeError(f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}")
|
||||
if len(tag_args) > 1:
|
||||
# At least one position arg, so take the first as the component name
|
||||
component_name = tag_args[1].token
|
||||
context_args = tag_args[2:]
|
||||
context_kwargs = tag_kwargs
|
||||
else: # No positional args, so look for component name as keyword arg
|
||||
try:
|
||||
component_name = tag_kwargs.pop("name").token
|
||||
context_args = []
|
||||
context_kwargs = tag_kwargs
|
||||
except IndexError:
|
||||
raise TemplateSyntaxError(f"Call the '{tag_name}' tag with a component name as the first parameter")
|
||||
|
||||
return component_name, context_args, context_kwargs
|
||||
component_name = _get_positional_param(tag_name, "name", 1, tag_args, tag_kwargs).token
|
||||
if len(tag_args) > 1:
|
||||
# Positional args given. Skip tag and component name and take the rest
|
||||
context_args = tag_args[2:]
|
||||
else: # No positional args
|
||||
context_args = []
|
||||
|
||||
return component_name, context_args, tag_kwargs
|
||||
|
||||
|
||||
def parse_html_attrs_args(
|
||||
parser: Parser, bits: List[str], tag_name: str
|
||||
) -> Tuple[Optional[FilterExpression], Optional[FilterExpression], List[Tuple[str, FilterExpression]]]:
|
||||
tag_args, tag_kwarg_pairs = parse_bits(
|
||||
parser=parser,
|
||||
bits=bits,
|
||||
params=["tag_name"],
|
||||
name=tag_name,
|
||||
)
|
||||
|
||||
# NOTE: Unlike in the `component` tag, in this case we don't care about duplicates,
|
||||
# as we're constructing the dict simply to find the `attrs` kwarg.
|
||||
tag_kwargs = {key: val for key, val in tag_kwarg_pairs}
|
||||
|
||||
if tag_name != tag_args[0].token:
|
||||
raise RuntimeError(f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}")
|
||||
|
||||
# Allow to optioanlly provide `attrs` as positional arg `{% html_attrs attrs %}`
|
||||
try:
|
||||
attrs = _get_positional_param(tag_name, "attrs", 1, tag_args, tag_kwargs)
|
||||
except TemplateSyntaxError:
|
||||
attrs = None
|
||||
|
||||
# Allow to optionally provide `defaults` as positional arg `{% html_attrs attrs defaults %}`
|
||||
try:
|
||||
defaults = _get_positional_param(tag_name, "defaults", 2, tag_args, tag_kwargs)
|
||||
except TemplateSyntaxError:
|
||||
defaults = None
|
||||
|
||||
# Allow only up to 2 positional args - [0] == tag name, [1] == attrs, [2] == defaults
|
||||
if len(tag_args) > 3:
|
||||
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {tag_args[2:]}")
|
||||
|
||||
return attrs, defaults, tag_kwarg_pairs
|
||||
|
||||
|
||||
def _get_positional_param(
|
||||
tag_name: str,
|
||||
param_name: str,
|
||||
param_index: int,
|
||||
args: List[FilterExpression],
|
||||
kwargs: Dict[str, FilterExpression],
|
||||
) -> FilterExpression:
|
||||
# Param is given as positional arg, e.g. `{% tag param %}`
|
||||
if len(args) > param_index:
|
||||
param = args[param_index]
|
||||
return param
|
||||
# Check if param was given as kwarg, e.g. `{% tag param_name=param %}`
|
||||
elif param_name in kwargs:
|
||||
param = kwargs.pop(param_name)
|
||||
return param
|
||||
|
||||
raise TemplateSyntaxError(f"Param '{param_name}' not found in '{tag_name}' tag")
|
||||
|
||||
|
||||
def is_wrapped_in_quotes(s: str) -> bool:
|
||||
|
|
364
tests/test_attributes.py
Normal file
364
tests/test_attributes.py
Normal file
|
@ -0,0 +1,364 @@
|
|||
from django.template import Context, Template, TemplateSyntaxError
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components import component, types
|
||||
from django_components.attributes import append_attributes, attributes_to_string
|
||||
|
||||
# isort: off
|
||||
from .django_test_setup import * # NOQA
|
||||
from .testutils import BaseTestCase
|
||||
|
||||
# isort: on
|
||||
|
||||
|
||||
class AttributesToStringTest(BaseTestCase):
|
||||
def test_simple_attribute(self):
|
||||
self.assertEqual(
|
||||
attributes_to_string({"foo": "bar"}),
|
||||
'foo="bar"',
|
||||
)
|
||||
|
||||
def test_multiple_attributes(self):
|
||||
self.assertEqual(
|
||||
attributes_to_string({"class": "foo", "style": "color: red;"}),
|
||||
'class="foo" style="color: red;"',
|
||||
)
|
||||
|
||||
def test_escapes_special_characters(self):
|
||||
self.assertEqual(
|
||||
attributes_to_string({"x-on:click": "bar", "@click": "'baz'"}),
|
||||
'x-on:click="bar" @click="'baz'"',
|
||||
)
|
||||
|
||||
def test_does_not_escape_special_characters_if_safe_string(self):
|
||||
self.assertEqual(
|
||||
attributes_to_string({"foo": mark_safe("'bar'")}),
|
||||
"foo=\"'bar'\"",
|
||||
)
|
||||
|
||||
def test_result_is_safe_string(self):
|
||||
result = attributes_to_string({"foo": mark_safe("'bar'")})
|
||||
self.assertTrue(isinstance(result, SafeString))
|
||||
|
||||
def test_attribute_with_no_value(self):
|
||||
self.assertEqual(
|
||||
attributes_to_string({"required": None}),
|
||||
"",
|
||||
)
|
||||
|
||||
def test_attribute_with_false_value(self):
|
||||
self.assertEqual(
|
||||
attributes_to_string({"required": False}),
|
||||
"",
|
||||
)
|
||||
|
||||
def test_attribute_with_true_value(self):
|
||||
self.assertEqual(
|
||||
attributes_to_string({"required": True}),
|
||||
"required",
|
||||
)
|
||||
|
||||
|
||||
class AppendAttributesTest(BaseTestCase):
|
||||
def test_single_dict(self):
|
||||
self.assertEqual(
|
||||
append_attributes(("foo", "bar")),
|
||||
{"foo": "bar"},
|
||||
)
|
||||
|
||||
def test_appends_dicts(self):
|
||||
self.assertEqual(
|
||||
append_attributes(("class", "foo"), ("id", "bar"), ("class", "baz")),
|
||||
{"class": "foo baz", "id": "bar"},
|
||||
)
|
||||
|
||||
|
||||
class HtmlAttrsTests(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "test" attrs:@click.stop="dispatch('click_event')" attrs:x-data="{hello: 'world'}" attrs:class=class_var %}
|
||||
{% endcomponent %}
|
||||
""" # noqa: E501
|
||||
|
||||
def test_tag_positional_args(self):
|
||||
@component.register("test")
|
||||
class AttrsComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div {% html_attrs attrs defaults class="added_class" class="another-class" data-id=123 %}>
|
||||
content
|
||||
</div>
|
||||
""" # noqa: E501
|
||||
|
||||
def get_context_data(self, *args, attrs):
|
||||
return {
|
||||
"attrs": attrs,
|
||||
"defaults": {"class": "override-me"},
|
||||
}
|
||||
|
||||
template = Template(self.template_str)
|
||||
rendered = template.render(Context({"class_var": "padding-top-8"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div @click.stop="dispatch('click_event')" x-data="{hello: 'world'}" class="padding-top-8 added_class another-class" data-id=123>
|
||||
content
|
||||
</div>
|
||||
""", # noqa: E501
|
||||
)
|
||||
self.assertNotIn("override-me", rendered)
|
||||
|
||||
def test_tag_raises_on_extra_positional_args(self):
|
||||
@component.register("test")
|
||||
class AttrsComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div {% html_attrs attrs defaults class %}>
|
||||
content
|
||||
</div>
|
||||
""" # noqa: E501
|
||||
|
||||
def get_context_data(self, *args, attrs):
|
||||
return {
|
||||
"attrs": attrs,
|
||||
"defaults": {"class": "override-me"},
|
||||
"class": "123 457",
|
||||
}
|
||||
|
||||
template = Template(self.template_str)
|
||||
|
||||
with self.assertRaisesMessage(
|
||||
TemplateSyntaxError, "Tag 'html_attrs' received unexpected positional arguments"
|
||||
):
|
||||
template.render(Context({"class_var": "padding-top-8"}))
|
||||
|
||||
def test_tag_kwargs(self):
|
||||
@component.register("test")
|
||||
class AttrsComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div {% html_attrs attrs=attrs defaults=defaults class="added_class" class="another-class" data-id=123 %}>
|
||||
content
|
||||
</div>
|
||||
""" # noqa: E501
|
||||
|
||||
def get_context_data(self, *args, attrs):
|
||||
return {
|
||||
"attrs": attrs,
|
||||
"defaults": {"class": "override-me"},
|
||||
}
|
||||
|
||||
template = Template(self.template_str)
|
||||
rendered = template.render(Context({"class_var": "padding-top-8"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div @click.stop="dispatch('click_event')" class="added_class another-class padding-top-8" data-id="123" x-data="{hello: 'world'}">
|
||||
content
|
||||
</div>
|
||||
""", # noqa: E501
|
||||
)
|
||||
self.assertNotIn("override-me", rendered)
|
||||
|
||||
def test_tag_kwargs_2(self):
|
||||
@component.register("test")
|
||||
class AttrsComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div {% html_attrs class="added_class" class="another-class" data-id=123 defaults=defaults attrs=attrs %}>
|
||||
content
|
||||
</div>
|
||||
""" # noqa: E501
|
||||
|
||||
def get_context_data(self, *args, attrs):
|
||||
return {
|
||||
"attrs": attrs,
|
||||
"defaults": {"class": "override-me"},
|
||||
}
|
||||
|
||||
template = Template(self.template_str)
|
||||
rendered = template.render(Context({"class_var": "padding-top-8"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div @click.stop="dispatch('click_event')" x-data="{hello: 'world'}" class="padding-top-8 added_class another-class" data-id=123>
|
||||
content
|
||||
</div>
|
||||
""", # noqa: E501
|
||||
)
|
||||
self.assertNotIn("override-me", rendered)
|
||||
|
||||
def test_tag_aggregate_args(self):
|
||||
@component.register("test")
|
||||
class AttrsComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div {% html_attrs attrs:class="from_agg_key" attrs:type="submit" defaults:class="override-me" class="added_class" class="another-class" data-id=123 %}>
|
||||
content
|
||||
</div>
|
||||
""" # noqa: E501
|
||||
|
||||
def get_context_data(self, *args, attrs):
|
||||
return {"attrs": attrs}
|
||||
|
||||
template = Template(self.template_str)
|
||||
rendered = template.render(Context({"class_var": "padding-top-8"}))
|
||||
|
||||
# NOTE: The attrs from self.template_str should be ignored because they are not used.
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div class="added_class another-class from_agg_key" data-id="123" type="submit">
|
||||
content
|
||||
</div>
|
||||
""", # noqa: E501
|
||||
)
|
||||
self.assertNotIn("override-me", rendered)
|
||||
|
||||
def test_tag_raises_on_aggregate_and_positional_args_for_attrs(self):
|
||||
@component.register("test")
|
||||
class AttrsComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div {% html_attrs attrs attrs:class="from_agg_key" defaults:class="override-me" class="added_class" class="another-class" data-id=123 %}>
|
||||
content
|
||||
</div>
|
||||
""" # noqa: E501
|
||||
|
||||
def get_context_data(self, *args, attrs):
|
||||
return {"attrs": attrs}
|
||||
|
||||
template = Template(self.template_str)
|
||||
with self.assertRaisesMessage(TemplateSyntaxError, "Received argument 'attrs' both as a regular input"):
|
||||
template.render(Context({"class_var": "padding-top-8"}))
|
||||
|
||||
def test_tag_raises_on_aggregate_and_positional_args_for_defaults(self):
|
||||
@component.register("test")
|
||||
class AttrsComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div {% html_attrs defaults=defaults attrs:class="from_agg_key" defaults:class="override-me" class="added_class" class="another-class" data-id=123 %}>
|
||||
content
|
||||
</div>
|
||||
""" # noqa: E501
|
||||
|
||||
def get_context_data(self, *args, attrs):
|
||||
return {"attrs": attrs}
|
||||
|
||||
template = Template(self.template_str)
|
||||
with self.assertRaisesMessage(
|
||||
TemplateSyntaxError,
|
||||
"Received argument 'defaults' both as a regular input",
|
||||
):
|
||||
template.render(Context({"class_var": "padding-top-8"}))
|
||||
|
||||
def test_tag_no_attrs(self):
|
||||
@component.register("test")
|
||||
class AttrsComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div {% html_attrs defaults:class="override-me" class="added_class" class="another-class" data-id=123 %}>
|
||||
content
|
||||
</div>
|
||||
""" # noqa: E501
|
||||
|
||||
def get_context_data(self, *args, attrs):
|
||||
return {"attrs": attrs}
|
||||
|
||||
template = Template(self.template_str)
|
||||
rendered = template.render(Context({"class_var": "padding-top-8"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div class="added_class another-class" data-id=123>
|
||||
content
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
self.assertNotIn("override-me", rendered)
|
||||
|
||||
def test_tag_no_defaults(self):
|
||||
@component.register("test")
|
||||
class AttrsComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div {% html_attrs attrs class="added_class" class="another-class" data-id=123 %}>
|
||||
content
|
||||
</div>
|
||||
""" # noqa: E501
|
||||
|
||||
def get_context_data(self, *args, attrs):
|
||||
return {"attrs": attrs}
|
||||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "test" attrs:@click.stop="dispatch('click_event')" attrs:x-data="{hello: 'world'}" attrs:class=class_var %}
|
||||
{% endcomponent %}
|
||||
""" # noqa: E501
|
||||
template = Template(template_str)
|
||||
rendered = template.render(Context({"class_var": "padding-top-8"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div @click.stop="dispatch('click_event')" x-data="{hello: 'world'}" class="padding-top-8 added_class another-class" data-id=123>
|
||||
content
|
||||
</div>
|
||||
""", # noqa: E501
|
||||
)
|
||||
self.assertNotIn("override-me", rendered)
|
||||
|
||||
def test_tag_no_attrs_no_defaults(self):
|
||||
@component.register("test")
|
||||
class AttrsComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div {% html_attrs class="added_class" class="another-class" data-id=123 %}>
|
||||
content
|
||||
</div>
|
||||
""" # noqa: E501
|
||||
|
||||
def get_context_data(self, *args, attrs):
|
||||
return {"attrs": attrs}
|
||||
|
||||
template = Template(self.template_str)
|
||||
rendered = template.render(Context({"class_var": "padding-top-8"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div class="added_class another-class" data-id="123">
|
||||
content
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
self.assertNotIn("override-me", rendered)
|
||||
|
||||
def test_tag_empty(self):
|
||||
@component.register("test")
|
||||
class AttrsComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div {% html_attrs %}>
|
||||
content
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_context_data(self, *args, attrs):
|
||||
return {
|
||||
"attrs": attrs,
|
||||
"defaults": {"class": "override-me"},
|
||||
}
|
||||
|
||||
template = Template(self.template_str)
|
||||
rendered = template.render(Context({"class_var": "padding-top-8"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div >
|
||||
content
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
self.assertNotIn("override-me", rendered)
|
|
@ -986,3 +986,36 @@ class SlotBehaviorTests(BaseTestCase):
|
|||
</custom-template>
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
class AggregateInputTests(BaseTestCase):
|
||||
def test_agg_input_accessible_in_get_context_data(self):
|
||||
@component.register("test")
|
||||
class AttrsComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
attrs: {{ attrs|safe }}
|
||||
my_dict: {{ my_dict|safe }}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_context_data(self, *args, attrs, my_dict):
|
||||
return {"attrs": attrs, "my_dict": my_dict}
|
||||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "test" attrs:@click.stop="dispatch('click_event')" attrs:x-data="{hello: 'world'}" attrs:class=class_var my_dict:one=2 %}
|
||||
{% endcomponent %}
|
||||
""" # noqa: E501
|
||||
template = Template(template_str)
|
||||
rendered = template.render(Context({"class_var": "padding-top-8"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div>
|
||||
attrs: {'@click.stop': "dispatch('click_event')", 'x-data': "{hello: 'world'}", 'class': 'padding-top-8'}
|
||||
my_dict: {'one': 2}
|
||||
</div>
|
||||
""", # noqa: E501
|
||||
)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue