mirror of
https://github.com/django-components/django-components.git
synced 2025-08-03 22:08:17 +00:00
feat: @template_tag and refactor how template tags are defined (#910)
This commit is contained in:
parent
a047908189
commit
f908197850
23 changed files with 2149 additions and 1148 deletions
61
CHANGELOG.md
61
CHANGELOG.md
|
@ -1,5 +1,66 @@
|
|||
# Release notes
|
||||
|
||||
## v0.125
|
||||
|
||||
#### Feat
|
||||
|
||||
- `@template_tag` and `BaseNode` - A decorator and a class that allow you to define
|
||||
custom template tags that will behave similarly to django-components' own template tags.
|
||||
|
||||
Read more on [Template tags](https://EmilStenstrom.github.io/django-components/0.125/concepts/advanced/template_tags/).
|
||||
|
||||
Template tags defined with `@template_tag` and `BaseNode` will have the following features:
|
||||
|
||||
- Accepting args, kwargs, and flags.
|
||||
|
||||
- Allowing literal lists and dicts as inputs as:
|
||||
|
||||
`key=[1, 2, 3]` or `key={"a": 1, "b": 2}`
|
||||
- Using template tags tag inputs as:
|
||||
|
||||
`{% my_tag key="{% lorem 3 w %}" / %}`
|
||||
- Supporting the flat dictionary definition:
|
||||
|
||||
`attr:key=value`
|
||||
- Spreading args and kwargs with `...`:
|
||||
|
||||
`{% my_tag ...args ...kwargs / %}`
|
||||
- Being able to call the template tag as:
|
||||
|
||||
`{% my_tag %} ... {% endmy_tag %}` or `{% my_tag / %}`
|
||||
|
||||
|
||||
#### Refactor
|
||||
|
||||
- Refactored template tag input validation. When you now call template tags like
|
||||
`{% slot %}`, `{% fill %}`, `{% html_attrs %}`, and others, their inputs are now
|
||||
validated the same way as Python function inputs are.
|
||||
|
||||
So, for example
|
||||
|
||||
```django
|
||||
{% slot "my_slot" name="content" / %}
|
||||
```
|
||||
|
||||
will raise an error, because the positional argument `name` is given twice.
|
||||
|
||||
NOTE: Special kwargs whose keys are not valid Python variable names are not affected by this change.
|
||||
So when you define:
|
||||
|
||||
```django
|
||||
{% component data-id=123 / %}
|
||||
```
|
||||
|
||||
The `data-id` will still be accepted as a valid kwarg, assuming that your `get_context_data()`
|
||||
accepts `**kwargs`:
|
||||
|
||||
```py
|
||||
def get_context_data(self, **kwargs):
|
||||
return {
|
||||
"data_id": kwargs["data-id"],
|
||||
}
|
||||
```
|
||||
|
||||
## v0.124
|
||||
|
||||
#### Feat
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Authoring component libraries
|
||||
weight: 8
|
||||
weight: 9
|
||||
---
|
||||
|
||||
You can publish and share your components for others to use. Below you will find the steps to do so.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Tag formatters
|
||||
weight: 7
|
||||
weight: 8
|
||||
---
|
||||
|
||||
## Customizing component tags with TagFormatter
|
||||
|
|
197
docs/concepts/advanced/template_tags.md
Normal file
197
docs/concepts/advanced/template_tags.md
Normal file
|
@ -0,0 +1,197 @@
|
|||
---
|
||||
title: Custom template tags
|
||||
weight: 7
|
||||
---
|
||||
|
||||
Template tags introduced by django-components, such as `{% component %}` and `{% slot %}`,
|
||||
offer additional features over the default Django template tags:
|
||||
|
||||
<!-- # TODO - Update docs regarding literal lists and dictionaries
|
||||
- Using literal lists and dictionaries
|
||||
- Comments inside and tag with `{# ... #}`
|
||||
-->
|
||||
- [Self-closing tags `{% mytag / %}`](../../fundamentals/template_tag_syntax#self-closing-tags)
|
||||
- [Allowing the use of `:`, `-` (and more) in keys](../../fundamentals/template_tag_syntax#special-characters)
|
||||
- [Spread operator `...`](../../fundamentals/template_tag_syntax#spread-operator)
|
||||
- [Using template tags as inputs to other template tags](../../fundamentals/template_tag_syntax#use-template-tags-inside-component-inputs)
|
||||
- [Flat definition of dictionaries `attr:key=val`](../../fundamentals/template_tag_syntax#pass-dictonary-by-its-key-value-pairs)
|
||||
- Function-like input validation
|
||||
|
||||
You too can easily create custom template tags that use the above features.
|
||||
|
||||
## Defining template tags with `@template_tag`
|
||||
|
||||
The simplest way to create a custom template tag is using
|
||||
the [`template_tag`](../../../reference/api#django_components.template_tag) decorator.
|
||||
This decorator allows you to define a template tag by just writing a function that returns the rendered content.
|
||||
|
||||
```python
|
||||
from django.template import Context, Library
|
||||
from django_components import BaseNode, template_tag
|
||||
|
||||
library = Library()
|
||||
|
||||
@template_tag(
|
||||
library,
|
||||
tag="mytag",
|
||||
end_tag="endmytag",
|
||||
allowed_flags=["required"]
|
||||
)
|
||||
def mytag(node: BaseNode, context: Context, name: str, **kwargs) -> str:
|
||||
return f"Hello, {name}!"
|
||||
```
|
||||
|
||||
This will allow you to use the tag in your templates like this:
|
||||
|
||||
```django
|
||||
{% mytag name="John" %}
|
||||
{% endmytag %}
|
||||
|
||||
{# or with self-closing syntax #}
|
||||
{% mytag name="John" / %}
|
||||
|
||||
{# or with flags #}
|
||||
{% mytag name="John" required %}
|
||||
{% endmytag %}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
The `@template_tag` decorator accepts the following parameters:
|
||||
|
||||
- `library`: The Django template library to register the tag with
|
||||
- `tag`: The name of the template tag (e.g. `"mytag"` for `{% mytag %}`)
|
||||
- `end_tag`: Optional. The name of the end tag (e.g. `"endmytag"` for `{% endmytag %}`)
|
||||
- `allowed_flags`: Optional. List of flags that can be used with the tag (e.g. `["required"]` for `{% mytag required %}`)
|
||||
|
||||
### Function signature
|
||||
|
||||
The function decorated with `@template_tag` must accept at least two arguments:
|
||||
|
||||
1. `node`: The node instance (we'll explain this in detail in the next section)
|
||||
2. `context`: The Django template context
|
||||
|
||||
Any additional parameters in your function's signature define what inputs your template tag accepts. For example:
|
||||
|
||||
```python
|
||||
@template_tag(library, tag="greet")
|
||||
def greet(
|
||||
node: BaseNode,
|
||||
context: Context,
|
||||
name: str, # required positional argument
|
||||
count: int = 1, # optional positional argument
|
||||
*, # keyword-only arguments marker
|
||||
msg: str, # required keyword argument
|
||||
mode: str = "default", # optional keyword argument
|
||||
) -> str:
|
||||
return f"{msg}, {name}!" * count
|
||||
```
|
||||
|
||||
This allows the tag to be used like:
|
||||
|
||||
```django
|
||||
{# All parameters #}
|
||||
{% greet "John" count=2 msg="Hello" mode="custom" %}
|
||||
|
||||
{# Only required parameters #}
|
||||
{% greet "John" msg="Hello" %}
|
||||
|
||||
{# Missing required parameter - will raise error #}
|
||||
{% greet "John" %} {# Error: missing 'msg' #}
|
||||
```
|
||||
|
||||
When you pass input to a template tag, it behaves the same way as if you passed the input to a function:
|
||||
|
||||
- If required parameters are missing, an error is raised
|
||||
- If unexpected parameters are passed, an error is raised
|
||||
|
||||
To accept keys that are not valid Python identifiers (e.g. `data-id`), or would conflict with Python keywords (e.g. `is`), you can use the `**kwargs` syntax:
|
||||
|
||||
```python
|
||||
@template_tag(library, tag="greet")
|
||||
def greet(
|
||||
node: BaseNode,
|
||||
context: Context,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
attrs = kwargs.copy()
|
||||
is_var = attrs.pop("is", None)
|
||||
attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
|
||||
|
||||
return mark_safe(f"""
|
||||
<div {attrs_str}>
|
||||
Hello, {is_var}!
|
||||
</div>
|
||||
""")
|
||||
```
|
||||
|
||||
This allows you to use the tag like this:
|
||||
|
||||
```django
|
||||
{% greet is="John" data-id="123" %}
|
||||
```
|
||||
|
||||
## Defining template tags with `BaseNode`
|
||||
|
||||
For more control over your template tag, you can subclass [`BaseNode`](../../../reference/api#django_components.BaseNode) directly instead of using the decorator. This gives you access to additional features like the node's internal state and parsing details.
|
||||
|
||||
```python
|
||||
from django_components import BaseNode
|
||||
|
||||
class GreetNode(BaseNode):
|
||||
tag = "greet"
|
||||
end_tag = "endgreet"
|
||||
allowed_flags = ["required"]
|
||||
|
||||
def render(self, context: Context, name: str, **kwargs) -> str:
|
||||
# Access node properties
|
||||
if self.flags["required"]:
|
||||
return f"Required greeting: Hello, {name}!"
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# Register the node
|
||||
GreetNode.register(library)
|
||||
```
|
||||
|
||||
### Node properties
|
||||
|
||||
When using `BaseNode`, you have access to several useful properties:
|
||||
|
||||
- `node_id`: A unique identifier for this node instance
|
||||
- `flags`: Dictionary of flag values (e.g. `{"required": True}`)
|
||||
- `params`: List of raw parameters passed to the tag
|
||||
- `nodelist`: The template nodes between the start and end tags
|
||||
- `active_flags`: List of flags that are currently set to True
|
||||
|
||||
This is what the `node` parameter in the `@template_tag` decorator gives you access to - it's the instance of the node class that was automatically created for your template tag.
|
||||
|
||||
### Rendering content between tags
|
||||
|
||||
When your tag has an end tag, you can access and render the content between the tags using `nodelist`:
|
||||
|
||||
```python
|
||||
class WrapNode(BaseNode):
|
||||
tag = "wrap"
|
||||
end_tag = "endwrap"
|
||||
|
||||
def render(self, context: Context, tag: str = "div", **attrs) -> str:
|
||||
# Render the content between tags
|
||||
inner = self.nodelist.render(context)
|
||||
attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
|
||||
return f"<{tag} {attrs_str}>{inner}</{tag}>"
|
||||
|
||||
# Usage:
|
||||
{% wrap tag="section" class="content" %}
|
||||
Hello, world!
|
||||
{% endwrap %}
|
||||
```
|
||||
|
||||
### Unregistering nodes
|
||||
|
||||
You can unregister a node from a library using the `unregister` method:
|
||||
|
||||
```python
|
||||
GreetNode.unregister(library)
|
||||
```
|
||||
|
||||
This is particularly useful in testing when you want to clean up after registering temporary tags.
|
|
@ -49,8 +49,8 @@ from django.urls import URLPattern, URLResolver
|
|||
|
||||
from django_components import ComponentVars, TagFormatterABC
|
||||
from django_components.component import Component
|
||||
from django_components.node import BaseNode
|
||||
from django_components.util.misc import get_import_path
|
||||
from django_components.util.template_tag import TagSpec
|
||||
|
||||
# NOTE: This file is an entrypoint for the `gen-files` plugin in `mkdocs.yml`.
|
||||
# However, `gen-files` plugin runs this file as a script, NOT as a module.
|
||||
|
@ -504,17 +504,18 @@ def gen_reference_templatetags():
|
|||
f"Import as\n```django\n{{% load {mod_name} %}}\n```\n\n"
|
||||
)
|
||||
|
||||
for name, obj in inspect.getmembers(tags_module):
|
||||
for _, obj in inspect.getmembers(tags_module):
|
||||
if not _is_template_tag(obj):
|
||||
continue
|
||||
|
||||
tag_spec: TagSpec = obj._tag_spec
|
||||
tag_signature = _format_tag_signature(tag_spec)
|
||||
obj_lineno = inspect.findsource(obj)[1]
|
||||
node_cls: BaseNode = obj
|
||||
name = node_cls.tag
|
||||
tag_signature = _format_tag_signature(node_cls)
|
||||
obj_lineno = inspect.findsource(node_cls)[1]
|
||||
source_code_link = _format_source_code_html(module_rel_path, obj_lineno)
|
||||
|
||||
# Use the tag's function's docstring
|
||||
docstring = dedent(obj.__doc__ or "").strip()
|
||||
docstring = dedent(node_cls.__doc__ or "").strip()
|
||||
|
||||
# Rebuild (almost) the same documentation than as if we used
|
||||
# mkdocstrings' `::: path.to.module` syntax.
|
||||
|
@ -585,29 +586,29 @@ def _list_urls(urlpatterns: Sequence[Union[URLPattern, URLResolver]], prefix="")
|
|||
return urls
|
||||
|
||||
|
||||
def _format_tag_signature(tag_spec: TagSpec) -> str:
|
||||
def _format_tag_signature(node_cls: BaseNode) -> str:
|
||||
"""
|
||||
Given the TagSpec instance, format the tag's function signature like:
|
||||
Given the Node class, format the tag's function signature like:
|
||||
```django
|
||||
{% component [arg, ...] **kwargs [only] %}
|
||||
{% component arg1: int, arg2: str, *args, **kwargs: Any [only] %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
"""
|
||||
# The signature returns a string like:
|
||||
# `(arg: Any, **kwargs: Any) -> None`
|
||||
params_str = str(tag_spec.signature)
|
||||
params_str = str(node_cls._signature)
|
||||
# Remove the return type annotation, the `-> None` part
|
||||
params_str = params_str.rsplit("->", 1)[0]
|
||||
# Remove brackets around the params, to end up only with `arg: Any, **kwargs: Any`
|
||||
params_str = params_str.strip()[1:-1]
|
||||
|
||||
if tag_spec.flags:
|
||||
params_str += " " + " ".join([f"[{name}]" for name in tag_spec.flags])
|
||||
if node_cls.allowed_flags:
|
||||
params_str += " " + " ".join([f"[{name}]" for name in node_cls.allowed_flags])
|
||||
|
||||
# Create the function signature
|
||||
full_tag = "{% " + tag_spec.tag + " " + params_str + " %}"
|
||||
if tag_spec.end_tag:
|
||||
full_tag += f"\n{{% {tag_spec.end_tag} %}}"
|
||||
full_tag = "{% " + node_cls.tag + " " + params_str + " %}"
|
||||
if node_cls.end_tag:
|
||||
full_tag += f"\n{{% {node_cls.end_tag} %}}"
|
||||
|
||||
return full_tag
|
||||
|
||||
|
@ -722,7 +723,7 @@ def _is_tag_formatter_instance(obj: Any) -> bool:
|
|||
|
||||
|
||||
def _is_template_tag(obj: Any) -> bool:
|
||||
return callable(obj) and hasattr(obj, "_tag_spec")
|
||||
return inspect.isclass(obj) and issubclass(obj, BaseNode)
|
||||
|
||||
|
||||
def gen_reference():
|
||||
|
|
|
@ -19,6 +19,7 @@ from django_components.component_registry import (
|
|||
from django_components.components import DynamicComponent
|
||||
from django_components.dependencies import render_dependencies
|
||||
from django_components.library import TagProtectedError
|
||||
from django_components.node import BaseNode, template_tag
|
||||
from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult
|
||||
from django_components.tag_formatter import (
|
||||
ComponentFormatter,
|
||||
|
@ -40,6 +41,7 @@ __all__ = [
|
|||
"AlreadyRegistered",
|
||||
"autodiscover",
|
||||
"cached_template",
|
||||
"BaseNode",
|
||||
"ContextBehavior",
|
||||
"ComponentsSettings",
|
||||
"Component",
|
||||
|
@ -72,5 +74,6 @@ __all__ = [
|
|||
"TagFormatterABC",
|
||||
"TagProtectedError",
|
||||
"TagResult",
|
||||
"template_tag",
|
||||
"types",
|
||||
]
|
||||
|
|
|
@ -2,39 +2,84 @@
|
|||
# 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 typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
from django.template import Context
|
||||
from django.utils.html import conditional_escape, format_html
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components.node import BaseNode
|
||||
from django_components.util.template_tag import TagParams
|
||||
|
||||
HTML_ATTRS_DEFAULTS_KEY = "defaults"
|
||||
HTML_ATTRS_ATTRS_KEY = "attrs"
|
||||
|
||||
|
||||
class HtmlAttrsNode(BaseNode):
|
||||
def __init__(
|
||||
"""
|
||||
Generate HTML attributes (`key="value"`), combining data from multiple sources,
|
||||
whether its template variables or static text.
|
||||
|
||||
It is designed to easily merge HTML attributes passed from outside with the internal.
|
||||
See how to in [Passing HTML attributes to components](../../guides/howto/passing_html_attrs/).
|
||||
|
||||
**Args:**
|
||||
|
||||
- `attrs` (dict, optional): Optional dictionary that holds HTML attributes. On conflict, overrides
|
||||
values in the `default` dictionary.
|
||||
- `default` (str, optional): Optional dictionary that holds HTML attributes. On conflict, is overriden
|
||||
with values in the `attrs` dictionary.
|
||||
- Any extra kwargs will be appended to the corresponding keys
|
||||
|
||||
The attributes in `attrs` and `defaults` are merged and resulting dict is rendered as HTML attributes
|
||||
(`key="value"`).
|
||||
|
||||
Extra kwargs (`key=value`) are concatenated to existing keys. So if we have
|
||||
|
||||
```python
|
||||
attrs = {"class": "my-class"}
|
||||
```
|
||||
|
||||
Then
|
||||
|
||||
```django
|
||||
{% html_attrs attrs class="extra-class" %}
|
||||
```
|
||||
|
||||
will result in `class="my-class extra-class"`.
|
||||
|
||||
**Example:**
|
||||
```django
|
||||
<div {% html_attrs
|
||||
attrs
|
||||
defaults:class="default-class"
|
||||
class="extra-class"
|
||||
data-id="123"
|
||||
%}>
|
||||
```
|
||||
|
||||
renders
|
||||
|
||||
```html
|
||||
<div class="my-class extra-class" data-id="123">
|
||||
```
|
||||
|
||||
**See more usage examples in
|
||||
[HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).**
|
||||
"""
|
||||
|
||||
tag = "html_attrs"
|
||||
end_tag = None # inline-only
|
||||
allowed_flags = []
|
||||
|
||||
def render(
|
||||
self,
|
||||
params: TagParams,
|
||||
node_id: Optional[str] = None,
|
||||
):
|
||||
super().__init__(nodelist=None, params=params, node_id=node_id)
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
append_attrs: List[Tuple[str, Any]] = []
|
||||
|
||||
# Resolve all data
|
||||
args, kwargs = self.params.resolve(context)
|
||||
attrs = kwargs.pop(HTML_ATTRS_ATTRS_KEY, None) or {}
|
||||
defaults = kwargs.pop(HTML_ATTRS_DEFAULTS_KEY, None) or {}
|
||||
append_attrs = list(kwargs.items())
|
||||
|
||||
# Merge it
|
||||
final_attrs = {**defaults, **attrs}
|
||||
final_attrs = append_attributes(*final_attrs.items(), *append_attrs)
|
||||
context: Context,
|
||||
attrs: Optional[Dict] = None,
|
||||
defaults: Optional[Dict] = None,
|
||||
**kwargs: Any,
|
||||
) -> SafeString:
|
||||
# Merge
|
||||
final_attrs = {}
|
||||
final_attrs.update(defaults or {})
|
||||
final_attrs.update(attrs or {})
|
||||
final_attrs = append_attributes(*final_attrs.items(), *kwargs.items())
|
||||
|
||||
# Render to HTML attributes
|
||||
return attributes_to_string(final_attrs)
|
||||
|
|
|
@ -11,6 +11,7 @@ from typing import (
|
|||
Dict,
|
||||
Generator,
|
||||
Generic,
|
||||
List,
|
||||
Literal,
|
||||
Mapping,
|
||||
NamedTuple,
|
||||
|
@ -26,7 +27,7 @@ from typing import (
|
|||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.forms.widgets import Media as MediaCls
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.template.base import NodeList, Template, TextNode
|
||||
from django.template.base import NodeList, Parser, Template, TextNode, Token
|
||||
from django.template.context import Context, RequestContext
|
||||
from django.template.loader import get_template
|
||||
from django.template.loader_tags import BLOCK_CONTEXT_KEY
|
||||
|
@ -69,9 +70,8 @@ from django_components.slots import (
|
|||
)
|
||||
from django_components.template import cached_template
|
||||
from django_components.util.django_monkeypatch import is_template_cls_patched
|
||||
from django_components.util.logger import trace_msg
|
||||
from django_components.util.misc import gen_id
|
||||
from django_components.util.template_tag import TagParams
|
||||
from django_components.util.template_tag import TagAttr
|
||||
from django_components.util.validation import validate_typed_dict, validate_typed_tuple
|
||||
|
||||
# TODO_REMOVE_IN_V1 - Users should use top-level import instead
|
||||
|
@ -1209,32 +1209,147 @@ class Component(
|
|||
|
||||
|
||||
class ComponentNode(BaseNode):
|
||||
"""Django.template.Node subclass that renders a django-components component"""
|
||||
"""
|
||||
Renders one of the components that was previously registered with
|
||||
[`@register()`](./api.md#django_components.register)
|
||||
decorator.
|
||||
|
||||
**Args:**
|
||||
|
||||
- `name` (str, required): Registered name of the component to render
|
||||
- All other args and kwargs are defined based on the component itself.
|
||||
|
||||
If you defined a component `"my_table"`
|
||||
|
||||
```python
|
||||
from django_component import Component, register
|
||||
|
||||
@register("my_table")
|
||||
class MyTable(Component):
|
||||
template = \"\"\"
|
||||
<table>
|
||||
<thead>
|
||||
{% for header in headers %}
|
||||
<th>{{ header }}</th>
|
||||
{% endfor %}
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td>{{ cell }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tbody>
|
||||
</table>
|
||||
\"\"\"
|
||||
|
||||
def get_context_data(self, rows: List, headers: List):
|
||||
return {
|
||||
"rows": rows,
|
||||
"headers": headers,
|
||||
}
|
||||
```
|
||||
|
||||
Then you can render this component by referring to `MyTable` via its
|
||||
registered name `"my_table"`:
|
||||
|
||||
```django
|
||||
{% component "my_table" rows=rows headers=headers ... / %}
|
||||
```
|
||||
|
||||
### Component input
|
||||
|
||||
Positional and keyword arguments can be literals or template variables.
|
||||
|
||||
The component name must be a single- or double-quotes string and must
|
||||
be either:
|
||||
|
||||
- The first positional argument after `component`:
|
||||
|
||||
```django
|
||||
{% component "my_table" rows=rows headers=headers ... / %}
|
||||
```
|
||||
|
||||
- Passed as kwarg `name`:
|
||||
|
||||
```django
|
||||
{% component rows=rows headers=headers name="my_table" ... / %}
|
||||
```
|
||||
|
||||
### Inserting into slots
|
||||
|
||||
If the component defined any [slots](../concepts/fundamentals/slots.md), you can
|
||||
pass in the content to be placed inside those slots by inserting [`{% fill %}`](#fill) tags,
|
||||
directly within the `{% component %}` tag:
|
||||
|
||||
```django
|
||||
{% component "my_table" rows=rows headers=headers ... / %}
|
||||
{% fill "pagination" %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Isolating components
|
||||
|
||||
By default, components behave similarly to Django's
|
||||
[`{% include %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#include),
|
||||
and the template inside the component has access to the variables defined in the outer template.
|
||||
|
||||
You can selectively isolate a component, using the `only` flag, so that the inner template
|
||||
can access only the data that was explicitly passed to it:
|
||||
|
||||
```django
|
||||
{% component "name" positional_arg keyword_arg=value ... only %}
|
||||
```
|
||||
"""
|
||||
|
||||
tag = "component"
|
||||
end_tag = "endcomponent"
|
||||
allowed_flags = [COMP_ONLY_FLAG]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# ComponentNode inputs
|
||||
name: str,
|
||||
registry: ComponentRegistry, # noqa F811
|
||||
nodelist: NodeList,
|
||||
params: TagParams,
|
||||
isolated_context: bool = False,
|
||||
# BaseNode inputs
|
||||
params: List[TagAttr],
|
||||
flags: Optional[Dict[str, bool]] = None,
|
||||
nodelist: Optional[NodeList] = None,
|
||||
node_id: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(nodelist=nodelist or NodeList(), params=params, node_id=node_id)
|
||||
super().__init__(params=params, flags=flags, nodelist=nodelist, node_id=node_id)
|
||||
|
||||
self.name = name
|
||||
self.isolated_context = isolated_context
|
||||
self.registry = registry
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "<ComponentNode: {}. Contents: {!r}>".format(
|
||||
self.name,
|
||||
getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later.
|
||||
@classmethod
|
||||
def parse( # type: ignore[override]
|
||||
cls,
|
||||
parser: Parser,
|
||||
token: Token,
|
||||
registry: ComponentRegistry, # noqa F811
|
||||
name: str,
|
||||
start_tag: str,
|
||||
end_tag: str,
|
||||
) -> "ComponentNode":
|
||||
# Set the component-specific start and end tags by subclassing the base node
|
||||
subcls_name = cls.__name__ + "_" + name
|
||||
subcls: Type[ComponentNode] = type(subcls_name, (cls,), {"tag": start_tag, "end_tag": end_tag})
|
||||
|
||||
# Call `BaseNode.parse()` as if with the context of subcls.
|
||||
node: ComponentNode = super(cls, subcls).parse( # type: ignore[attr-defined]
|
||||
parser,
|
||||
token,
|
||||
registry=registry,
|
||||
name=name,
|
||||
)
|
||||
return node
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
trace_msg("RENDR", "COMP", self.name, self.node_id)
|
||||
|
||||
def render(self, context: Context, *args: Any, **kwargs: Any) -> str:
|
||||
# Do not render nested `{% component %}` tags in other `{% component %}` tags
|
||||
# at the stage when we are determining if the latter has named fills or not.
|
||||
if _is_extracting_fill(context):
|
||||
|
@ -1242,11 +1357,6 @@ class ComponentNode(BaseNode):
|
|||
|
||||
component_cls: Type[Component] = self.registry.get(self.name)
|
||||
|
||||
# Resolve FilterExpressions and Variables that were passed as args to the
|
||||
# component, then call component's context method
|
||||
# to get values to insert into the context
|
||||
args, kwargs = self.params.resolve(context)
|
||||
|
||||
slot_fills = resolve_fills(context, self.nodelist, self.name)
|
||||
|
||||
component: Component = component_cls(
|
||||
|
@ -1256,7 +1366,7 @@ class ComponentNode(BaseNode):
|
|||
)
|
||||
|
||||
# Prevent outer context from leaking into the template of the component
|
||||
if self.isolated_context or self.registry.settings.context_behavior == ContextBehavior.ISOLATED:
|
||||
if self.flags[COMP_ONLY_FLAG] or self.registry.settings.context_behavior == ContextBehavior.ISOLATED:
|
||||
context = make_isolated_context_copy(context)
|
||||
|
||||
output = component._render(
|
||||
|
@ -1269,7 +1379,6 @@ class ComponentNode(BaseNode):
|
|||
render_dependencies=False,
|
||||
)
|
||||
|
||||
trace_msg("RENDR", "COMP", self.name, self.node_id, "...Done!")
|
||||
return output
|
||||
|
||||
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, Union
|
||||
|
||||
from django.template import Library
|
||||
from django.template.base import Parser, Token
|
||||
|
||||
from django_components.app_settings import ContextBehaviorType, app_settings
|
||||
from django_components.library import is_tag_protected, mark_protected_tags, register_tag_from_formatter
|
||||
from django_components.library import is_tag_protected, mark_protected_tags, register_tag
|
||||
from django_components.tag_formatter import TagFormatterABC, get_tag_formatter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
@ -462,12 +463,39 @@ class ComponentRegistry:
|
|||
component: Type["Component"],
|
||||
) -> ComponentRegistryEntry:
|
||||
# Lazily import to avoid circular dependencies
|
||||
from django_components.templatetags.component_tags import component as do_component
|
||||
from django_components.component import ComponentNode
|
||||
|
||||
formatter = get_tag_formatter(self)
|
||||
tag = register_tag_from_formatter(self, do_component, formatter, comp_name)
|
||||
registry = self
|
||||
|
||||
return ComponentRegistryEntry(cls=component, tag=tag)
|
||||
# Define a tag function that pre-processes the tokens, extracting
|
||||
# the component name and passing the rest to the actual tag function.
|
||||
def tag_fn(parser: Parser, token: Token) -> ComponentNode:
|
||||
# Let the TagFormatter pre-process the tokens
|
||||
bits = token.split_contents()
|
||||
formatter = get_tag_formatter(registry)
|
||||
result = formatter.parse([*bits])
|
||||
start_tag = formatter.start_tag(result.component_name)
|
||||
end_tag = formatter.end_tag(result.component_name)
|
||||
|
||||
# NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself,
|
||||
# so we add it back in.
|
||||
bits = [bits[0], *result.tokens]
|
||||
token.contents = " ".join(bits)
|
||||
|
||||
return ComponentNode.parse(
|
||||
parser,
|
||||
token,
|
||||
registry=registry,
|
||||
name=result.component_name,
|
||||
start_tag=start_tag,
|
||||
end_tag=end_tag,
|
||||
)
|
||||
|
||||
formatter = get_tag_formatter(registry)
|
||||
start_tag = formatter.start_tag(comp_name)
|
||||
register_tag(self.library, start_tag, tag_fn)
|
||||
|
||||
return ComponentRegistryEntry(cls=component, tag=start_tag)
|
||||
|
||||
|
||||
# This variable represents the global component registry
|
||||
|
|
|
@ -28,11 +28,13 @@ from asgiref.sync import iscoroutinefunction, markcoroutinefunction
|
|||
from django.forms import Media
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed, HttpResponseNotFound, StreamingHttpResponse
|
||||
from django.http.response import HttpResponseBase
|
||||
from django.template import Context, TemplateSyntaxError
|
||||
from django.templatetags.static import static
|
||||
from django.urls import path, reverse
|
||||
from django.utils.decorators import sync_and_async_middleware
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components.node import BaseNode
|
||||
from django_components.util.html import SoupNode
|
||||
from django_components.util.misc import get_import_path, is_nonempty_str
|
||||
|
||||
|
@ -1036,3 +1038,66 @@ class ComponentDependencyMiddleware:
|
|||
response.content = render_dependencies(response.content, type="document")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
#########################################################
|
||||
# 6. Template tags
|
||||
#########################################################
|
||||
|
||||
|
||||
def _component_dependencies(type: Literal["js", "css"]) -> SafeString:
|
||||
"""Marks location where CSS link and JS script tags should be rendered."""
|
||||
if type == "css":
|
||||
placeholder = CSS_DEPENDENCY_PLACEHOLDER
|
||||
elif type == "js":
|
||||
placeholder = JS_DEPENDENCY_PLACEHOLDER
|
||||
else:
|
||||
raise TemplateSyntaxError(
|
||||
f"Unknown dependency type in {{% component_dependencies %}}. Must be one of 'css' or 'js', got {type}"
|
||||
)
|
||||
|
||||
return mark_safe(placeholder)
|
||||
|
||||
|
||||
class ComponentCssDependenciesNode(BaseNode):
|
||||
"""
|
||||
Marks location where CSS link tags should be rendered after the whole HTML has been generated.
|
||||
|
||||
Generally, this should be inserted into the `<head>` tag of the HTML.
|
||||
|
||||
If the generated HTML does NOT contain any `{% component_css_dependencies %}` tags, CSS links
|
||||
are by default inserted into the `<head>` tag of the HTML. (See
|
||||
[JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations))
|
||||
|
||||
Note that there should be only one `{% component_css_dependencies %}` for the whole HTML document.
|
||||
If you insert this tag multiple times, ALL CSS links will be duplicately inserted into ALL these places.
|
||||
"""
|
||||
|
||||
tag = "component_css_dependencies"
|
||||
end_tag = None # inline-only
|
||||
allowed_flags = []
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
return _component_dependencies("css")
|
||||
|
||||
|
||||
class ComponentJsDependenciesNode(BaseNode):
|
||||
"""
|
||||
Marks location where JS link tags should be rendered after the whole HTML has been generated.
|
||||
|
||||
Generally, this should be inserted at the end of the `<body>` tag of the HTML.
|
||||
|
||||
If the generated HTML does NOT contain any `{% component_js_dependencies %}` tags, JS scripts
|
||||
are by default inserted at the end of the `<body>` tag of the HTML. (See
|
||||
[JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations))
|
||||
|
||||
Note that there should be only one `{% component_js_dependencies %}` for the whole HTML document.
|
||||
If you insert this tag multiple times, ALL JS scripts will be duplicately inserted into ALL these places.
|
||||
"""
|
||||
|
||||
tag = "component_js_dependencies"
|
||||
end_tag = None # inline-only
|
||||
allowed_flags = []
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
return _component_dependencies("js")
|
||||
|
|
|
@ -1,15 +1,10 @@
|
|||
"""Module for interfacing with Django's Library (`django.template.library`)"""
|
||||
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from django.template.base import Node, Parser, Token
|
||||
from django.template.library import Library
|
||||
|
||||
from django_components.tag_formatter import InternalTagFormatter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components.component_registry import ComponentRegistry
|
||||
|
||||
|
||||
class TagProtectedError(Exception):
|
||||
"""
|
||||
|
@ -56,26 +51,15 @@ as they would conflict with other tags in the Library.
|
|||
|
||||
|
||||
def register_tag(
|
||||
registry: "ComponentRegistry",
|
||||
library: Library,
|
||||
tag: str,
|
||||
tag_fn: Callable[[Parser, Token, "ComponentRegistry", str], Node],
|
||||
tag_fn: Callable[[Parser, Token], Node],
|
||||
) -> None:
|
||||
# Register inline tag
|
||||
if is_tag_protected(registry.library, tag):
|
||||
if is_tag_protected(library, tag):
|
||||
raise TagProtectedError('Cannot register tag "%s", this tag name is protected' % tag)
|
||||
else:
|
||||
registry.library.tag(tag, lambda parser, token: tag_fn(parser, token, registry, tag))
|
||||
|
||||
|
||||
def register_tag_from_formatter(
|
||||
registry: "ComponentRegistry",
|
||||
tag_fn: Callable[[Parser, Token, "ComponentRegistry", str], Node],
|
||||
formatter: InternalTagFormatter,
|
||||
component_name: str,
|
||||
) -> str:
|
||||
tag = formatter.start_tag(component_name)
|
||||
register_tag(registry, tag, tag_fn)
|
||||
return tag
|
||||
library.tag(tag, tag_fn)
|
||||
|
||||
|
||||
def mark_protected_tags(lib: Library, tags: Optional[List[str]] = None) -> None:
|
||||
|
|
|
@ -1,20 +1,475 @@
|
|||
from typing import Optional
|
||||
import functools
|
||||
import inspect
|
||||
import keyword
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, cast
|
||||
|
||||
from django.template.base import Node, NodeList
|
||||
from django.template import Context, Library
|
||||
from django.template.base import Node, NodeList, Parser, Token
|
||||
|
||||
from django_components.util.logger import trace_msg
|
||||
from django_components.util.misc import gen_id
|
||||
from django_components.util.template_tag import TagParams
|
||||
from django_components.util.template_tag import (
|
||||
TagAttr,
|
||||
TagParam,
|
||||
apply_params_in_original_order,
|
||||
parse_template_tag,
|
||||
resolve_params,
|
||||
validate_params,
|
||||
)
|
||||
|
||||
|
||||
class BaseNode(Node):
|
||||
"""Shared behavior for our subclasses of Django's `Node`"""
|
||||
# Normally, when `Node.render()` is called, it receives only a single argument `context`.
|
||||
#
|
||||
# ```python
|
||||
# def render(self, context: Context) -> str:
|
||||
# return self.nodelist.render(context)
|
||||
# ```
|
||||
#
|
||||
# In django-components, the input to template tags is treated as function inputs, e.g.
|
||||
#
|
||||
# `{% component name="John" age=20 %}`
|
||||
#
|
||||
# And, for convenience, we want to allow the `render()` method to accept these extra parameters.
|
||||
# That way, user can define just the `render()` method and have access to all the information:
|
||||
#
|
||||
# ```python
|
||||
# def render(self, context: Context, name: str, **kwargs: Any) -> str:
|
||||
# return f"Hello, {name}!"
|
||||
# ```
|
||||
#
|
||||
# So we need to wrap the `render()` method, and for that we need the metaclass.
|
||||
#
|
||||
# The outer `render()` (our wrapper) will match the `Node.render()` signature (accepting only `context`),
|
||||
# while the inner `render()` (the actual implementation) will match the user-defined `render()` method's signature
|
||||
# (accepting all the parameters).
|
||||
class NodeMeta(type):
|
||||
def __new__(
|
||||
mcs,
|
||||
name: str,
|
||||
bases: Tuple[Type, ...],
|
||||
attrs: Dict[str, Any],
|
||||
) -> Type["BaseNode"]:
|
||||
cls = cast(Type["BaseNode"], super().__new__(mcs, name, bases, attrs))
|
||||
|
||||
# Ignore the `BaseNode` class itself
|
||||
if attrs.get("__module__", None) == "django_components.node":
|
||||
return cls
|
||||
|
||||
if not hasattr(cls, "tag"):
|
||||
raise ValueError(f"Node {name} must have a 'tag' attribute")
|
||||
|
||||
# Skip if already wrapped
|
||||
orig_render = cls.render
|
||||
if getattr(orig_render, "_djc_wrapped", False):
|
||||
return cls
|
||||
|
||||
signature = inspect.signature(orig_render)
|
||||
|
||||
# A full signature of `BaseNode.render()` may look like this:
|
||||
#
|
||||
# `def render(self, context: Context, name: str, **kwargs) -> str:`
|
||||
#
|
||||
# We need to remove the first two parameters from the signature.
|
||||
# So we end up only with
|
||||
#
|
||||
# `def render(name: str, **kwargs) -> str:`
|
||||
#
|
||||
# And this becomes the signature that defines what params the template tag accepts, e.g.
|
||||
#
|
||||
# `{% component name="John" age=20 %}`
|
||||
if len(signature.parameters) < 2:
|
||||
raise TypeError(f"`render()` method of {name} must have at least two parameters")
|
||||
|
||||
validation_params = list(signature.parameters.values())
|
||||
validation_params = validation_params[2:]
|
||||
validation_signature = signature.replace(parameters=validation_params)
|
||||
|
||||
# NOTE: This is used for creating docs by `_format_tag_signature()` in `docs/scripts/reference.py`
|
||||
cls._signature = validation_signature
|
||||
|
||||
@functools.wraps(orig_render)
|
||||
def wrapper_render(self: "BaseNode", context: Context) -> str:
|
||||
trace_msg("RENDR", self.tag, self.node_id)
|
||||
|
||||
resolved_params = resolve_params(self.tag, self.params, context)
|
||||
|
||||
# Template tags may accept kwargs that are not valid Python identifiers, e.g.
|
||||
# `{% component data-id="John" class="pt-4" :href="myVar" %}`
|
||||
#
|
||||
# Passing them in is still useful, as user may want to pass in arbitrary data
|
||||
# to their `{% component %}` tags as HTML attributes. E.g. example below passes
|
||||
# `data-id`, `class` and `:href` as HTML attributes to the `<div>` element:
|
||||
#
|
||||
# ```py
|
||||
# class MyComponent(Component):
|
||||
# def get_context_data(self, name: str, **kwargs: Any) -> str:
|
||||
# return {
|
||||
# "name": name,
|
||||
# "attrs": kwargs,
|
||||
# }
|
||||
# template = """
|
||||
# <div {% html_attrs attrs %}>
|
||||
# {{ name }}
|
||||
# </div>
|
||||
# """
|
||||
# ```
|
||||
#
|
||||
# HOWEVER, these kwargs like `data-id`, `class` and `:href` may not be valid Python identifiers,
|
||||
# or like in case of `class`, may be a reserved keyword. Thus, we cannot pass them in to the `render()`
|
||||
# method as regular kwargs, because that will raise Python's native errors like
|
||||
# `SyntaxError: invalid syntax`. E.g.
|
||||
#
|
||||
# ```python
|
||||
# def render(self, context: Context, data-id: str, class: str, :href: str) -> str:
|
||||
# ```
|
||||
#
|
||||
# So instead, we filter out any invalid kwargs, and pass those in through a dictionary spread.
|
||||
# We can do so, because following is allowed in Python:
|
||||
#
|
||||
# ```python
|
||||
# def x(**kwargs):
|
||||
# print(kwargs)
|
||||
#
|
||||
# d = {"data-id": 1}
|
||||
# x(**d)
|
||||
# # {'data-id': 1}
|
||||
# ```
|
||||
#
|
||||
# See https://github.com/EmilStenstrom/django-components/discussions/900#discussioncomment-11859970
|
||||
resolved_params_without_invalid_kwargs = []
|
||||
invalid_kwargs = {}
|
||||
did_see_special_kwarg = False
|
||||
for resolved_param in resolved_params:
|
||||
key = resolved_param.key
|
||||
if key is not None:
|
||||
# Case: Special kwargs
|
||||
if not key.isidentifier() or keyword.iskeyword(key):
|
||||
# NOTE: Since these keys are not part of signature validation,
|
||||
# we have to check ourselves if any args follow them.
|
||||
invalid_kwargs[key] = resolved_param.value
|
||||
did_see_special_kwarg = True
|
||||
else:
|
||||
# Case: Regular kwargs
|
||||
resolved_params_without_invalid_kwargs.append(resolved_param)
|
||||
else:
|
||||
# Case: Regular positional args
|
||||
if did_see_special_kwarg:
|
||||
raise SyntaxError("positional argument follows keyword argument")
|
||||
resolved_params_without_invalid_kwargs.append(resolved_param)
|
||||
|
||||
# Validate the params against the signature
|
||||
#
|
||||
# Unlike the call to `apply_params_in_original_order()` further below, this uses a signature
|
||||
# that has been stripped of the `self` and `context` parameters. E.g.
|
||||
#
|
||||
# `def render(name: str, **kwargs: Any) -> None`
|
||||
#
|
||||
# If there are any errors in the input, this will trigger Python's
|
||||
# native error handling (e.g. `TypeError: render() got multiple values for argument 'context'`)
|
||||
#
|
||||
# But because we stripped the two parameters, then these errors will correctly
|
||||
# point to the actual error in the template tag.
|
||||
#
|
||||
# E.g. if we supplied one too many positional args,
|
||||
# `{% mytag "John" 20 %}`
|
||||
#
|
||||
# Then without stripping the two parameters, then the error could be:
|
||||
# `render() takes from 3 positional arguments but 4 were given`
|
||||
#
|
||||
# Which is confusing, because we supplied only two positional args.
|
||||
#
|
||||
# But cause we stripped the two parameters, then the error will be:
|
||||
# `render() takes from 1 positional arguments but 2 were given`
|
||||
validate_params(self.tag, validation_signature, resolved_params_without_invalid_kwargs, invalid_kwargs)
|
||||
|
||||
# The code below calls the `orig_render()` function like so:
|
||||
# `orig_render(self, context, arg1, arg2, kwarg1=val1, kwarg2=val2)`
|
||||
#
|
||||
# So it's called in the same order as what was passed to the template tag, e.g.
|
||||
# `{% component arg1 arg2 kwarg1=val1 kwarg2=val2 %}`
|
||||
#
|
||||
# That's why we don't simply spread all args and kwargs as `*args, **kwargs`,
|
||||
# because then Python's validation wouldn't catch such errors.
|
||||
resolved_params_with_context = [
|
||||
TagParam(key=None, value=self),
|
||||
TagParam(key=None, value=context),
|
||||
] + resolved_params_without_invalid_kwargs
|
||||
output = apply_params_in_original_order(orig_render, resolved_params_with_context, invalid_kwargs)
|
||||
|
||||
trace_msg("RENDR", self.tag, self.node_id, msg="...Done!")
|
||||
return output
|
||||
|
||||
# Wrap cls.render() so we resolve the args and kwargs and pass them to the
|
||||
# actual render method.
|
||||
cls.render = wrapper_render # type: ignore
|
||||
cls.render._djc_wrapped = True # type: ignore
|
||||
|
||||
return cls
|
||||
|
||||
|
||||
class BaseNode(Node, metaclass=NodeMeta):
|
||||
"""
|
||||
Node class for all django-components custom template tags.
|
||||
|
||||
This class has a dual role:
|
||||
|
||||
1. It declares how a particular template tag should be parsed - By setting the
|
||||
[`tag`](../api#django_components.BaseNode.tag),
|
||||
[`end_tag`](../api#django_components.BaseNode.end_tag),
|
||||
and [`allowed_flags`](../api#django_components.BaseNode.allowed_flags)
|
||||
attributes:
|
||||
|
||||
```python
|
||||
class SlotNode(BaseNode):
|
||||
tag = "slot"
|
||||
end_tag = "endslot"
|
||||
allowed_flags = ["required"]
|
||||
```
|
||||
|
||||
This will allow the template tag `{% slot %}` to be used like this:
|
||||
|
||||
```django
|
||||
{% slot required %} ... {% endslot %}
|
||||
```
|
||||
|
||||
2. The [`render`](../api#django_components.BaseNode.render) method is
|
||||
the actual implementation of the template tag.
|
||||
|
||||
This is where the tag's logic is implemented:
|
||||
|
||||
```python
|
||||
class MyNode(BaseNode):
|
||||
tag = "mynode"
|
||||
|
||||
def render(self, context: Context, name: str, **kwargs: Any) -> str:
|
||||
return f"Hello, {name}!"
|
||||
```
|
||||
|
||||
This will allow the template tag `{% mynode %}` to be used like this:
|
||||
|
||||
```django
|
||||
{% mynode name="John" %}
|
||||
```
|
||||
|
||||
The template tag accepts parameters as defined on the
|
||||
[`render`](../api#django_components.BaseNode.render) method's signature.
|
||||
|
||||
For more info, see [`BaseNode.render()`](../api#django_components.BaseNode.render).
|
||||
"""
|
||||
|
||||
# #####################################
|
||||
# PUBLIC API (Configurable by users)
|
||||
# #####################################
|
||||
|
||||
tag: str
|
||||
"""
|
||||
The tag name.
|
||||
|
||||
E.g. `"component"` or `"slot"` will make this class match
|
||||
template tags `{% component %}` or `{% slot %}`.
|
||||
"""
|
||||
|
||||
end_tag: Optional[str] = None
|
||||
"""
|
||||
The end tag name.
|
||||
|
||||
E.g. `"endcomponent"` or `"endslot"` will make this class match
|
||||
template tags `{% endcomponent %}` or `{% endslot %}`.
|
||||
|
||||
If not set, then this template tag has no end tag.
|
||||
|
||||
So instead of `{% component %} ... {% endcomponent %}`, you'd use only
|
||||
`{% component %}`.
|
||||
"""
|
||||
|
||||
allowed_flags: Optional[List[str]] = None
|
||||
"""
|
||||
The allowed flags for this tag.
|
||||
|
||||
E.g. `["required"]` will allow this tag to be used like `{% slot required %}`.
|
||||
"""
|
||||
|
||||
def render(self, context: Context, *args: Any, **kwargs: Any) -> str:
|
||||
"""
|
||||
Render the node. This method is meant to be overridden by subclasses.
|
||||
|
||||
The signature of this function decides what input the template tag accepts.
|
||||
|
||||
The `render()` method MUST accept a `context` argument. Any arguments after that
|
||||
will be part of the tag's input parameters.
|
||||
|
||||
So if you define a `render` method like this:
|
||||
|
||||
```python
|
||||
def render(self, context: Context, name: str, **kwargs: Any) -> str:
|
||||
```
|
||||
|
||||
Then the tag will require the `name` parameter, and accept any extra keyword arguments:
|
||||
|
||||
```django
|
||||
{% component name="John" age=20 %}
|
||||
```
|
||||
"""
|
||||
return self.nodelist.render(context)
|
||||
|
||||
# #####################################
|
||||
# MISC
|
||||
# #####################################
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
params: TagParams,
|
||||
params: List[TagAttr],
|
||||
flags: Optional[Dict[str, bool]] = None,
|
||||
nodelist: Optional[NodeList] = None,
|
||||
node_id: Optional[str] = None,
|
||||
):
|
||||
self.params = params
|
||||
self.flags = flags or {flag: False for flag in self.allowed_flags or []}
|
||||
self.nodelist = nodelist or NodeList()
|
||||
self.node_id = node_id or gen_id()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<{self.__class__.__name__}: {self.node_id}. Contents: {repr(self.nodelist)}."
|
||||
f" Flags: {self.active_flags}>"
|
||||
)
|
||||
|
||||
@property
|
||||
def active_flags(self) -> List[str]:
|
||||
"""Flags that were set for this specific instance."""
|
||||
flags = []
|
||||
for flag, value in self.flags.items():
|
||||
if value:
|
||||
flags.append(flag)
|
||||
return flags
|
||||
|
||||
@classmethod
|
||||
def parse(cls, parser: Parser, token: Token, **kwargs: Any) -> "BaseNode":
|
||||
"""
|
||||
This function is what is passed to Django's `Library.tag()` when
|
||||
[registering the tag](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#registering-the-tag).
|
||||
|
||||
In other words, this method is called by Django's template parser when we encounter
|
||||
a tag that matches this node's tag, e.g. `{% component %}` or `{% slot %}`.
|
||||
|
||||
To register the tag, you can use [`BaseNode.register()`](../api#django_components.BaseNode.register).
|
||||
"""
|
||||
tag_id = gen_id()
|
||||
tag = parse_template_tag(cls.tag, cls.end_tag, cls.allowed_flags, parser, token)
|
||||
|
||||
trace_msg("PARSE", cls.tag, tag_id)
|
||||
|
||||
body = tag.parse_body()
|
||||
node = cls(
|
||||
nodelist=body,
|
||||
node_id=tag_id,
|
||||
params=tag.params,
|
||||
flags=tag.flags,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", cls.tag, tag_id, "...Done!")
|
||||
return node
|
||||
|
||||
@classmethod
|
||||
def register(cls, library: Library) -> None:
|
||||
"""
|
||||
A convenience method for registering the tag with the given library.
|
||||
|
||||
```python
|
||||
class MyNode(BaseNode):
|
||||
tag = "mynode"
|
||||
|
||||
MyNode.register(library)
|
||||
```
|
||||
|
||||
Allows you to then use the node in templates like so:
|
||||
|
||||
```django
|
||||
{% load mylibrary %}
|
||||
{% mynode %}
|
||||
```
|
||||
"""
|
||||
library.tag(cls.tag, cls.parse)
|
||||
|
||||
@classmethod
|
||||
def unregister(cls, library: Library) -> None:
|
||||
"""Unregisters the node from the given library."""
|
||||
library.tags.pop(cls.tag, None)
|
||||
|
||||
|
||||
def template_tag(
|
||||
library: Library,
|
||||
tag: str,
|
||||
end_tag: Optional[str] = None,
|
||||
allowed_flags: Optional[List[str]] = None,
|
||||
) -> Callable[[Callable], Callable]:
|
||||
"""
|
||||
A simplified version of creating a template tag based on [`BaseNode`](../api#django_components.BaseNode).
|
||||
|
||||
Instead of defining the whole class, you can just define the
|
||||
[`render()`](../api#django_components.BaseNode.render) method.
|
||||
|
||||
```python
|
||||
from django.template import Context, Library
|
||||
from django_components import BaseNode, template_tag
|
||||
|
||||
library = Library()
|
||||
|
||||
@template_tag(
|
||||
library,
|
||||
tag="mytag",
|
||||
end_tag="endmytag",
|
||||
allowed_flags=["required"],
|
||||
)
|
||||
def mytag(node: BaseNode, context: Context, name: str, **kwargs: Any) -> str:
|
||||
return f"Hello, {name}!"
|
||||
```
|
||||
|
||||
This will allow the template tag `{% mytag %}` to be used like this:
|
||||
|
||||
```django
|
||||
{% mytag name="John" %}
|
||||
{% mytag name="John" required %} ... {% endmytag %}
|
||||
```
|
||||
|
||||
The given function will be wrapped in a class that inherits from [`BaseNode`](../api#django_components.BaseNode).
|
||||
|
||||
And this class will be registered with the given library.
|
||||
|
||||
The function MUST accept at least two positional arguments: `node` and `context`
|
||||
|
||||
- `node` is the [`BaseNode`](../api#django_components.BaseNode) instance.
|
||||
- `context` is the [`Context`](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context)
|
||||
of the template.
|
||||
|
||||
Any extra parameters defined on this function will be part of the tag's input parameters.
|
||||
|
||||
For more info, see [`BaseNode.render()`](../api#django_components.BaseNode.render).
|
||||
"""
|
||||
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
subcls_name = fn.__name__.title().replace("_", "").replace("-", "") + "Node"
|
||||
|
||||
try:
|
||||
subcls: Type[BaseNode] = type(
|
||||
subcls_name,
|
||||
(BaseNode,),
|
||||
{
|
||||
"tag": tag,
|
||||
"end_tag": end_tag,
|
||||
"allowed_flags": allowed_flags or [],
|
||||
"render": fn,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
raise e.__class__(f"Failed to create node class in 'template_tag()' for '{fn.__name__}'") from e
|
||||
|
||||
subcls.register(library)
|
||||
|
||||
# Allow to access the node class
|
||||
fn._node = subcls # type: ignore[attr-defined]
|
||||
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
|
|
@ -1,42 +1,87 @@
|
|||
from typing import Dict, Optional, Tuple
|
||||
from typing import Any
|
||||
|
||||
from django.template import Context
|
||||
from django.template.base import NodeList
|
||||
from django.utils.safestring import SafeString
|
||||
|
||||
from django_components.context import set_provided_context_var
|
||||
from django_components.node import BaseNode
|
||||
from django_components.util.logger import trace_msg
|
||||
from django_components.util.template_tag import TagParams
|
||||
|
||||
PROVIDE_NAME_KWARG = "name"
|
||||
|
||||
|
||||
class ProvideNode(BaseNode):
|
||||
"""
|
||||
Implementation of the `{% provide %}` tag.
|
||||
For more info see `Component.inject`.
|
||||
The "provider" part of the [provide / inject feature](../../concepts/advanced/provide_inject).
|
||||
Pass kwargs to this tag to define the provider's data.
|
||||
Any components defined within the `{% provide %}..{% endprovide %}` tags will be able to access this data
|
||||
with [`Component.inject()`](../api#django_components.Component.inject).
|
||||
|
||||
This is similar to React's [`ContextProvider`](https://react.dev/learn/passing-data-deeply-with-context),
|
||||
or Vue's [`provide()`](https://vuejs.org/guide/components/provide-inject).
|
||||
|
||||
**Args:**
|
||||
|
||||
- `name` (str, required): Provider name. This is the name you will then use in
|
||||
[`Component.inject()`](../api#django_components.Component.inject).
|
||||
- `**kwargs`: Any extra kwargs will be passed as the provided data.
|
||||
|
||||
**Example:**
|
||||
|
||||
Provide the "user_data" in parent component:
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% provide "user_data" user=user %}
|
||||
{% component "child" / %}
|
||||
{% endprovide %}
|
||||
</div>
|
||||
\"\"\"
|
||||
|
||||
def get_context_data(self, user: User):
|
||||
return {
|
||||
"user": user,
|
||||
}
|
||||
```
|
||||
|
||||
Since the "child" component is used within the `{% provide %} / {% endprovide %}` tags,
|
||||
we can request the "user_data" using `Component.inject("user_data")`:
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
User is: {{ user }}
|
||||
</div>
|
||||
\"\"\"
|
||||
|
||||
def get_context_data(self):
|
||||
user = self.inject("user_data").user
|
||||
return {
|
||||
"user": user,
|
||||
}
|
||||
```
|
||||
|
||||
Notice that the keys defined on the `{% provide %}` tag are then accessed as attributes
|
||||
when accessing them with [`Component.inject()`](../api#django_components.Component.inject).
|
||||
|
||||
✅ Do this
|
||||
```python
|
||||
user = self.inject("user_data").user
|
||||
```
|
||||
|
||||
❌ Don't do this
|
||||
```python
|
||||
user = self.inject("user_data")["user"]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nodelist: NodeList,
|
||||
params: TagParams,
|
||||
trace_id: str,
|
||||
node_id: Optional[str] = None,
|
||||
):
|
||||
super().__init__(nodelist=nodelist, params=params, node_id=node_id)
|
||||
|
||||
self.trace_id = trace_id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Provide Node: {self.node_id}. Contents: {repr(self.nodelist)}>"
|
||||
|
||||
def render(self, context: Context) -> SafeString:
|
||||
trace_msg("RENDR", "PROVIDE", self.trace_id, self.node_id)
|
||||
|
||||
name, kwargs = self.resolve_kwargs(context)
|
||||
tag = "provide"
|
||||
end_tag = "endprovide"
|
||||
allowed_flags = []
|
||||
|
||||
def render(self, context: Context, name: str, **kwargs: Any) -> SafeString:
|
||||
# NOTE: The "provided" kwargs are meant to be shared privately, meaning that components
|
||||
# have to explicitly opt in by using the `Component.inject()` method. That's why we don't
|
||||
# add the provided kwargs into the Context.
|
||||
|
@ -46,14 +91,4 @@ class ProvideNode(BaseNode):
|
|||
|
||||
output = self.nodelist.render(context)
|
||||
|
||||
trace_msg("RENDR", "PROVIDE", self.trace_id, self.node_id, msg="...Done!")
|
||||
return output
|
||||
|
||||
def resolve_kwargs(self, context: Context) -> Tuple[str, Dict[str, Optional[str]]]:
|
||||
args, kwargs = self.params.resolve(context)
|
||||
name = kwargs.pop(PROVIDE_NAME_KWARG, None)
|
||||
|
||||
if not name:
|
||||
raise RuntimeError("Provide tag kwarg 'name' is missing")
|
||||
|
||||
return (name, kwargs)
|
||||
|
|
|
@ -13,7 +13,6 @@ from typing import (
|
|||
Optional,
|
||||
Protocol,
|
||||
Set,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
|
@ -33,9 +32,7 @@ from django_components.context import (
|
|||
_ROOT_CTX_CONTEXT_KEY,
|
||||
)
|
||||
from django_components.node import BaseNode
|
||||
from django_components.util.logger import trace_msg
|
||||
from django_components.util.misc import get_last_index, is_identifier
|
||||
from django_components.util.template_tag import TagParams
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components.component_registry import ComponentRegistry
|
||||
|
@ -150,34 +147,132 @@ class ComponentSlotContext:
|
|||
|
||||
|
||||
class SlotNode(BaseNode):
|
||||
"""Node corresponding to `{% slot %}`"""
|
||||
"""
|
||||
Slot tag marks a place inside a component where content can be inserted
|
||||
from outside.
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nodelist: NodeList,
|
||||
params: TagParams,
|
||||
trace_id: str,
|
||||
node_id: Optional[str] = None,
|
||||
is_required: bool = False,
|
||||
is_default: bool = False,
|
||||
):
|
||||
super().__init__(nodelist=nodelist, params=params, node_id=node_id)
|
||||
[Learn more](../../concepts/fundamentals/slots) about using slots.
|
||||
|
||||
self.is_required = is_required
|
||||
self.is_default = is_default
|
||||
self.trace_id = trace_id
|
||||
This is similar to slots as seen in
|
||||
[Web components](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot),
|
||||
[Vue](https://vuejs.org/guide/components/slots.html)
|
||||
or [React's `children`](https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children).
|
||||
|
||||
@property
|
||||
def active_flags(self) -> List[str]:
|
||||
flags = []
|
||||
if self.is_required:
|
||||
flags.append("required")
|
||||
if self.is_default:
|
||||
flags.append("default")
|
||||
return flags
|
||||
**Args:**
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Slot Node: {self.node_id}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
|
||||
- `name` (str, required): Registered name of the component to render
|
||||
- `default`: Optional flag. If there is a default slot, you can pass the component slot content
|
||||
without using the [`{% fill %}`](#fill) tag. See
|
||||
[Default slot](../../concepts/fundamentals/slots#default-slot)
|
||||
- `required`: Optional flag. Will raise an error if a slot is required but not given.
|
||||
- `**kwargs`: Any extra kwargs will be passed as the slot data.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% slot "content" default %}
|
||||
This is shown if not overriden!
|
||||
{% endslot %}
|
||||
</div>
|
||||
<aside>
|
||||
{% slot "sidebar" required / %}
|
||||
</aside>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% component "child" %}
|
||||
{% fill "content" %}
|
||||
🗞️📰
|
||||
{% endfill %}
|
||||
|
||||
{% fill "sidebar" %}
|
||||
🍷🧉🍾
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
</div>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
### Passing data to slots
|
||||
|
||||
Any extra kwargs will be considered as slot data, and will be accessible in the [`{% fill %}`](#fill)
|
||||
tag via fill's `data` kwarg:
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{# Passing data to the slot #}
|
||||
{% slot "content" user=user %}
|
||||
This is shown if not overriden!
|
||||
{% endslot %}
|
||||
</div>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
{# Parent can access the slot data #}
|
||||
{% component "child" %}
|
||||
{% fill "content" data="data" %}
|
||||
<div class="wrapper-class">
|
||||
{{ data.user }}
|
||||
</div>
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
### Accessing default slot content
|
||||
|
||||
The content between the `{% slot %}..{% endslot %}` tags is the default content that
|
||||
will be rendered if no fill is given for the slot.
|
||||
|
||||
This default content can then be accessed from within the [`{% fill %}`](#fill) tag using
|
||||
the fill's `default` kwarg.
|
||||
This is useful if you need to wrap / prepend / append the original slot's content.
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% slot "content" %}
|
||||
This is default content!
|
||||
{% endslot %}
|
||||
</div>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
{# Parent can access the slot's default content #}
|
||||
{% component "child" %}
|
||||
{% fill "content" default="default" %}
|
||||
{{ default }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
\"\"\"
|
||||
```
|
||||
"""
|
||||
|
||||
tag = "slot"
|
||||
end_tag = "endslot"
|
||||
allowed_flags = [SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD]
|
||||
|
||||
# NOTE:
|
||||
# In the current implementation, the slots are resolved only at the render time.
|
||||
|
@ -200,9 +295,7 @@ class SlotNode(BaseNode):
|
|||
# for unfilled slots (rendered slots WILL raise an error if the fill is missing).
|
||||
# 2. User may provide extra fills, but these may belong to slots we haven't
|
||||
# encountered in this render run. So we CANNOT say which ones are extra.
|
||||
def render(self, context: Context) -> SafeString:
|
||||
trace_msg("RENDR", "SLOT", self.trace_id, self.node_id)
|
||||
|
||||
def render(self, context: Context, name: str, **kwargs: Any) -> SafeString:
|
||||
# Do not render `{% slot %}` tags within the `{% component %} .. {% endcomponent %}` tags
|
||||
# at the fill discovery stage (when we render the component's body to determine if the body
|
||||
# is a default slot, or contains named slots).
|
||||
|
@ -217,10 +310,12 @@ class SlotNode(BaseNode):
|
|||
)
|
||||
|
||||
component_ctx: ComponentSlotContext = context[_COMPONENT_SLOT_CTX_CONTEXT_KEY]
|
||||
slot_name, kwargs = self.resolve_kwargs(context, component_ctx.component_name)
|
||||
slot_name = name
|
||||
is_default = self.flags[SLOT_DEFAULT_KEYWORD]
|
||||
is_required = self.flags[SLOT_REQUIRED_KEYWORD]
|
||||
|
||||
# Check for errors
|
||||
if self.is_default and not component_ctx.is_dynamic_component:
|
||||
if is_default and not component_ctx.is_dynamic_component:
|
||||
# Allow one slot to be marked as 'default', or multiple slots but with
|
||||
# the same name. If there is multiple 'default' slots with different names, raise.
|
||||
default_slot_name = component_ctx.default_slot
|
||||
|
@ -249,7 +344,7 @@ class SlotNode(BaseNode):
|
|||
|
||||
# If slot is marked as 'default', we use the name 'default' for the fill,
|
||||
# IF SUCH FILL EXISTS. Otherwise, we use the slot's name.
|
||||
if self.is_default and DEFAULT_SLOT_KEY in component_ctx.fills:
|
||||
if is_default and DEFAULT_SLOT_KEY in component_ctx.fills:
|
||||
fill_name = DEFAULT_SLOT_KEY
|
||||
else:
|
||||
fill_name = slot_name
|
||||
|
@ -284,7 +379,7 @@ class SlotNode(BaseNode):
|
|||
# Note: Finding a good `cutoff` value may require further trial-and-error.
|
||||
# Higher values make matching stricter. This is probably preferable, as it
|
||||
# reduces false positives.
|
||||
if self.is_required and not slot_fill.is_filled and not component_ctx.is_dynamic_component:
|
||||
if is_required and not slot_fill.is_filled and not component_ctx.is_dynamic_component:
|
||||
msg = (
|
||||
f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), "
|
||||
f"yet no fill is provided. Check template.'"
|
||||
|
@ -349,7 +444,6 @@ class SlotNode(BaseNode):
|
|||
# the render function ALWAYS receives them.
|
||||
output = slot_fill.slot(used_ctx, kwargs, slot_ref)
|
||||
|
||||
trace_msg("RENDR", "SLOT", self.trace_id, self.node_id, msg="...Done!")
|
||||
return output
|
||||
|
||||
def _resolve_slot_context(self, context: Context, slot_fill: "SlotFill") -> Context:
|
||||
|
@ -368,99 +462,150 @@ class SlotNode(BaseNode):
|
|||
else:
|
||||
raise ValueError(f"Unknown value for context_behavior: '{registry.settings.context_behavior}'")
|
||||
|
||||
def resolve_kwargs(
|
||||
self,
|
||||
context: Context,
|
||||
component_name: Optional[str] = None,
|
||||
) -> Tuple[str, Dict[str, Optional[str]]]:
|
||||
_, kwargs = self.params.resolve(context)
|
||||
name = kwargs.pop(SLOT_NAME_KWARG, None)
|
||||
|
||||
if not name:
|
||||
raise RuntimeError(f"Slot tag kwarg 'name' is missing in component {component_name}")
|
||||
|
||||
return (name, kwargs)
|
||||
|
||||
|
||||
class FillNode(BaseNode):
|
||||
"""Node corresponding to `{% fill %}`"""
|
||||
"""
|
||||
Use this tag to insert content into component's slots.
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nodelist: NodeList,
|
||||
params: TagParams,
|
||||
trace_id: str,
|
||||
node_id: Optional[str] = None,
|
||||
):
|
||||
super().__init__(nodelist=nodelist, params=params, node_id=node_id)
|
||||
`{% fill %}` tag may be used only within a `{% component %}..{% endcomponent %}` block.
|
||||
Runtime checks should prohibit other usages.
|
||||
|
||||
self.trace_id = trace_id
|
||||
**Args:**
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
if _is_extracting_fill(context):
|
||||
self._extract_fill(context)
|
||||
return ""
|
||||
- `name` (str, required): Name of the slot to insert this content into. Use `"default"` for
|
||||
the default slot.
|
||||
- `default` (str, optional): This argument allows you to access the original content of the slot
|
||||
under the specified variable name. See
|
||||
[Accessing original content of slots](../../concepts/fundamentals/slots#accessing-original-content-of-slots)
|
||||
- `data` (str, optional): This argument allows you to access the data passed to the slot
|
||||
under the specified variable name. See [Scoped slots](../../concepts/fundamentals/slots#scoped-slots)
|
||||
|
||||
**Examples:**
|
||||
|
||||
Basic usage:
|
||||
```django
|
||||
{% component "my_table" %}
|
||||
{% fill "pagination" %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Accessing slot's default content with the `default` kwarg
|
||||
|
||||
```django
|
||||
{# my_table.html #}
|
||||
<table>
|
||||
...
|
||||
{% slot "pagination" %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endslot %}
|
||||
</table>
|
||||
```
|
||||
|
||||
```django
|
||||
{% component "my_table" %}
|
||||
{% fill "pagination" default="default_pag" %}
|
||||
<div class="my-class">
|
||||
{{ default_pag }}
|
||||
</div>
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Accessing slot's data with the `data` kwarg
|
||||
|
||||
```django
|
||||
{# my_table.html #}
|
||||
<table>
|
||||
...
|
||||
{% slot "pagination" pages=pages %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endslot %}
|
||||
</table>
|
||||
```
|
||||
|
||||
```django
|
||||
{% component "my_table" %}
|
||||
{% fill "pagination" data="slot_data" %}
|
||||
{% for page in slot_data.pages %}
|
||||
<a href="{{ page.link }}">
|
||||
{{ page.index }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Accessing slot data and default content on the default slot
|
||||
|
||||
To access slot data and the default slot content on the default slot,
|
||||
use `{% fill %}` with `name` set to `"default"`:
|
||||
|
||||
```django
|
||||
{% component "button" %}
|
||||
{% fill name="default" data="slot_data" default="default_slot" %}
|
||||
You clicked me {{ slot_data.count }} times!
|
||||
{{ default_slot }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
"""
|
||||
|
||||
tag = "fill"
|
||||
end_tag = "endfill"
|
||||
allowed_flags = []
|
||||
|
||||
def render(self, context: Context, name: str, *, data: Optional[str] = None, default: Optional[str] = None) -> str:
|
||||
if not _is_extracting_fill(context):
|
||||
raise TemplateSyntaxError(
|
||||
"FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. "
|
||||
"Make sure that the {% fill %} tags are nested within {% component %} tags."
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__} ID: {self.node_id}. Contents: {repr(self.nodelist)}.>"
|
||||
|
||||
def resolve_kwargs(self, context: Context) -> "FillWithData":
|
||||
_, kwargs = self.params.resolve(context)
|
||||
|
||||
name = self._process_kwarg(kwargs, SLOT_NAME_KWARG, identifier=False)
|
||||
default_var = self._process_kwarg(kwargs, SLOT_DEFAULT_KWARG)
|
||||
data_var = self._process_kwarg(kwargs, SLOT_DATA_KWARG)
|
||||
|
||||
# Validate inputs
|
||||
if not isinstance(name, str):
|
||||
raise TemplateSyntaxError(f"Fill tag '{SLOT_NAME_KWARG}' kwarg must resolve to a string, got {name}")
|
||||
|
||||
if data_var is not None and not isinstance(data_var, str):
|
||||
raise TemplateSyntaxError(f"Fill tag '{SLOT_DATA_KWARG}' kwarg must resolve to a string, got {data_var}")
|
||||
if data is not None:
|
||||
if not isinstance(data, str):
|
||||
raise TemplateSyntaxError(f"Fill tag '{SLOT_DATA_KWARG}' kwarg must resolve to a string, got {data}")
|
||||
if not is_identifier(data):
|
||||
raise RuntimeError(
|
||||
f"Fill tag kwarg '{SLOT_DATA_KWARG}' does not resolve to a valid Python identifier, got '{data}'"
|
||||
)
|
||||
|
||||
if default_var is not None and not isinstance(default_var, str):
|
||||
if default is not None:
|
||||
if not isinstance(default, str):
|
||||
raise TemplateSyntaxError(
|
||||
f"Fill tag '{SLOT_DEFAULT_KWARG}' kwarg must resolve to a string, got {default_var}"
|
||||
f"Fill tag '{SLOT_DEFAULT_KWARG}' kwarg must resolve to a string, got {default}"
|
||||
)
|
||||
if not is_identifier(default):
|
||||
raise RuntimeError(
|
||||
f"Fill tag kwarg '{SLOT_DEFAULT_KWARG}' does not resolve to a valid Python identifier,"
|
||||
f" got '{default}'"
|
||||
)
|
||||
|
||||
# data and default cannot be bound to the same variable
|
||||
if data_var and default_var and data_var == default_var:
|
||||
if data and default and data == default:
|
||||
raise RuntimeError(
|
||||
f"Fill '{name}' received the same string for slot default ({SLOT_DEFAULT_KWARG}=...)"
|
||||
f" and slot data ({SLOT_DATA_KWARG}=...)"
|
||||
)
|
||||
|
||||
return FillWithData(
|
||||
fill_data = FillWithData(
|
||||
fill=self,
|
||||
name=name,
|
||||
default_var=default_var,
|
||||
data_var=data_var,
|
||||
default_var=default,
|
||||
data_var=data,
|
||||
extra_context={},
|
||||
)
|
||||
|
||||
def _process_kwarg(
|
||||
self,
|
||||
kwargs: Dict[str, Any],
|
||||
key: str,
|
||||
identifier: bool = True,
|
||||
) -> Optional[Any]:
|
||||
if key not in kwargs:
|
||||
return None
|
||||
self._extract_fill(context, fill_data)
|
||||
|
||||
value = kwargs[key]
|
||||
if value is None:
|
||||
return None
|
||||
return ""
|
||||
|
||||
if identifier and not is_identifier(value):
|
||||
raise RuntimeError(f"Fill tag kwarg '{key}' does not resolve to a valid Python identifier, got '{value}'")
|
||||
|
||||
return value
|
||||
|
||||
def _extract_fill(self, context: Context) -> None:
|
||||
def _extract_fill(self, context: Context, data: "FillWithData") -> None:
|
||||
# `FILL_GEN_CONTEXT_KEY` is only ever set when we are rendering content between the
|
||||
# `{% component %}...{% endcomponent %}` tags. This is done in order to collect all fill tags.
|
||||
# E.g.
|
||||
|
@ -474,10 +619,6 @@ class FillNode(BaseNode):
|
|||
if collected_fills is None:
|
||||
return
|
||||
|
||||
# NOTE: It's important that we use the context given to the fill tag, so it accounts
|
||||
# for any variables set via e.g. for-loops.
|
||||
data = self.resolve_kwargs(context)
|
||||
|
||||
# To allow using variables which were defined within the template and to which
|
||||
# the `{% fill %}` tag has access, we need to capture those variables too.
|
||||
#
|
||||
|
|
|
@ -1,707 +1,43 @@
|
|||
# Notes on documentation:
|
||||
# - For intuitive use via Python imports, keep the tag names same as the function name.
|
||||
# E.g. so if the tag name is `slot`, one can also do
|
||||
# `from django_components.templatetags.component_tags import slot`
|
||||
#
|
||||
# - All tags are defined using `@register.tag`. Do NOT use `@register.simple_tag`.
|
||||
# The reason for this is so that we use `TagSpec` and `parse_template_tag`. When generating
|
||||
# documentation, we extract the `TagSpecs` to be able to describe each tag's function signature.
|
||||
#
|
||||
# - Use `with_tag_spec` for defining `TagSpecs`. This will make it available to the function
|
||||
# as the last argument, and will also set the `TagSpec` instance to `fn._tag_spec`.
|
||||
# During documentation generation, we access the `fn._tag_spec`.
|
||||
|
||||
import inspect
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
import django.template
|
||||
from django.template.base import Parser, TextNode, Token
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components.attributes import HtmlAttrsNode
|
||||
from django_components.component import COMP_ONLY_FLAG, ComponentNode
|
||||
from django_components.component_registry import ComponentRegistry
|
||||
from django_components.dependencies import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER
|
||||
from django_components.component import ComponentNode
|
||||
from django_components.dependencies import ComponentCssDependenciesNode, ComponentJsDependenciesNode
|
||||
from django_components.provide import ProvideNode
|
||||
from django_components.slots import SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD, FillNode, SlotNode
|
||||
from django_components.tag_formatter import get_tag_formatter
|
||||
from django_components.util.logger import trace_msg
|
||||
from django_components.util.misc import gen_id
|
||||
from django_components.util.template_tag import TagSpec, parse_template_tag, with_tag_spec
|
||||
from django_components.slots import FillNode, SlotNode
|
||||
|
||||
# NOTE: Variable name `register` is required by Django to recognize this as a template tag library
|
||||
# See https://docs.djangoproject.com/en/dev/howto/custom-template-tags
|
||||
register = django.template.Library()
|
||||
|
||||
|
||||
def _component_dependencies(type: Literal["js", "css"]) -> SafeString:
|
||||
"""Marks location where CSS link and JS script tags should be rendered."""
|
||||
if type == "css":
|
||||
placeholder = CSS_DEPENDENCY_PLACEHOLDER
|
||||
elif type == "js":
|
||||
placeholder = JS_DEPENDENCY_PLACEHOLDER
|
||||
else:
|
||||
raise TemplateSyntaxError(
|
||||
f"Unknown dependency type in {{% component_dependencies %}}. Must be one of 'css' or 'js', got {type}"
|
||||
)
|
||||
|
||||
return TextNode(mark_safe(placeholder))
|
||||
|
||||
|
||||
def component_dependencies_signature() -> None: ... # noqa: E704
|
||||
|
||||
|
||||
@register.tag("component_css_dependencies")
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="component_css_dependencies",
|
||||
end_tag=None, # inline-only
|
||||
signature=inspect.Signature.from_callable(component_dependencies_signature),
|
||||
)
|
||||
)
|
||||
def component_css_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -> TextNode:
|
||||
"""
|
||||
Marks location where CSS link tags should be rendered after the whole HTML has been generated.
|
||||
|
||||
Generally, this should be inserted into the `<head>` tag of the HTML.
|
||||
|
||||
If the generated HTML does NOT contain any `{% component_css_dependencies %}` tags, CSS links
|
||||
are by default inserted into the `<head>` tag of the HTML. (See
|
||||
[JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations))
|
||||
|
||||
Note that there should be only one `{% component_css_dependencies %}` for the whole HTML document.
|
||||
If you insert this tag multiple times, ALL CSS links will be duplicately inserted into ALL these places.
|
||||
"""
|
||||
# Parse to check that the syntax is valid
|
||||
parse_template_tag(parser, token, tag_spec)
|
||||
return _component_dependencies("css")
|
||||
|
||||
|
||||
@register.tag("component_js_dependencies")
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="component_js_dependencies",
|
||||
end_tag=None, # inline-only
|
||||
signature=inspect.Signature.from_callable(component_dependencies_signature),
|
||||
)
|
||||
)
|
||||
def component_js_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -> TextNode:
|
||||
"""
|
||||
Marks location where JS link tags should be rendered after the whole HTML has been generated.
|
||||
|
||||
Generally, this should be inserted at the end of the `<body>` tag of the HTML.
|
||||
|
||||
If the generated HTML does NOT contain any `{% component_js_dependencies %}` tags, JS scripts
|
||||
are by default inserted at the end of the `<body>` tag of the HTML. (See
|
||||
[JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations))
|
||||
|
||||
Note that there should be only one `{% component_js_dependencies %}` for the whole HTML document.
|
||||
If you insert this tag multiple times, ALL JS scripts will be duplicately inserted into ALL these places.
|
||||
"""
|
||||
# Parse to check that the syntax is valid
|
||||
parse_template_tag(parser, token, tag_spec)
|
||||
return _component_dependencies("js")
|
||||
|
||||
|
||||
def slot_signature(name: str, **kwargs: Any) -> None: ... # noqa: E704
|
||||
|
||||
|
||||
@register.tag("slot")
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="slot",
|
||||
end_tag="endslot",
|
||||
signature=inspect.Signature.from_callable(slot_signature),
|
||||
flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD],
|
||||
)
|
||||
)
|
||||
def slot(parser: Parser, token: Token, tag_spec: TagSpec) -> SlotNode:
|
||||
"""
|
||||
Slot tag marks a place inside a component where content can be inserted
|
||||
from outside.
|
||||
|
||||
[Learn more](../../concepts/fundamentals/slots) about using slots.
|
||||
|
||||
This is similar to slots as seen in
|
||||
[Web components](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot),
|
||||
[Vue](https://vuejs.org/guide/components/slots.html)
|
||||
or [React's `children`](https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children).
|
||||
|
||||
**Args:**
|
||||
|
||||
- `name` (str, required): Registered name of the component to render
|
||||
- `default`: Optional flag. If there is a default slot, you can pass the component slot content
|
||||
without using the [`{% fill %}`](#fill) tag. See
|
||||
[Default slot](../../concepts/fundamentals/slots#default-slot)
|
||||
- `required`: Optional flag. Will raise an error if a slot is required but not given.
|
||||
- `**kwargs`: Any extra kwargs will be passed as the slot data.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% slot "content" default %}
|
||||
This is shown if not overriden!
|
||||
{% endslot %}
|
||||
</div>
|
||||
<aside>
|
||||
{% slot "sidebar" required / %}
|
||||
</aside>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% component "child" %}
|
||||
{% fill "content" %}
|
||||
🗞️📰
|
||||
{% endfill %}
|
||||
|
||||
{% fill "sidebar" %}
|
||||
🍷🧉🍾
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
</div>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
### Passing data to slots
|
||||
|
||||
Any extra kwargs will be considered as slot data, and will be accessible in the [`{% fill %}`](#fill)
|
||||
tag via fill's `data` kwarg:
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{# Passing data to the slot #}
|
||||
{% slot "content" user=user %}
|
||||
This is shown if not overriden!
|
||||
{% endslot %}
|
||||
</div>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
{# Parent can access the slot data #}
|
||||
{% component "child" %}
|
||||
{% fill "content" data="data" %}
|
||||
<div class="wrapper-class">
|
||||
{{ data.user }}
|
||||
</div>
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
### Accessing default slot content
|
||||
|
||||
The content between the `{% slot %}..{% endslot %}` tags is the default content that
|
||||
will be rendered if no fill is given for the slot.
|
||||
|
||||
This default content can then be accessed from within the [`{% fill %}`](#fill) tag using
|
||||
the fill's `default` kwarg.
|
||||
This is useful if you need to wrap / prepend / append the original slot's content.
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% slot "content" %}
|
||||
This is default content!
|
||||
{% endslot %}
|
||||
</div>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
{# Parent can access the slot's default content #}
|
||||
{% component "child" %}
|
||||
{% fill "content" default="default" %}
|
||||
{{ default }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
\"\"\"
|
||||
```
|
||||
"""
|
||||
tag_id = gen_id()
|
||||
tag = parse_template_tag(parser, token, tag_spec)
|
||||
|
||||
trace_id = f"slot-id-{tag_id}"
|
||||
trace_msg("PARSE", "SLOT", trace_id, tag_id)
|
||||
|
||||
body = tag.parse_body()
|
||||
slot_node = SlotNode(
|
||||
nodelist=body,
|
||||
node_id=tag_id,
|
||||
params=tag.params,
|
||||
is_required=tag.flags[SLOT_REQUIRED_KEYWORD],
|
||||
is_default=tag.flags[SLOT_DEFAULT_KEYWORD],
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", "SLOT", trace_id, tag_id, "...Done!")
|
||||
return slot_node
|
||||
|
||||
|
||||
def fill_signature(name: str, *, data: Optional[str] = None, default: Optional[str] = None) -> None: ... # noqa: E704
|
||||
|
||||
|
||||
@register.tag("fill")
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="fill",
|
||||
end_tag="endfill",
|
||||
signature=inspect.Signature.from_callable(fill_signature),
|
||||
)
|
||||
)
|
||||
def fill(parser: Parser, token: Token, tag_spec: TagSpec) -> FillNode:
|
||||
"""
|
||||
Use this tag to insert content into component's slots.
|
||||
|
||||
`{% fill %}` tag may be used only within a `{% component %}..{% endcomponent %}` block.
|
||||
Runtime checks should prohibit other usages.
|
||||
|
||||
**Args:**
|
||||
|
||||
- `name` (str, required): Name of the slot to insert this content into. Use `"default"` for
|
||||
the default slot.
|
||||
- `default` (str, optional): This argument allows you to access the original content of the slot
|
||||
under the specified variable name. See
|
||||
[Accessing original content of slots](../../concepts/fundamentals/slots#accessing-original-content-of-slots)
|
||||
- `data` (str, optional): This argument allows you to access the data passed to the slot
|
||||
under the specified variable name. See [Scoped slots](../../concepts/fundamentals/slots#scoped-slots)
|
||||
|
||||
**Examples:**
|
||||
|
||||
Basic usage:
|
||||
```django
|
||||
{% component "my_table" %}
|
||||
{% fill "pagination" %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Accessing slot's default content with the `default` kwarg
|
||||
|
||||
```django
|
||||
{# my_table.html #}
|
||||
<table>
|
||||
...
|
||||
{% slot "pagination" %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endslot %}
|
||||
</table>
|
||||
```
|
||||
|
||||
```django
|
||||
{% component "my_table" %}
|
||||
{% fill "pagination" default="default_pag" %}
|
||||
<div class="my-class">
|
||||
{{ default_pag }}
|
||||
</div>
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Accessing slot's data with the `data` kwarg
|
||||
|
||||
```django
|
||||
{# my_table.html #}
|
||||
<table>
|
||||
...
|
||||
{% slot "pagination" pages=pages %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endslot %}
|
||||
</table>
|
||||
```
|
||||
|
||||
```django
|
||||
{% component "my_table" %}
|
||||
{% fill "pagination" data="slot_data" %}
|
||||
{% for page in slot_data.pages %}
|
||||
<a href="{{ page.link }}">
|
||||
{{ page.index }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Accessing slot data and default content on the default slot
|
||||
|
||||
To access slot data and the default slot content on the default slot,
|
||||
use `{% fill %}` with `name` set to `"default"`:
|
||||
|
||||
```django
|
||||
{% component "button" %}
|
||||
{% fill name="default" data="slot_data" default="default_slot" %}
|
||||
You clicked me {{ slot_data.count }} times!
|
||||
{{ default_slot }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
"""
|
||||
tag_id = gen_id()
|
||||
tag = parse_template_tag(parser, token, tag_spec)
|
||||
|
||||
trace_id = f"fill-id-{tag_id}"
|
||||
trace_msg("PARSE", "FILL", trace_id, tag_id)
|
||||
|
||||
body = tag.parse_body()
|
||||
fill_node = FillNode(
|
||||
nodelist=body,
|
||||
node_id=tag_id,
|
||||
params=tag.params,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", "FILL", trace_id, tag_id, "...Done!")
|
||||
return fill_node
|
||||
|
||||
|
||||
def component_signature(*args: Any, **kwargs: Any) -> None: ... # noqa: E704
|
||||
|
||||
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="component",
|
||||
end_tag="endcomponent",
|
||||
signature=inspect.Signature.from_callable(component_signature),
|
||||
flags=[COMP_ONLY_FLAG],
|
||||
)
|
||||
)
|
||||
def component(
|
||||
parser: Parser,
|
||||
token: Token,
|
||||
registry: ComponentRegistry,
|
||||
tag_name: str,
|
||||
tag_spec: TagSpec,
|
||||
) -> ComponentNode:
|
||||
"""
|
||||
Renders one of the components that was previously registered with
|
||||
[`@register()`](./api.md#django_components.register)
|
||||
decorator.
|
||||
|
||||
**Args:**
|
||||
|
||||
- `name` (str, required): Registered name of the component to render
|
||||
- All other args and kwargs are defined based on the component itself.
|
||||
|
||||
If you defined a component `"my_table"`
|
||||
|
||||
```python
|
||||
from django_component import Component, register
|
||||
|
||||
@register("my_table")
|
||||
class MyTable(Component):
|
||||
template = \"\"\"
|
||||
<table>
|
||||
<thead>
|
||||
{% for header in headers %}
|
||||
<th>{{ header }}</th>
|
||||
{% endfor %}
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td>{{ cell }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tbody>
|
||||
</table>
|
||||
\"\"\"
|
||||
|
||||
def get_context_data(self, rows: List, headers: List):
|
||||
return {
|
||||
"rows": rows,
|
||||
"headers": headers,
|
||||
}
|
||||
```
|
||||
|
||||
Then you can render this component by referring to `MyTable` via its
|
||||
registered name `"my_table"`:
|
||||
|
||||
```django
|
||||
{% component "my_table" rows=rows headers=headers ... / %}
|
||||
```
|
||||
|
||||
### Component input
|
||||
|
||||
Positional and keyword arguments can be literals or template variables.
|
||||
|
||||
The component name must be a single- or double-quotes string and must
|
||||
be either:
|
||||
|
||||
- The first positional argument after `component`:
|
||||
|
||||
```django
|
||||
{% component "my_table" rows=rows headers=headers ... / %}
|
||||
```
|
||||
|
||||
- Passed as kwarg `name`:
|
||||
|
||||
```django
|
||||
{% component rows=rows headers=headers name="my_table" ... / %}
|
||||
```
|
||||
|
||||
### Inserting into slots
|
||||
|
||||
If the component defined any [slots](../concepts/fundamentals/slots.md), you can
|
||||
pass in the content to be placed inside those slots by inserting [`{% fill %}`](#fill) tags,
|
||||
directly within the `{% component %}` tag:
|
||||
|
||||
```django
|
||||
{% component "my_table" rows=rows headers=headers ... / %}
|
||||
{% fill "pagination" %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Isolating components
|
||||
|
||||
By default, components behave similarly to Django's
|
||||
[`{% include %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#include),
|
||||
and the template inside the component has access to the variables defined in the outer template.
|
||||
|
||||
You can selectively isolate a component, using the `only` flag, so that the inner template
|
||||
can access only the data that was explicitly passed to it:
|
||||
|
||||
```django
|
||||
{% component "name" positional_arg keyword_arg=value ... only %}
|
||||
```
|
||||
"""
|
||||
tag_id = gen_id()
|
||||
|
||||
bits = token.split_contents()
|
||||
|
||||
# Let the TagFormatter pre-process the tokens
|
||||
formatter = get_tag_formatter(registry)
|
||||
result = formatter.parse([*bits])
|
||||
end_tag = formatter.end_tag(result.component_name)
|
||||
|
||||
# NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself,
|
||||
# so we add it back in.
|
||||
bits = [bits[0], *result.tokens]
|
||||
token.contents = " ".join(bits)
|
||||
|
||||
# Set the component-specific start and end tags
|
||||
component_tag_spec = tag_spec.copy()
|
||||
component_tag_spec.tag = tag_name
|
||||
component_tag_spec.end_tag = end_tag
|
||||
|
||||
tag = parse_template_tag(parser, token, component_tag_spec)
|
||||
|
||||
trace_msg("PARSE", "COMP", result.component_name, tag_id)
|
||||
|
||||
body = tag.parse_body()
|
||||
|
||||
component_node = ComponentNode(
|
||||
name=result.component_name,
|
||||
params=tag.params,
|
||||
isolated_context=tag.flags[COMP_ONLY_FLAG],
|
||||
nodelist=body,
|
||||
node_id=tag_id,
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", "COMP", result.component_name, tag_id, "...Done!")
|
||||
return component_node
|
||||
|
||||
|
||||
def provide_signature(name: str, **kwargs: Any) -> None: ... # noqa: E704
|
||||
|
||||
|
||||
@register.tag("provide")
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="provide",
|
||||
end_tag="endprovide",
|
||||
signature=inspect.Signature.from_callable(provide_signature),
|
||||
flags=[],
|
||||
)
|
||||
)
|
||||
def provide(parser: Parser, token: Token, tag_spec: TagSpec) -> ProvideNode:
|
||||
"""
|
||||
The "provider" part of the [provide / inject feature](../../concepts/advanced/provide_inject).
|
||||
Pass kwargs to this tag to define the provider's data.
|
||||
Any components defined within the `{% provide %}..{% endprovide %}` tags will be able to access this data
|
||||
with [`Component.inject()`](../api#django_components.Component.inject).
|
||||
|
||||
This is similar to React's [`ContextProvider`](https://react.dev/learn/passing-data-deeply-with-context),
|
||||
or Vue's [`provide()`](https://vuejs.org/guide/components/provide-inject).
|
||||
|
||||
**Args:**
|
||||
|
||||
- `name` (str, required): Provider name. This is the name you will then use in
|
||||
[`Component.inject()`](../api#django_components.Component.inject).
|
||||
- `**kwargs`: Any extra kwargs will be passed as the provided data.
|
||||
|
||||
**Example:**
|
||||
|
||||
Provide the "user_data" in parent component:
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% provide "user_data" user=user %}
|
||||
{% component "child" / %}
|
||||
{% endprovide %}
|
||||
</div>
|
||||
\"\"\"
|
||||
|
||||
def get_context_data(self, user: User):
|
||||
return {
|
||||
"user": user,
|
||||
}
|
||||
```
|
||||
|
||||
Since the "child" component is used within the `{% provide %} / {% endprovide %}` tags,
|
||||
we can request the "user_data" using `Component.inject("user_data")`:
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
User is: {{ user }}
|
||||
</div>
|
||||
\"\"\"
|
||||
|
||||
def get_context_data(self):
|
||||
user = self.inject("user_data").user
|
||||
return {
|
||||
"user": user,
|
||||
}
|
||||
```
|
||||
|
||||
Notice that the keys defined on the `{% provide %}` tag are then accessed as attributes
|
||||
when accessing them with [`Component.inject()`](../api#django_components.Component.inject).
|
||||
|
||||
✅ Do this
|
||||
```python
|
||||
user = self.inject("user_data").user
|
||||
```
|
||||
|
||||
❌ Don't do this
|
||||
```python
|
||||
user = self.inject("user_data")["user"]
|
||||
```
|
||||
"""
|
||||
tag_id = gen_id()
|
||||
|
||||
# e.g. {% provide <name> key=val key2=val2 %}
|
||||
tag = parse_template_tag(parser, token, tag_spec)
|
||||
|
||||
trace_id = f"fill-id-{tag_id}"
|
||||
trace_msg("PARSE", "PROVIDE", trace_id, tag_id)
|
||||
|
||||
body = tag.parse_body()
|
||||
provide_node = ProvideNode(
|
||||
nodelist=body,
|
||||
node_id=tag_id,
|
||||
params=tag.params,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", "PROVIDE", trace_id, tag_id, "...Done!")
|
||||
return provide_node
|
||||
|
||||
|
||||
def html_attrs_signature( # noqa: E704
|
||||
attrs: Optional[Dict] = None, defaults: Optional[Dict] = None, **kwargs: Any
|
||||
) -> None: ...
|
||||
|
||||
|
||||
@register.tag("html_attrs")
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="html_attrs",
|
||||
end_tag=None, # inline-only
|
||||
signature=inspect.Signature.from_callable(html_attrs_signature),
|
||||
flags=[],
|
||||
)
|
||||
)
|
||||
def html_attrs(parser: Parser, token: Token, tag_spec: TagSpec) -> HtmlAttrsNode:
|
||||
"""
|
||||
Generate HTML attributes (`key="value"`), combining data from multiple sources,
|
||||
whether its template variables or static text.
|
||||
|
||||
It is designed to easily merge HTML attributes passed from outside with the internal.
|
||||
See how to in [Passing HTML attributes to components](../../guides/howto/passing_html_attrs/).
|
||||
|
||||
**Args:**
|
||||
|
||||
- `attrs` (dict, optional): Optional dictionary that holds HTML attributes. On conflict, overrides
|
||||
values in the `default` dictionary.
|
||||
- `default` (str, optional): Optional dictionary that holds HTML attributes. On conflict, is overriden
|
||||
with values in the `attrs` dictionary.
|
||||
- Any extra kwargs will be appended to the corresponding keys
|
||||
|
||||
The attributes in `attrs` and `defaults` are merged and resulting dict is rendered as HTML attributes
|
||||
(`key="value"`).
|
||||
|
||||
Extra kwargs (`key=value`) are concatenated to existing keys. So if we have
|
||||
|
||||
```python
|
||||
attrs = {"class": "my-class"}
|
||||
```
|
||||
|
||||
Then
|
||||
|
||||
```django
|
||||
{% html_attrs attrs class="extra-class" %}
|
||||
```
|
||||
|
||||
will result in `class="my-class extra-class"`.
|
||||
|
||||
**Example:**
|
||||
```django
|
||||
<div {% html_attrs
|
||||
attrs
|
||||
defaults:class="default-class"
|
||||
class="extra-class"
|
||||
data-id="123"
|
||||
%}>
|
||||
```
|
||||
|
||||
renders
|
||||
|
||||
```html
|
||||
<div class="my-class extra-class" data-id="123">
|
||||
```
|
||||
|
||||
**See more usage examples in
|
||||
[HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).**
|
||||
"""
|
||||
tag_id = gen_id()
|
||||
tag = parse_template_tag(parser, token, tag_spec)
|
||||
|
||||
return HtmlAttrsNode(
|
||||
node_id=tag_id,
|
||||
params=tag.params,
|
||||
)
|
||||
# All tags are defined with our BaseNode class. Reasons for that are:
|
||||
# - This ensures they all have the same set of features, like supporting flags,
|
||||
# or literal lists and dicts as parameters.
|
||||
# - The individual Node classes double as a source of truth for the tag's documentation.
|
||||
#
|
||||
# NOTE: The documentation generation script in `docs/scripts/reference.py` actually
|
||||
# searches this file for all `Node` classes and uses them to generate the documentation.
|
||||
# The docstring on the Node classes is used as the tag's documentation.
|
||||
ComponentNode.register(register)
|
||||
ComponentCssDependenciesNode.register(register)
|
||||
ComponentJsDependenciesNode.register(register)
|
||||
FillNode.register(register)
|
||||
HtmlAttrsNode.register(register)
|
||||
ProvideNode.register(register)
|
||||
SlotNode.register(register)
|
||||
|
||||
|
||||
# For an intuitive use via Python imports, the tags are aliased to the function name.
|
||||
# E.g. so if the tag name is `slot`, one can also do:
|
||||
# `from django_components.templatetags.component_tags import slot`
|
||||
component = ComponentNode.parse
|
||||
component_css_dependencies = ComponentCssDependenciesNode.parse
|
||||
component_js_dependencies = ComponentJsDependenciesNode.parse
|
||||
fill = FillNode.parse
|
||||
html_attrs = HtmlAttrsNode.parse
|
||||
provide = ProvideNode.parse
|
||||
slot = SlotNode.parse
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import logging
|
||||
import sys
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
from typing import Any, Dict, Literal
|
||||
|
||||
DEFAULT_TRACE_LEVEL_NUM = 5 # NOTE: MUST be lower than DEBUG which is 10
|
||||
|
||||
|
@ -62,27 +62,18 @@ def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> No
|
|||
|
||||
|
||||
def trace_msg(
|
||||
action: Literal["PARSE", "RENDR", "GET", "SET"],
|
||||
node_type: Literal["COMP", "FILL", "SLOT", "PROVIDE", "N/A"],
|
||||
node_name: str,
|
||||
action: Literal["PARSE", "RENDR"],
|
||||
node_type: str,
|
||||
node_id: str,
|
||||
msg: str = "",
|
||||
component_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
TRACE level logger with opinionated format for tracing interaction of components,
|
||||
nodes, and slots. Formats messages like so:
|
||||
|
||||
`"ASSOC SLOT test_slot ID 0088 TO COMP 0087"`
|
||||
`"PARSE slot ID 0088 ...Done!"`
|
||||
"""
|
||||
msg_prefix = ""
|
||||
if action == "RENDR" and node_type == "FILL":
|
||||
if not component_id:
|
||||
raise ValueError("component_id must be set for the RENDER action")
|
||||
msg_prefix = f"FOR COMP {component_id}"
|
||||
|
||||
msg_parts = [f"{action} {node_type} {node_name} ID {node_id}", *([msg_prefix] if msg_prefix else []), msg]
|
||||
full_msg = " ".join(msg_parts)
|
||||
full_msg = f"{action} {node_type} ID {node_id} {msg}"
|
||||
|
||||
# NOTE: When debugging tests during development, it may be easier to change
|
||||
# this to `print()`
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import functools
|
||||
"""
|
||||
This file is for logic that focuses on transforming the AST of template tags
|
||||
(as parsed from tag_parser) into a form that can be used by the Nodes.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, Iterable, List, Mapping, NamedTuple, Optional, Set, Tuple
|
||||
from typing import Any, Callable, Dict, Iterable, List, Mapping, NamedTuple, Optional, Set, Tuple, Union
|
||||
|
||||
from django.template import Context, NodeList
|
||||
from django.template.base import Parser, Token
|
||||
|
@ -11,152 +16,36 @@ from django_components.expression import process_aggregate_kwargs
|
|||
from django_components.util.tag_parser import TagAttr, parse_tag
|
||||
|
||||
|
||||
@dataclass
|
||||
class TagSpec:
|
||||
"""Definition of args, kwargs, flags, etc, for a template tag."""
|
||||
|
||||
signature: inspect.Signature
|
||||
"""Input to the tag as a Python function signature."""
|
||||
tag: str
|
||||
"""Tag name. E.g. `"slot"` means the tag is written like so `{% slot ... %}`"""
|
||||
end_tag: Optional[str] = None
|
||||
# For details see https://github.com/EmilStenstrom/django-components/pull/902#discussion_r1913611633
|
||||
# and following comments
|
||||
def validate_params(
|
||||
tag: str,
|
||||
signature: inspect.Signature,
|
||||
params: List["TagParam"],
|
||||
extra_kwargs: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
End tag.
|
||||
|
||||
E.g. `"endslot"` means anything between the start tag and `{% endslot %}`
|
||||
is considered the slot's body.
|
||||
"""
|
||||
flags: Optional[List[str]] = None
|
||||
"""
|
||||
List of allowed flags.
|
||||
|
||||
Flags are like kwargs, but without the value part. E.g. in `{% mytag only required %}`:
|
||||
- `only` and `required` are treated as `only=True` and `required=True` if present
|
||||
- and treated as `only=False` and `required=False` if omitted
|
||||
"""
|
||||
|
||||
def copy(self) -> "TagSpec":
|
||||
sig_parameters_copy = [param.replace() for param in self.signature.parameters.values()]
|
||||
signature = inspect.Signature(sig_parameters_copy)
|
||||
flags = self.flags.copy() if self.flags else None
|
||||
return self.__class__(
|
||||
signature=signature,
|
||||
tag=self.tag,
|
||||
end_tag=self.end_tag,
|
||||
flags=flags,
|
||||
)
|
||||
|
||||
# For details see https://github.com/EmilStenstrom/django-components/pull/902
|
||||
def validate_params(self, params: List["TagParam"]) -> Tuple[List[Any], Dict[str, Any]]:
|
||||
"""
|
||||
Validates a list of TagParam objects against this tag spec's function signature.
|
||||
Validates a list of TagParam objects against this tag's function signature.
|
||||
|
||||
The validation preserves the order of parameters as they appeared in the template.
|
||||
|
||||
Args:
|
||||
params: List of TagParam objects representing the parameters as they appeared
|
||||
in the template tag.
|
||||
|
||||
Returns:
|
||||
A tuple of (args, kwargs) containing the validated parameters.
|
||||
|
||||
Raises:
|
||||
TypeError: If the parameters don't match the tag spec's rules.
|
||||
Raises `TypeError` if the parameters don't match the tag's signature.
|
||||
"""
|
||||
|
||||
# Create a function with this signature that captures the input and sorts
|
||||
# it into args and kwargs
|
||||
def validator(*args: Any, **kwargs: Any) -> Tuple[List[Any], Dict[str, Any]]:
|
||||
# Create a function that uses the given signature
|
||||
def validator(*args: Any, **kwargs: Any) -> None:
|
||||
# Let Python do the signature validation
|
||||
bound = self.signature.bind(*args, **kwargs)
|
||||
bound = signature.bind(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
|
||||
# Extract positional args
|
||||
pos_args: List[Any] = []
|
||||
for name, param in self.signature.parameters.items():
|
||||
# Case: `name` (positional)
|
||||
if param.kind == inspect.Parameter.POSITIONAL_ONLY:
|
||||
pos_args.append(bound.arguments[name])
|
||||
# Case: `*args`
|
||||
elif param.kind == inspect.Parameter.VAR_POSITIONAL:
|
||||
pos_args.extend(bound.arguments[name])
|
||||
|
||||
# Extract kwargs
|
||||
kw_args: Dict[str, Any] = {}
|
||||
for name, param in self.signature.parameters.items():
|
||||
# Case: `name=...`
|
||||
if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY):
|
||||
if name in bound.arguments:
|
||||
kw_args[name] = bound.arguments[name]
|
||||
# Case: `**kwargs`
|
||||
elif param.kind == inspect.Parameter.VAR_KEYWORD:
|
||||
kw_args.update(bound.arguments[name])
|
||||
|
||||
return pos_args, kw_args
|
||||
|
||||
# Set the signature on the function
|
||||
validator.__signature__ = self.signature # type: ignore[attr-defined]
|
||||
|
||||
# Call the validator with our args and kwargs, in such a way to
|
||||
# let the Python interpreter validate on repeated kwargs. E.g.
|
||||
#
|
||||
# ```
|
||||
# args, kwargs = validator(
|
||||
# *call_args,
|
||||
# **call_kwargs[0],
|
||||
# **call_kwargs[1],
|
||||
# ...
|
||||
# )
|
||||
# ```
|
||||
call_args = []
|
||||
call_kwargs = []
|
||||
for param in params:
|
||||
if param.key is None:
|
||||
call_args.append(param.value)
|
||||
else:
|
||||
call_kwargs.append({param.key: param.value})
|
||||
|
||||
# NOTE: Although we use `exec()` here, it's safe, because we control the input -
|
||||
# we make dynamic only the list index.
|
||||
#
|
||||
# We MUST use the indices, because we can't trust neither the param keys nor values,
|
||||
# so we MUST NOT reference them directly in the exec script, otherwise we'd be at risk
|
||||
# of injection attack.
|
||||
validator_call_script = "args, kwargs = validator(*call_args, "
|
||||
for kw_index, _ in enumerate(call_kwargs):
|
||||
validator_call_script += f"**call_kwargs[{kw_index}], "
|
||||
validator_call_script += ")"
|
||||
validator.__signature__ = signature # type: ignore[attr-defined]
|
||||
|
||||
# Call the validator with our args and kwargs in the same order as they appeared
|
||||
# in the template, to let the Python interpreter validate on repeated kwargs.
|
||||
try:
|
||||
# Create function namespace
|
||||
namespace: Dict[str, Any] = {"validator": validator, "call_args": call_args, "call_kwargs": call_kwargs}
|
||||
exec(validator_call_script, namespace)
|
||||
new_args, new_kwargs = namespace["args"], namespace["kwargs"]
|
||||
return new_args, new_kwargs
|
||||
apply_params_in_original_order(validator, params, extra_kwargs)
|
||||
except TypeError as e:
|
||||
# Enhance the error message
|
||||
raise TypeError(f"Invalid parameters for tag '{self.tag}': {str(e)}") from None
|
||||
|
||||
|
||||
def with_tag_spec(tag_spec: TagSpec) -> Callable:
|
||||
"""
|
||||
Decorator that binds a `tag_spec` to a template tag function,
|
||||
there's a single source of truth for the tag spec, while also:
|
||||
|
||||
1. Making the tag spec available inside the tag function as `tag_spec`.
|
||||
2. Making the tag spec accessible from outside as `_tag_spec` for documentation generation.
|
||||
"""
|
||||
|
||||
def decorator(fn: Callable) -> Any:
|
||||
fn._tag_spec = tag_spec # type: ignore[attr-defined]
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
return fn(*args, **kwargs, tag_spec=tag_spec)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
raise TypeError(f"Invalid parameters for tag '{tag}': {str(e)}") from None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -180,25 +69,15 @@ class TagParam:
|
|||
value: Any
|
||||
|
||||
|
||||
class TagParams(NamedTuple):
|
||||
"""
|
||||
TagParams holds the parsed tag attributes and the tag spec, so that, at render time,
|
||||
when we are able to resolve the tag inputs with the given Context, we are also able to validate
|
||||
the inputs against the tag spec.
|
||||
|
||||
This is done so that the tag's public API (as defined in the tag spec) can be defined
|
||||
next to the tag implementation. Otherwise the input validation would have to be defined by
|
||||
the internal `Node` classes.
|
||||
"""
|
||||
|
||||
params: List[TagAttr]
|
||||
tag_spec: TagSpec
|
||||
|
||||
def resolve(self, context: Context) -> Tuple[List[Any], Dict[str, Any]]:
|
||||
def resolve_params(
|
||||
tag: str,
|
||||
params: List[TagAttr],
|
||||
context: Context,
|
||||
) -> List[TagParam]:
|
||||
# First, resolve any spread operators. Spreads can introduce both positional
|
||||
# args (e.g. `*args`) and kwargs (e.g. `**kwargs`).
|
||||
resolved_params: List[TagParam] = []
|
||||
for param in self.params:
|
||||
for param in params:
|
||||
resolved = param.value.resolve(context)
|
||||
|
||||
if param.value.spread:
|
||||
|
@ -218,26 +97,26 @@ class TagParams(NamedTuple):
|
|||
else:
|
||||
resolved_params.append(TagParam(key=param.key, value=resolved))
|
||||
|
||||
if self.tag_spec.tag == "html_attrs":
|
||||
if tag == "html_attrs":
|
||||
resolved_params = merge_repeated_kwargs(resolved_params)
|
||||
resolved_params = process_aggregate_kwargs(resolved_params)
|
||||
|
||||
args, kwargs = self.tag_spec.validate_params(resolved_params)
|
||||
return args, kwargs
|
||||
return resolved_params
|
||||
|
||||
|
||||
# Data obj to give meaning to the parsed tag fields
|
||||
class ParsedTag(NamedTuple):
|
||||
tag_name: str
|
||||
flags: Dict[str, bool]
|
||||
params: TagParams
|
||||
params: List[TagAttr]
|
||||
parse_body: Callable[[], NodeList]
|
||||
|
||||
|
||||
def parse_template_tag(
|
||||
tag: str,
|
||||
end_tag: Optional[str],
|
||||
allowed_flags: Optional[List[str]],
|
||||
parser: Parser,
|
||||
token: Token,
|
||||
tag_spec: TagSpec,
|
||||
) -> ParsedTag:
|
||||
_, attrs = parse_tag(token.contents, parser)
|
||||
|
||||
|
@ -246,13 +125,14 @@ def parse_template_tag(
|
|||
tag_name = tag_name_attr.serialize(omit_key=True)
|
||||
|
||||
# Sanity check
|
||||
if tag_name != tag_spec.tag:
|
||||
raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag_spec.tag}'")
|
||||
if tag_name != tag:
|
||||
raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag}'")
|
||||
|
||||
# There's 3 ways how we tell when a tag ends:
|
||||
# 1. If the tag contains `/` at the end, it's a self-closing tag (like `<div />`),
|
||||
# and it doesn't have an end tag. In this case we strip the trailing slash.
|
||||
# Otherwise, depending on the tag spec, the tag may be:
|
||||
#
|
||||
# Otherwise, depending on the end_tag, the tag may be:
|
||||
# 2. Block tag - With corresponding end tag, e.g. `{% endslot %}`
|
||||
# 3. Inlined tag - Without the end tag.
|
||||
last_token = attrs[-1].value if len(attrs) else None
|
||||
|
@ -260,9 +140,9 @@ def parse_template_tag(
|
|||
attrs.pop()
|
||||
is_inline = True
|
||||
else:
|
||||
is_inline = not tag_spec.end_tag
|
||||
is_inline = not end_tag
|
||||
|
||||
raw_params, flags = _extract_flags(tag_name, attrs, tag_spec.flags or [])
|
||||
raw_params, flags = _extract_flags(tag_name, attrs, allowed_flags or [])
|
||||
|
||||
def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
|
||||
if inline:
|
||||
|
@ -273,14 +153,13 @@ def parse_template_tag(
|
|||
return body
|
||||
|
||||
return ParsedTag(
|
||||
tag_name=tag_name,
|
||||
params=TagParams(params=raw_params, tag_spec=tag_spec),
|
||||
params=raw_params,
|
||||
flags=flags,
|
||||
# NOTE: We defer parsing of the body, so we have the chance to call the tracing
|
||||
# loggers before the parsing. This is because, if the body contains any other
|
||||
# tags, it will trigger their tag handlers. So the code called AFTER
|
||||
# `parse_body()` is already after all the nested tags were processed.
|
||||
parse_body=lambda: _parse_tag_body(parser, tag_spec.end_tag, is_inline) if tag_spec.end_tag else NodeList(),
|
||||
parse_body=lambda: _parse_tag_body(parser, end_tag, is_inline) if end_tag else NodeList(),
|
||||
)
|
||||
|
||||
|
||||
|
@ -305,7 +184,7 @@ def _extract_flags(
|
|||
found_flags.add(value)
|
||||
|
||||
flags_dict: Dict[str, bool] = {
|
||||
# Base state, as defined in the tag spec
|
||||
# Base state - all flags False
|
||||
**{flag: False for flag in (allowed_flags or [])},
|
||||
# Flags found on the template tag
|
||||
**{flag: True for flag in found_flags},
|
||||
|
@ -348,3 +227,99 @@ def merge_repeated_kwargs(params: List[TagParam]) -> List[TagParam]:
|
|||
params_by_key[param.key].value += " " + str(param.value)
|
||||
|
||||
return resolved_params
|
||||
|
||||
|
||||
def apply_params_in_original_order(
|
||||
fn: Callable[..., Any],
|
||||
params: List[TagParam],
|
||||
extra_kwargs: Optional[Dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Apply a list of `TagParams` to another function, keeping the order of the params as they
|
||||
appeared in the template.
|
||||
|
||||
If a template tag was called like this:
|
||||
|
||||
```django
|
||||
{% component key1=value1 arg1 arg2 key2=value2 key3=value3 %}
|
||||
```
|
||||
|
||||
Then `apply_params_in_original_order()` will call the `fn` like this:
|
||||
```
|
||||
component(
|
||||
key1=call_params[0], # kwarg 1
|
||||
call_params[1], # arg 1
|
||||
call_params[2], # arg 2
|
||||
key2=call_params[3], # kwarg 2
|
||||
key3=call_params[4], # kwarg 3
|
||||
...
|
||||
**extra_kwargs,
|
||||
)
|
||||
```
|
||||
|
||||
This way, this will be effectively the same as:
|
||||
|
||||
```python
|
||||
component(key1=value1, arg1, arg2, key2=value2, key3=value3, ..., **extra_kwargs)
|
||||
```
|
||||
|
||||
The problem this works around is that, dynamically, args and kwargs in Python
|
||||
can be passed only with `*args` and `**kwargs`. But in such case, we're already
|
||||
grouping all args and kwargs, which may not represent the original order of the params
|
||||
as they appeared in the template tag.
|
||||
|
||||
If you need to pass kwargs that are not valid Python identifiers, e.g. `data-id`, `class`, `:href`,
|
||||
you can pass them in via `extra_kwargs`. These kwargs will be exempt from the validation, and will be
|
||||
passed to the function as a dictionary spread.
|
||||
"""
|
||||
# Generate a script like so:
|
||||
# ```py
|
||||
# component(
|
||||
# key1=call_params[0],
|
||||
# call_params[1],
|
||||
# call_params[2],
|
||||
# key2=call_params[3],
|
||||
# key3=call_params[4],
|
||||
# ...
|
||||
# **extra_kwargs,
|
||||
# )
|
||||
# ```
|
||||
#
|
||||
# NOTE: Instead of grouping params into args and kwargs, we preserve the original order
|
||||
# of the params as they appeared in the template.
|
||||
#
|
||||
# NOTE: Because we use `eval()` here, we can't trust neither the param keys nor values.
|
||||
# So we MUST NOT reference them directly in the exec script, otherwise we'd be at risk
|
||||
# of injection attack.
|
||||
#
|
||||
# Currently, the use of `eval()` is safe, because we control the input:
|
||||
# - List with indices is used so that we don't have to reference directly or try to print the values.
|
||||
# and instead refer to them as `call_params[0]`, `call_params[1]`, etc.
|
||||
# - List indices are safe, because we generate them.
|
||||
# - Kwarg names come from the user. But Python expects the kwargs to be valid identifiers.
|
||||
# So if a key is not a valid identifier, we'll raise an error. Before passing it to `eval()`
|
||||
validator_call_script = "fn("
|
||||
call_params: List[Union[List, Dict]] = []
|
||||
for index, param in enumerate(params):
|
||||
call_params.append(param.value)
|
||||
if param.key is None:
|
||||
validator_call_script += f"call_params[{index}], "
|
||||
else:
|
||||
validator_call_script += f"{param.key}=call_params[{index}], "
|
||||
|
||||
validator_call_script += "**extra_kwargs, "
|
||||
validator_call_script += ")"
|
||||
|
||||
def applier(fn: Callable[..., Any]) -> Any:
|
||||
locals = {
|
||||
"fn": fn,
|
||||
"call_params": call_params,
|
||||
"extra_kwargs": extra_kwargs or {},
|
||||
}
|
||||
# NOTE: `eval()` changed API in Python 3.13
|
||||
if sys.version_info >= (3, 13):
|
||||
return eval(validator_call_script, globals={}, locals=locals)
|
||||
else:
|
||||
return eval(validator_call_script, {}, locals)
|
||||
|
||||
return applier(fn)
|
||||
|
|
|
@ -22,6 +22,11 @@ def setup_test_config(
|
|||
"tests/templates/",
|
||||
"tests/components/", # Required for template relative imports in tests
|
||||
],
|
||||
"OPTIONS": {
|
||||
"builtins": [
|
||||
"django_components.templatetags.component_tags",
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
"COMPONENTS": {
|
||||
|
|
|
@ -108,7 +108,7 @@ class MainMediaTest(BaseTestCase):
|
|||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<form data-djc-id-a1bc3f method="post">
|
||||
<form data-djc-id-a1bc41 method="post">
|
||||
<input name="variable" type="text" value="test"/>
|
||||
<input type="submit"/>
|
||||
</form>
|
||||
|
@ -184,7 +184,7 @@ class MainMediaTest(BaseTestCase):
|
|||
rendered = render_dependencies(rendered_raw)
|
||||
|
||||
self.assertIn(
|
||||
"Variable: <strong data-djc-id-a1bc3f>test</strong>",
|
||||
"Variable: <strong data-djc-id-a1bc41>test</strong>",
|
||||
rendered,
|
||||
)
|
||||
self.assertInHTML(
|
||||
|
@ -915,7 +915,7 @@ class MediaRelativePathTests(BaseTestCase):
|
|||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<form data-djc-id-a1bc3f method="post">
|
||||
<form data-djc-id-a1bc41 method="post">
|
||||
<input type="text" name="variable" value="test">
|
||||
<input type="submit">
|
||||
</form>
|
||||
|
|
|
@ -265,7 +265,7 @@ class RenderDependenciesTests(BaseTestCase):
|
|||
self.assertInHTML(
|
||||
"""
|
||||
<body>
|
||||
Variable: <strong data-djc-id-a1bc3f>foo</strong>
|
||||
Variable: <strong data-djc-id-a1bc41>foo</strong>
|
||||
|
||||
<style>.xyz { color: red; }</style>
|
||||
<link href="style.css" media="all" rel="stylesheet">
|
||||
|
@ -510,7 +510,7 @@ class MiddlewareTests(BaseTestCase):
|
|||
|
||||
assert_dependencies(rendered1)
|
||||
self.assertEqual(
|
||||
rendered1.count("Variable: <strong data-djc-id-a1bc3f data-djc-id-a1bc40>value</strong>"),
|
||||
rendered1.count("Variable: <strong data-djc-id-a1bc41 data-djc-id-a1bc42>value</strong>"),
|
||||
1,
|
||||
)
|
||||
|
||||
|
@ -520,7 +520,7 @@ class MiddlewareTests(BaseTestCase):
|
|||
)
|
||||
assert_dependencies(rendered2)
|
||||
self.assertEqual(
|
||||
rendered2.count("Variable: <strong data-djc-id-a1bc41 data-djc-id-a1bc42>value</strong>"),
|
||||
rendered2.count("Variable: <strong data-djc-id-a1bc43 data-djc-id-a1bc44>value</strong>"),
|
||||
1,
|
||||
)
|
||||
|
||||
|
@ -531,6 +531,6 @@ class MiddlewareTests(BaseTestCase):
|
|||
|
||||
assert_dependencies(rendered3)
|
||||
self.assertEqual(
|
||||
rendered3.count("Variable: <strong data-djc-id-a1bc43 data-djc-id-a1bc44>value</strong>"),
|
||||
rendered3.count("Variable: <strong data-djc-id-a1bc45 data-djc-id-a1bc46>value</strong>"),
|
||||
1,
|
||||
)
|
||||
|
|
|
@ -745,10 +745,7 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
|
||||
template1 = Template(template_str1)
|
||||
|
||||
with self.assertRaisesMessage(
|
||||
TypeError,
|
||||
"got multiple values for keyword argument 'x'",
|
||||
):
|
||||
with self.assertRaisesMessage(SyntaxError, "keyword argument repeated"):
|
||||
template1.render(context)
|
||||
|
||||
# But, similarly to python, we can merge multiple **kwargs by instead
|
||||
|
|
570
tests/test_node.py
Normal file
570
tests/test_node.py
Normal file
|
@ -0,0 +1,570 @@
|
|||
from django.template import Context, Template
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
|
||||
from django_components import types
|
||||
from django_components.node import BaseNode, template_tag
|
||||
from django_components.templatetags import component_tags
|
||||
|
||||
from .django_test_setup import setup_test_config
|
||||
from .testutils import BaseTestCase
|
||||
|
||||
setup_test_config({"autodiscover": False})
|
||||
|
||||
|
||||
class NodeTests(BaseTestCase):
|
||||
def test_node_class_requires_tag(self):
|
||||
with self.assertRaises(ValueError):
|
||||
|
||||
class CaptureNode(BaseNode):
|
||||
pass
|
||||
|
||||
# Test that the template tag can be used within the template under the registered tag
|
||||
def test_node_class_tags(self):
|
||||
class TestNode(BaseNode):
|
||||
tag = "mytag"
|
||||
end_tag = "endmytag"
|
||||
|
||||
def render(self, context: Context, name: str, **kwargs) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
TestNode.register(component_tags.register)
|
||||
|
||||
# Works with end tag and self-closing
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' %}
|
||||
{% endmytag %}
|
||||
Shorthand: {% mytag 'Mary' / %}
|
||||
"""
|
||||
template = Template(template_str)
|
||||
rendered = template.render(Context({}))
|
||||
|
||||
self.assertEqual(rendered.strip(), "Hello, John!\n Shorthand: Hello, Mary!")
|
||||
|
||||
# But raises if missing end tag
|
||||
template_str2: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' %}
|
||||
"""
|
||||
with self.assertRaisesMessage(TemplateSyntaxError, "Unclosed tag on line 3: 'mytag'"):
|
||||
Template(template_str2)
|
||||
|
||||
TestNode.unregister(component_tags.register)
|
||||
|
||||
def test_node_class_no_end_tag(self):
|
||||
class TestNode(BaseNode):
|
||||
tag = "mytag"
|
||||
|
||||
def render(self, context: Context, name: str, **kwargs) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
TestNode.register(component_tags.register)
|
||||
|
||||
# Raises with end tag or self-closing
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' %}
|
||||
{% endmytag %}
|
||||
Shorthand: {% mytag 'Mary' / %}
|
||||
"""
|
||||
with self.assertRaisesMessage(TemplateSyntaxError, "Invalid block tag on line 4: 'endmytag'"):
|
||||
Template(template_str)
|
||||
|
||||
# Works when missing end tag
|
||||
template_str2: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' %}
|
||||
"""
|
||||
template2 = Template(template_str2)
|
||||
rendered2 = template2.render(Context({}))
|
||||
self.assertEqual(rendered2.strip(), "Hello, John!")
|
||||
|
||||
TestNode.unregister(component_tags.register)
|
||||
|
||||
def test_node_class_flags(self):
|
||||
captured = None
|
||||
|
||||
class TestNode(BaseNode):
|
||||
tag = "mytag"
|
||||
end_tag = "endmytag"
|
||||
allowed_flags = ["required", "default"]
|
||||
|
||||
def render(self, context: Context, name: str, **kwargs) -> str:
|
||||
nonlocal captured
|
||||
captured = self.allowed_flags, self.flags, self.active_flags
|
||||
|
||||
return f"Hello, {name}!"
|
||||
|
||||
TestNode.register(component_tags.register)
|
||||
|
||||
template_str = """
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' required / %}
|
||||
"""
|
||||
template = Template(template_str)
|
||||
template.render(Context({}))
|
||||
|
||||
allowed_flags, flags, active_flags = captured # type: ignore
|
||||
self.assertEqual(allowed_flags, ["required", "default"])
|
||||
self.assertEqual(flags, {"required": True, "default": False})
|
||||
self.assertEqual(active_flags, ["required"])
|
||||
|
||||
TestNode.unregister(component_tags.register)
|
||||
|
||||
def test_node_render(self):
|
||||
# Check that the render function is called with the context
|
||||
captured = None
|
||||
|
||||
class TestNode(BaseNode):
|
||||
tag = "mytag"
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
nonlocal captured
|
||||
captured = context.flatten()
|
||||
|
||||
return f"Hello, {context['name']}!"
|
||||
|
||||
TestNode.register(component_tags.register)
|
||||
|
||||
template_str = """
|
||||
{% load component_tags %}
|
||||
{% mytag / %}
|
||||
"""
|
||||
template = Template(template_str)
|
||||
rendered = template.render(Context({"name": "John"}))
|
||||
|
||||
self.assertEqual(captured, {"False": False, "None": None, "True": True, "name": "John"})
|
||||
self.assertEqual(rendered.strip(), "Hello, John!")
|
||||
|
||||
TestNode.unregister(component_tags.register)
|
||||
|
||||
def test_node_render_raises_if_no_context_arg(self):
|
||||
with self.assertRaisesMessage(TypeError, "`render()` method of TestNode must have at least two parameters"):
|
||||
|
||||
class TestNode(BaseNode):
|
||||
tag = "mytag"
|
||||
|
||||
def render(self) -> str: # type: ignore
|
||||
return ""
|
||||
|
||||
def test_node_render_accepted_params_set_by_render_signature(self):
|
||||
captured = None
|
||||
|
||||
class TestNode1(BaseNode):
|
||||
tag = "mytag"
|
||||
allowed_flags = ["required", "default"]
|
||||
|
||||
def render(self, context: Context, name: str, count: int = 1, *, msg: str, mode: str = "default") -> str:
|
||||
nonlocal captured
|
||||
captured = name, count, msg, mode
|
||||
return ""
|
||||
|
||||
TestNode1.register(component_tags.register)
|
||||
|
||||
# Set only required params
|
||||
template1 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' msg='Hello' required %}
|
||||
"""
|
||||
)
|
||||
template1.render(Context({}))
|
||||
self.assertEqual(captured, ("John", 1, "Hello", "default"))
|
||||
|
||||
# Set all params
|
||||
template2 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag 'John2' count=2 msg='Hello' mode='custom' required %}
|
||||
"""
|
||||
)
|
||||
template2.render(Context({}))
|
||||
self.assertEqual(captured, ("John2", 2, "Hello", "custom"))
|
||||
|
||||
# Set no params
|
||||
template3 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(
|
||||
TypeError, "Invalid parameters for tag 'mytag': missing a required argument: 'name'"
|
||||
):
|
||||
template3.render(Context({}))
|
||||
|
||||
# Omit required arg
|
||||
template4 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag msg='Hello' %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(
|
||||
TypeError, "Invalid parameters for tag 'mytag': missing a required argument: 'name'"
|
||||
):
|
||||
template4.render(Context({}))
|
||||
|
||||
# Omit required kwarg
|
||||
template5 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag name='John' %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(
|
||||
TypeError, "Invalid parameters for tag 'mytag': missing a required argument: 'msg'"
|
||||
):
|
||||
template5.render(Context({}))
|
||||
|
||||
# Extra args
|
||||
template6 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag 123 count=1 name='John' %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(
|
||||
TypeError, "Invalid parameters for tag 'mytag': multiple values for argument 'name'"
|
||||
):
|
||||
template6.render(Context({}))
|
||||
|
||||
# Extra args after kwargs
|
||||
template6 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag count=1 name='John' 123 %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(SyntaxError, "positional argument follows keyword argument"):
|
||||
template6.render(Context({}))
|
||||
|
||||
# Extra kwargs
|
||||
template7 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' msg='Hello' mode='custom' var=123 %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(
|
||||
TypeError, "Invalid parameters for tag 'mytag': got an unexpected keyword argument 'var'"
|
||||
):
|
||||
template7.render(Context({}))
|
||||
|
||||
# Extra kwargs - non-identifier or kwargs
|
||||
template8 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' msg='Hello' mode='custom' data-id=123 class="pa-4" @click.once="myVar" %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(
|
||||
TypeError, "Invalid parameters for tag 'mytag': got an unexpected keyword argument 'data-id'"
|
||||
):
|
||||
template8.render(Context({}))
|
||||
|
||||
# Extra arg after special kwargs
|
||||
template9 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag data-id=123 'John' msg='Hello' %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(SyntaxError, "positional argument follows keyword argument"):
|
||||
template9.render(Context({}))
|
||||
|
||||
TestNode1.unregister(component_tags.register)
|
||||
|
||||
def test_node_render_extra_args_and_kwargs(self):
|
||||
captured = None
|
||||
|
||||
class TestNode1(BaseNode):
|
||||
tag = "mytag"
|
||||
allowed_flags = ["required", "default"]
|
||||
|
||||
def render(self, context: Context, name: str, *args, msg: str, **kwargs) -> str:
|
||||
nonlocal captured
|
||||
captured = name, args, msg, kwargs
|
||||
return ""
|
||||
|
||||
TestNode1.register(component_tags.register)
|
||||
|
||||
template1 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag 'John'
|
||||
123 456 789 msg='Hello' a=1 b=2 c=3 required
|
||||
data-id=123 class="pa-4" @click.once="myVar"
|
||||
%}
|
||||
"""
|
||||
)
|
||||
template1.render(Context({}))
|
||||
self.assertEqual(
|
||||
captured,
|
||||
(
|
||||
"John",
|
||||
(123, 456, 789),
|
||||
"Hello",
|
||||
{"a": 1, "b": 2, "c": 3, "data-id": 123, "class": "pa-4", "@click.once": "myVar"},
|
||||
),
|
||||
)
|
||||
|
||||
TestNode1.unregister(component_tags.register)
|
||||
|
||||
|
||||
class DecoratorTests(BaseTestCase):
|
||||
def test_decorator_requires_tag(self):
|
||||
with self.assertRaisesMessage(TypeError, "template_tag() missing 1 required positional argument: 'tag'"):
|
||||
|
||||
@template_tag(component_tags.register) # type: ignore
|
||||
def mytag(node: BaseNode, context: Context) -> str:
|
||||
return ""
|
||||
|
||||
# Test that the template tag can be used within the template under the registered tag
|
||||
def test_decorator_tags(self):
|
||||
@template_tag(component_tags.register, tag="mytag", end_tag="endmytag")
|
||||
def render(node: BaseNode, context: Context, name: str, **kwargs) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# Works with end tag and self-closing
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' %}
|
||||
{% endmytag %}
|
||||
Shorthand: {% mytag 'Mary' / %}
|
||||
"""
|
||||
template = Template(template_str)
|
||||
rendered = template.render(Context({}))
|
||||
|
||||
self.assertEqual(rendered.strip(), "Hello, John!\n Shorthand: Hello, Mary!")
|
||||
|
||||
# But raises if missing end tag
|
||||
template_str2: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' %}
|
||||
"""
|
||||
with self.assertRaisesMessage(TemplateSyntaxError, "Unclosed tag on line 3: 'mytag'"):
|
||||
Template(template_str2)
|
||||
|
||||
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
|
||||
|
||||
def test_decorator_no_end_tag(self):
|
||||
@template_tag(component_tags.register, tag="mytag") # type: ignore
|
||||
def render(node: BaseNode, context: Context, name: str, **kwargs) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# Raises with end tag or self-closing
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' %}
|
||||
{% endmytag %}
|
||||
Shorthand: {% mytag 'Mary' / %}
|
||||
"""
|
||||
with self.assertRaisesMessage(TemplateSyntaxError, "Invalid block tag on line 4: 'endmytag'"):
|
||||
Template(template_str)
|
||||
|
||||
# Works when missing end tag
|
||||
template_str2: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' %}
|
||||
"""
|
||||
template2 = Template(template_str2)
|
||||
rendered2 = template2.render(Context({}))
|
||||
self.assertEqual(rendered2.strip(), "Hello, John!")
|
||||
|
||||
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
|
||||
|
||||
def test_decorator_flags(self):
|
||||
@template_tag(component_tags.register, tag="mytag", end_tag="endmytag", allowed_flags=["required", "default"])
|
||||
def render(node: BaseNode, context: Context, name: str, **kwargs) -> str:
|
||||
return ""
|
||||
|
||||
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
|
||||
|
||||
def test_decorator_render(self):
|
||||
# Check that the render function is called with the context
|
||||
captured = None
|
||||
|
||||
@template_tag(component_tags.register, tag="mytag") # type: ignore
|
||||
def render(node: BaseNode, context: Context) -> str:
|
||||
nonlocal captured
|
||||
captured = context.flatten()
|
||||
return f"Hello, {context['name']}!"
|
||||
|
||||
template_str = """
|
||||
{% load component_tags %}
|
||||
{% mytag / %}
|
||||
"""
|
||||
template = Template(template_str)
|
||||
rendered = template.render(Context({"name": "John"}))
|
||||
|
||||
self.assertEqual(captured, {"False": False, "None": None, "True": True, "name": "John"})
|
||||
self.assertEqual(rendered.strip(), "Hello, John!")
|
||||
|
||||
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
|
||||
|
||||
def test_decorator_render_raises_if_no_context_arg(self):
|
||||
with self.assertRaisesMessage(
|
||||
TypeError,
|
||||
"Failed to create node class in 'template_tag()' for 'render'",
|
||||
):
|
||||
|
||||
@template_tag(component_tags.register, tag="mytag") # type: ignore
|
||||
def render(node: BaseNode) -> str: # type: ignore
|
||||
return ""
|
||||
|
||||
def test_decorator_render_accepted_params_set_by_render_signature(self):
|
||||
captured = None
|
||||
|
||||
@template_tag(component_tags.register, tag="mytag", allowed_flags=["required", "default"]) # type: ignore
|
||||
def render(
|
||||
node: BaseNode, context: Context, name: str, count: int = 1, *, msg: str, mode: str = "default"
|
||||
) -> str:
|
||||
nonlocal captured
|
||||
captured = name, count, msg, mode
|
||||
return ""
|
||||
|
||||
# Set only required params
|
||||
template1 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' msg='Hello' required %}
|
||||
"""
|
||||
)
|
||||
template1.render(Context({}))
|
||||
self.assertEqual(captured, ("John", 1, "Hello", "default"))
|
||||
|
||||
# Set all params
|
||||
template2 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag 'John2' count=2 msg='Hello' mode='custom' required %}
|
||||
"""
|
||||
)
|
||||
template2.render(Context({}))
|
||||
self.assertEqual(captured, ("John2", 2, "Hello", "custom"))
|
||||
|
||||
# Set no params
|
||||
template3 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(
|
||||
TypeError, "Invalid parameters for tag 'mytag': missing a required argument: 'name'"
|
||||
):
|
||||
template3.render(Context({}))
|
||||
|
||||
# Omit required arg
|
||||
template4 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag msg='Hello' %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(
|
||||
TypeError, "Invalid parameters for tag 'mytag': missing a required argument: 'name'"
|
||||
):
|
||||
template4.render(Context({}))
|
||||
|
||||
# Omit required kwarg
|
||||
template5 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag name='John' %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(
|
||||
TypeError, "Invalid parameters for tag 'mytag': missing a required argument: 'msg'"
|
||||
):
|
||||
template5.render(Context({}))
|
||||
|
||||
# Extra args
|
||||
template6 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag 123 count=1 name='John' %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(
|
||||
TypeError, "Invalid parameters for tag 'mytag': multiple values for argument 'name'"
|
||||
):
|
||||
template6.render(Context({}))
|
||||
|
||||
# Extra args after kwargs
|
||||
template6 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag count=1 name='John' 123 %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(SyntaxError, "positional argument follows keyword argument"):
|
||||
template6.render(Context({}))
|
||||
|
||||
# Extra kwargs
|
||||
template7 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' msg='Hello' mode='custom' var=123 %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(
|
||||
TypeError, "Invalid parameters for tag 'mytag': got an unexpected keyword argument 'var'"
|
||||
):
|
||||
template7.render(Context({}))
|
||||
|
||||
# Extra kwargs - non-identifier or kwargs
|
||||
template8 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag 'John' msg='Hello' mode='custom' data-id=123 class="pa-4" @click.once="myVar" %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(
|
||||
TypeError, "Invalid parameters for tag 'mytag': got an unexpected keyword argument 'data-id'"
|
||||
):
|
||||
template8.render(Context({}))
|
||||
|
||||
# Extra arg after special kwargs
|
||||
template9 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag data-id=123 'John' msg='Hello' %}
|
||||
"""
|
||||
)
|
||||
with self.assertRaisesMessage(SyntaxError, "positional argument follows keyword argument"):
|
||||
template9.render(Context({}))
|
||||
|
||||
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
|
||||
|
||||
def test_decorator_render_extra_args_and_kwargs(self):
|
||||
captured = None
|
||||
|
||||
@template_tag(component_tags.register, tag="mytag", allowed_flags=["required", "default"]) # type: ignore
|
||||
def render(node: BaseNode, context: Context, name: str, *args, msg: str, **kwargs) -> str:
|
||||
nonlocal captured
|
||||
captured = name, args, msg, kwargs
|
||||
return ""
|
||||
|
||||
template1 = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% mytag 'John'
|
||||
123 456 789 msg='Hello' a=1 b=2 c=3 required
|
||||
data-id=123 class="pa-4" @click.once="myVar"
|
||||
%}
|
||||
"""
|
||||
)
|
||||
template1.render(Context({}))
|
||||
self.assertEqual(
|
||||
captured,
|
||||
(
|
||||
"John",
|
||||
(123, 456, 789),
|
||||
"Hello",
|
||||
{"a": 1, "b": 2, "c": 3, "data-id": 123, "class": "pa-4", "@click.once": "myVar"},
|
||||
),
|
||||
)
|
||||
|
||||
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
|
|
@ -381,7 +381,10 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
with self.assertRaisesMessage(RuntimeError, "Provide tag kwarg 'name' is missing"):
|
||||
with self.assertRaisesMessage(
|
||||
TemplateSyntaxError,
|
||||
"Provide tag received an empty string. Key must be non-empty and a valid identifier",
|
||||
):
|
||||
Template(template_str).render(Context({}))
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue