feat: Support for HTML attributes and html_attrs tag (#491)

This commit is contained in:
Juro Oravec 2024-05-12 11:21:34 +02:00 committed by GitHub
parent ba86cee578
commit 610475353f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1156 additions and 32 deletions

458
README.md
View file

@ -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.

View 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

View file

@ -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:

View file

@ -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

View file

@ -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
View 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="&#x27;baz&#x27;"',
)
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)

View file

@ -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
)