mirror of
https://github.com/django-components/django-components.git
synced 2025-09-25 06:59:10 +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
|
## Release notes
|
||||||
|
|
||||||
|
**Version 0.74** introduces `html_attrs` tag and `prefix:key=val` construct for passing dicts to components.
|
||||||
|
|
||||||
🚨📢 **Version 0.70**
|
🚨📢 **Version 0.70**
|
||||||
|
|
||||||
- `{% if_filled "my_slot" %}` tags were replaced with `{{ component_vars.is_filled.my_slot }}` variables.
|
- `{% 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
|
### Special characters
|
||||||
|
|
||||||
|
_New in version 0.71_:
|
||||||
|
|
||||||
Keyword arguments can contain special characters `# @ . - _`, so keywords like
|
Keyword arguments can contain special characters `# @ . - _`, so keywords like
|
||||||
so are still valid:
|
so are still valid:
|
||||||
|
|
||||||
|
@ -701,10 +705,462 @@ class Calendar(component.Component):
|
||||||
return {
|
return {
|
||||||
"date": kwargs["my-date"],
|
"date": kwargs["my-date"],
|
||||||
"id": kwargs["#some_id"],
|
"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
|
## 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.
|
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.logger import logger, trace_msg
|
||||||
from django_components.middleware import is_dependency_middleware_active
|
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.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
|
from django_components.utils import gen_id, search
|
||||||
|
|
||||||
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
|
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."
|
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)
|
component_context: dict = self.get_context_data(*args, **kwargs)
|
||||||
|
|
||||||
with context.update(component_context):
|
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():
|
if is_dependency_middleware_active():
|
||||||
output = RENDERED_COMMENT_TEMPLATE.format(name=self.registered_name) + rendered_component
|
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
|
# to get values to insert into the context
|
||||||
resolved_context_args = safe_resolve_list(self.context_args, context)
|
resolved_context_args = safe_resolve_list(self.context_args, context)
|
||||||
resolved_context_kwargs = safe_resolve_dict(self.context_kwargs, 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
|
is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
|
||||||
if is_default_slot:
|
if is_default_slot:
|
||||||
|
|
|
@ -5,7 +5,7 @@ Based on Django Slippers v0.6.2 - https://github.com/mixxorz/slippers/blob/main/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Tuple
|
from typing import Any, Dict, List, Mapping, Tuple
|
||||||
|
|
||||||
from django.template.base import (
|
from django.template.base import (
|
||||||
FILTER_ARGUMENT_SEPARATOR,
|
FILTER_ARGUMENT_SEPARATOR,
|
||||||
|
@ -154,7 +154,7 @@ def parse_bits(
|
||||||
bits: List[str],
|
bits: List[str],
|
||||||
params: List[str],
|
params: List[str],
|
||||||
name: 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
|
Parse bits for template tag helpers simple_tag and inclusion_tag, in
|
||||||
particular by detecting syntax errors and by extracting positional and
|
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`
|
This is a simplified version of `django.template.library.parse_bits`
|
||||||
where we use custom regex to handle special characters in keyword names.
|
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 = []
|
args: List[FilterExpression] = []
|
||||||
kwargs = {}
|
kwargs: List[Tuple[str, FilterExpression]] = []
|
||||||
unhandled_params = list(params)
|
unhandled_params = list(params)
|
||||||
for bit in bits:
|
for bit in bits:
|
||||||
# First we try to extract a potential kwarg from the bit
|
# First we try to extract a potential kwarg from the bit
|
||||||
|
@ -172,12 +176,8 @@ def parse_bits(
|
||||||
if kwarg:
|
if kwarg:
|
||||||
# The kwarg was successfully extracted
|
# The kwarg was successfully extracted
|
||||||
param, value = kwarg.popitem()
|
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
|
# All good, record the keyword argument
|
||||||
kwargs[str(param)] = value
|
kwargs.append((str(param), value))
|
||||||
if param in unhandled_params:
|
if param in unhandled_params:
|
||||||
# If using the keyword syntax for a positional arg, then
|
# If using the keyword syntax for a positional arg, then
|
||||||
# consume it.
|
# consume it.
|
||||||
|
@ -202,3 +202,82 @@ def parse_bits(
|
||||||
% (name, ", ".join("'%s'" % p for p in unhandled_params))
|
% (name, ", ".join("'%s'" % p for p in unhandled_params))
|
||||||
)
|
)
|
||||||
return args, kwargs
|
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
|
import django.template
|
||||||
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode, Token, TokenType
|
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.utils.safestring import SafeString, mark_safe
|
||||||
|
|
||||||
from django_components.app_settings import ContextBehavior, app_settings
|
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 import RENDERED_COMMENT_TEMPLATE, ComponentNode
|
||||||
from django_components.component_registry import ComponentRegistry
|
from django_components.component_registry import ComponentRegistry
|
||||||
from django_components.component_registry import registry as component_registry
|
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
|
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:
|
def is_whitespace_node(node: Node) -> bool:
|
||||||
return isinstance(node, TextNode) and node.s.isspace()
|
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(
|
def parse_component_with_args(
|
||||||
parser: Parser, bits: List[str], tag_name: str
|
parser: Parser, bits: List[str], tag_name: str
|
||||||
) -> Tuple[str, List[FilterExpression], Mapping[str, FilterExpression]]:
|
) -> Tuple[str, List[FilterExpression], Mapping[str, FilterExpression]]:
|
||||||
tag_args, tag_kwargs = parse_bits(
|
tag_args, tag_kwarg_pairs = parse_bits(
|
||||||
parser=parser,
|
parser=parser,
|
||||||
bits=bits,
|
bits=bits,
|
||||||
params=["tag_name", "name"],
|
params=["tag_name", "name"],
|
||||||
name=tag_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:
|
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}")
|
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:
|
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>
|
</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