mirror of
https://github.com/django-components/django-components.git
synced 2025-10-08 13:10:18 +00:00
feat: TagFormatter - Allow users to customize component template tags (#572)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
b89c09aa5f
commit
71d8679e8d
23 changed files with 1593 additions and 474 deletions
238
README.md
238
README.md
|
@ -37,6 +37,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
|
||||||
- [Rendering HTML attributes](#rendering-html-attributes)
|
- [Rendering HTML attributes](#rendering-html-attributes)
|
||||||
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject)
|
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject)
|
||||||
- [Component context and scope](#component-context-and-scope)
|
- [Component context and scope](#component-context-and-scope)
|
||||||
|
- [Customizing component tags with TagFormatter](#customizing-component-tags-with-tagformatter)
|
||||||
- [Defining HTML/JS/CSS files](#defining-htmljscss-files)
|
- [Defining HTML/JS/CSS files](#defining-htmljscss-files)
|
||||||
- [Rendering JS/CSS dependencies](#rendering-jscss-dependencies)
|
- [Rendering JS/CSS dependencies](#rendering-jscss-dependencies)
|
||||||
- [Available settings](#available-settings)
|
- [Available settings](#available-settings)
|
||||||
|
@ -48,6 +49,34 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
|
||||||
|
|
||||||
## Release notes
|
## Release notes
|
||||||
|
|
||||||
|
**Version 0.90**
|
||||||
|
- All tags (`component`, `slot`, `fill`, ...) now support "self-closing" or "inline" form, where you can omit the closing tag:
|
||||||
|
```django
|
||||||
|
{# Before #}
|
||||||
|
{% component "button" %}{% endcomponent %}
|
||||||
|
{# After #}
|
||||||
|
{% component "button" / %}
|
||||||
|
```
|
||||||
|
- All tags now support the "dictionary key" or "aggregate" syntax (`kwarg:key=val`):
|
||||||
|
```django
|
||||||
|
{% component "button" attrs:class="hidden" %}
|
||||||
|
```
|
||||||
|
- You can change how the components are written in the template with [TagFormatter](#customizing-component-tags-with-tagformatter).
|
||||||
|
|
||||||
|
The default is `django_components.component_formatter`:
|
||||||
|
```django
|
||||||
|
{% component "button" href="..." disabled %}
|
||||||
|
Click me!
|
||||||
|
{% endcomponent %}
|
||||||
|
```
|
||||||
|
|
||||||
|
While `django_components.shorthand_component_formatter` allows you to write components like so:
|
||||||
|
|
||||||
|
```django
|
||||||
|
{% button href="..." disabled %}
|
||||||
|
Click me!
|
||||||
|
{% endbutton %}
|
||||||
|
|
||||||
🚨📢 **Version 0.85** Autodiscovery module resolution changed. Following undocumented behavior was removed:
|
🚨📢 **Version 0.85** Autodiscovery module resolution changed. Following undocumented behavior was removed:
|
||||||
|
|
||||||
- Previously, autodiscovery also imported any `[app]/components.py` files, and used `SETTINGS_MODULE` to search for component dirs.
|
- Previously, autodiscovery also imported any `[app]/components.py` files, and used `SETTINGS_MODULE` to search for component dirs.
|
||||||
|
@ -420,6 +449,10 @@ First load the `component_tags` tag library, then use the `component_[js/css]_de
|
||||||
<html>
|
<html>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> NOTE: Instead of writing `{% endcomponent %}` at the end, you can use a self-closing tag:
|
||||||
|
>
|
||||||
|
> `{% component "calendar" date="2015-06-19" / %}`
|
||||||
|
|
||||||
The output from the above template will be:
|
The output from the above template will be:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
|
@ -461,7 +494,7 @@ class SimpleComponent(Component):
|
||||||
hello: {{ hello }}
|
hello: {{ hello }}
|
||||||
foo: {{ foo }}
|
foo: {{ foo }}
|
||||||
kwargs: {{ kwargs|safe }}
|
kwargs: {{ kwargs|safe }}
|
||||||
slot_first: {% slot "first" required %}{% endslot %}
|
slot_first: {% slot "first" required / %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_context_data(self, arg1, arg2, **kwargs):
|
def get_context_data(self, arg1, arg2, **kwargs):
|
||||||
|
@ -597,7 +630,7 @@ class Calendar(Component):
|
||||||
template = """
|
template = """
|
||||||
<div class="calendar-component">
|
<div class="calendar-component">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
{% slot "header" %}{% endslot %}
|
{% slot "header" / %}
|
||||||
</div>
|
</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
Today's date is <span>{{ date }}</span>
|
Today's date is <span>{{ date }}</span>
|
||||||
|
@ -1134,7 +1167,7 @@ To negate the meaning of `component_vars.is_filled`, simply treat it as boolean
|
||||||
```htmldjango
|
```htmldjango
|
||||||
{% if not component_vars.is_filled.subtitle %}
|
{% if not component_vars.is_filled.subtitle %}
|
||||||
<div class="subtitle">
|
<div class="subtitle">
|
||||||
{% slot "subtitle" %}{% endslot %}
|
{% slot "subtitle" / %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
```
|
```
|
||||||
|
@ -1266,8 +1299,7 @@ so are still valid:
|
||||||
|
|
||||||
```django
|
```django
|
||||||
<body>
|
<body>
|
||||||
{% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True %}
|
{% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %}
|
||||||
{% endcomponent %}
|
|
||||||
</body>
|
</body>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -1302,8 +1334,7 @@ But for that, we need to define this dictionary on Python side:
|
||||||
@register("my_comp")
|
@register("my_comp")
|
||||||
class MyComp(Component):
|
class MyComp(Component):
|
||||||
template = """
|
template = """
|
||||||
{% component "other" attrs=attrs %}
|
{% component "other" attrs=attrs / %}
|
||||||
{% endcomponent %}
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_context_data(self, some_id: str):
|
def get_context_data(self, some_id: str):
|
||||||
|
@ -1334,8 +1365,7 @@ class MyComp(Component):
|
||||||
attrs:class="pa-4 flex"
|
attrs:class="pa-4 flex"
|
||||||
attrs:data-some-id=some_id
|
attrs:data-some-id=some_id
|
||||||
attrs:@click.stop="onClickHandler"
|
attrs:@click.stop="onClickHandler"
|
||||||
%}
|
/ %}
|
||||||
{% endcomponent %}
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_context_data(self, some_id: str):
|
def get_context_data(self, some_id: str):
|
||||||
|
@ -1349,8 +1379,7 @@ Sweet! Now all the relevant HTML is inside the template, and we can move it to a
|
||||||
attrs:class="pa-4 flex"
|
attrs:class="pa-4 flex"
|
||||||
attrs:data-some-id=some_id
|
attrs:data-some-id=some_id
|
||||||
attrs:@click.stop="onClickHandler"
|
attrs:@click.stop="onClickHandler"
|
||||||
%}
|
/ %}
|
||||||
{% endcomponent %}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Note: It is NOT possible to define nested dictionaries, so
|
> Note: It is NOT possible to define nested dictionaries, so
|
||||||
|
@ -1646,8 +1675,7 @@ class Parent(Component):
|
||||||
attrs:class="pa-0 border-solid border-red"
|
attrs:class="pa-0 border-solid border-red"
|
||||||
attrs:data-json=json_data
|
attrs:data-json=json_data
|
||||||
attrs:@click="(e) => onClick(e, 'from_parent')"
|
attrs:@click="(e) => onClick(e, 'from_parent')"
|
||||||
%}
|
/ %}
|
||||||
{% endcomponent %}
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_context_data(self, date: Date):
|
def get_context_data(self, date: Date):
|
||||||
|
@ -1775,12 +1803,10 @@ First we use the `{% provide %}` tag to define the data we want to "provide" (ma
|
||||||
|
|
||||||
```django
|
```django
|
||||||
{% provide "my_data" key="hi" another=123 %}
|
{% provide "my_data" key="hi" another=123 %}
|
||||||
{% component "child" %} <--- Can access "my_data"
|
{% component "child" / %} <--- Can access "my_data"
|
||||||
{% endcomponent %}
|
|
||||||
{% endprovide %}
|
{% endprovide %}
|
||||||
|
|
||||||
{% component "child" %} <--- Cannot access "my_data"
|
{% component "child" / %} <--- Cannot access "my_data"
|
||||||
{% endcomponent %}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Notice that the `provide` tag REQUIRES a name as a first argument. This is the _key_ by which we can then access the data passed to this tag.
|
Notice that the `provide` tag REQUIRES a name as a first argument. This is the _key_ by which we can then access the data passed to this tag.
|
||||||
|
@ -1854,8 +1880,7 @@ class ChildComponent(Component):
|
||||||
template_str = """
|
template_str = """
|
||||||
{% load component_tags %}
|
{% load component_tags %}
|
||||||
{% provide "my_data" key="hi" another=123 %}
|
{% provide "my_data" key="hi" another=123 %}
|
||||||
{% component "child" %}
|
{% component "child" / %}
|
||||||
{% endcomponent %}
|
|
||||||
{% endprovide %}
|
{% endprovide %}
|
||||||
"""
|
"""
|
||||||
```
|
```
|
||||||
|
@ -1873,7 +1898,7 @@ By default, context variables are passed down the template as in regular Django
|
||||||
|
|
||||||
With this in mind, the `{% component %}` tag behaves similarly to `{% include %}` tag - inside the component tag, you can access all variables that were defined outside of it.
|
With this in mind, the `{% component %}` tag behaves similarly to `{% include %}` tag - inside the component tag, you can access all variables that were defined outside of it.
|
||||||
|
|
||||||
And just like with `{% include %}`, if you don't want a specific component template to have access to the parent context, add `only` to the end of the `{% component %}` tag:
|
And just like with `{% include %}`, if you don't want a specific component template to have access to the parent context, add `only` to the `{% component %}` tag:
|
||||||
|
|
||||||
```htmldjango
|
```htmldjango
|
||||||
{% component "calendar" date="2015-06-19" only %}{% endcomponent %}
|
{% component "calendar" date="2015-06-19" only %}{% endcomponent %}
|
||||||
|
@ -1885,6 +1910,155 @@ If you find yourself using the `only` modifier often, you can set the [context_b
|
||||||
|
|
||||||
Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`.
|
Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`.
|
||||||
|
|
||||||
|
## Customizing component tags with TagFormatter
|
||||||
|
|
||||||
|
_New in version 0.89_
|
||||||
|
|
||||||
|
By default, components are rendered using the pair of `{% component %}` / `{% endcomponent %}` template tags:
|
||||||
|
|
||||||
|
```django
|
||||||
|
{% component "button" href="..." disabled %}
|
||||||
|
Click me!
|
||||||
|
{% endcomponent %}
|
||||||
|
|
||||||
|
{# or #}
|
||||||
|
|
||||||
|
{% component "button" href="..." disabled / %}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can change this behaviour in the settings under the [`COMPONENTS.tag_formatter`](#tag-formatter-setting).
|
||||||
|
|
||||||
|
For example, if you set the tag formatter to `django_components.shorthand_component_formatter`, the components will use their name as the template tags:
|
||||||
|
|
||||||
|
```django
|
||||||
|
{% button href="..." disabled %}
|
||||||
|
Click me!
|
||||||
|
{% endbutton %}
|
||||||
|
|
||||||
|
{# or #}
|
||||||
|
|
||||||
|
{% button href="..." disabled / %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available TagFormatters
|
||||||
|
|
||||||
|
django_components provides following predefined TagFormatters:
|
||||||
|
|
||||||
|
- **`ComponentFormatter` (`django_components.component_formatter`)**
|
||||||
|
|
||||||
|
Default
|
||||||
|
|
||||||
|
Uses the `component` and `endcomponent` tags, and the component name is gives as the first positional argument.
|
||||||
|
|
||||||
|
Example as block:
|
||||||
|
```django
|
||||||
|
{% component "button" href="..." %}
|
||||||
|
{% fill "content" %}
|
||||||
|
...
|
||||||
|
{% endfill %}
|
||||||
|
{% endcomponent %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example as inlined tag:
|
||||||
|
```django
|
||||||
|
{% component "button" href="..." / %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`ShorthandComponentFormatter` (`django_components.shorthand_component_formatter`)**
|
||||||
|
|
||||||
|
Uses the component name as start tag, and `end<component_name>`
|
||||||
|
as an end tag.
|
||||||
|
|
||||||
|
Example as block:
|
||||||
|
```django
|
||||||
|
{% button href="..." %}
|
||||||
|
Click me!
|
||||||
|
{% endbutton %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example as inlined tag:
|
||||||
|
```django
|
||||||
|
{% button href="..." / %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Writing your own TagFormatter
|
||||||
|
|
||||||
|
#### Background
|
||||||
|
|
||||||
|
First, let's discuss how TagFormatters work, and how components are rendered in django_components.
|
||||||
|
|
||||||
|
When you render a component with `{% component %}` (or your own tag), the following happens:
|
||||||
|
1. `component` must be registered as a Django's template tag
|
||||||
|
2. Django triggers django_components's tag handler for tag `component`.
|
||||||
|
3. The tag handler passes the tag contents for pre-processing to `TagFormatter.parse()`.
|
||||||
|
|
||||||
|
So if you render this:
|
||||||
|
```django
|
||||||
|
{% component "button" href="..." disabled %}
|
||||||
|
{% endcomponent %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `TagFormatter.parse()` will receive a following input:
|
||||||
|
```py
|
||||||
|
["component", '"button"', 'href="..."', 'disabled']
|
||||||
|
```
|
||||||
|
4. `TagFormatter` extracts the component name and the remaining input.
|
||||||
|
|
||||||
|
So, given the above, `TagFormatter.parse()` returns the following:
|
||||||
|
```py
|
||||||
|
TagResult(
|
||||||
|
component_name="button",
|
||||||
|
tokens=['href="..."', 'disabled']
|
||||||
|
)
|
||||||
|
```
|
||||||
|
5. The tag handler resumes, using the tokens returned from `TagFormatter`.
|
||||||
|
|
||||||
|
So, continuing the example, at this point the tag handler practically behaves as if you rendered:
|
||||||
|
```django
|
||||||
|
{% component href="..." disabled %}
|
||||||
|
```
|
||||||
|
6. Tag handler looks up the component `button`, and passes the args, kwargs, and slots to it.
|
||||||
|
|
||||||
|
#### TagFormatter
|
||||||
|
|
||||||
|
`TagFormatter` handles following parts of the process above:
|
||||||
|
- Generates start/end tags, given a component. This is what you then call from within your template as `{% component %}`.
|
||||||
|
|
||||||
|
- When you `{% component %}`, tag formatter pre-processes the tag contents, so it can link back the custom template tag to the right component.
|
||||||
|
|
||||||
|
To do so, subclass from `TagFormatterABC` and implement following method:
|
||||||
|
- `start_tag`
|
||||||
|
- `end_tag`
|
||||||
|
- `parse`
|
||||||
|
|
||||||
|
For example, this is the implementation of [`ShorthandComponentFormatter`](#available-tagformatters)
|
||||||
|
|
||||||
|
```py
|
||||||
|
class ShorthandComponentFormatter(TagFormatterABC):
|
||||||
|
# Given a component name, generate the start template tag
|
||||||
|
def start_tag(self, name: str) -> str:
|
||||||
|
return name # e.g. 'button'
|
||||||
|
|
||||||
|
# Given a component name, generate the start template tag
|
||||||
|
def end_tag(self, name: str) -> str:
|
||||||
|
return f"end{name}" # e.g. 'endbutton'
|
||||||
|
|
||||||
|
# Given a tag, e.g.
|
||||||
|
# `{% button href="..." disabled %}`
|
||||||
|
#
|
||||||
|
# The parser receives:
|
||||||
|
# `['button', 'href="..."', 'disabled']`
|
||||||
|
def parse(self, tokens: List[str]) -> TagResult:
|
||||||
|
tokens = [*tokens]
|
||||||
|
name = tokens.pop(0)
|
||||||
|
return TagResult(
|
||||||
|
name, # e.g. 'button'
|
||||||
|
tokens # e.g. ['href="..."', 'disabled']
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it! And once your `TagFormatter` is ready, don't forget to update the settings!
|
||||||
|
|
||||||
## Defining HTML/JS/CSS files
|
## Defining HTML/JS/CSS files
|
||||||
|
|
||||||
django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/).
|
django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/).
|
||||||
|
@ -2187,7 +2361,7 @@ COMPONENTS = {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Context behavior
|
### Context behavior setting
|
||||||
|
|
||||||
> NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70.
|
> NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70.
|
||||||
>
|
>
|
||||||
|
@ -2292,6 +2466,28 @@ But since `"cheese"` is not defined there, it's empty.
|
||||||
|
|
||||||
Notice that the variables defined with the `{% with %}` tag are ignored inside the `{% fill %}` tag with the `"isolated"` mode.
|
Notice that the variables defined with the `{% with %}` tag are ignored inside the `{% fill %}` tag with the `"isolated"` mode.
|
||||||
|
|
||||||
|
### Tag formatter setting
|
||||||
|
|
||||||
|
Set the [`TagFormatter`](#available-tagformatters) instance.
|
||||||
|
|
||||||
|
Can be set either as direct reference, or as an import string;
|
||||||
|
|
||||||
|
```py
|
||||||
|
COMPONENTS = {
|
||||||
|
"tag_formatter": "django_components.component_formatter"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or
|
||||||
|
|
||||||
|
```py
|
||||||
|
from django_components import component_formatter
|
||||||
|
|
||||||
|
COMPONENTS = {
|
||||||
|
"tag_formatter": component_formatter
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Logging and debugging
|
## Logging and debugging
|
||||||
|
|
||||||
Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). This can help with troubleshooting.
|
Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). This can help with troubleshooting.
|
||||||
|
|
|
@ -3,14 +3,27 @@ import django
|
||||||
|
|
||||||
# Public API
|
# Public API
|
||||||
# isort: off
|
# isort: off
|
||||||
from django_components.autodiscover import autodiscover as autodiscover
|
from django_components.autodiscover import (
|
||||||
from django_components.autodiscover import import_libraries as import_libraries
|
autodiscover as autodiscover,
|
||||||
|
import_libraries as import_libraries,
|
||||||
|
)
|
||||||
from django_components.component import Component as Component
|
from django_components.component import Component as Component
|
||||||
from django_components.component_registry import AlreadyRegistered as AlreadyRegistered
|
from django_components.component_registry import (
|
||||||
from django_components.component_registry import ComponentRegistry as ComponentRegistry
|
AlreadyRegistered as AlreadyRegistered,
|
||||||
from django_components.component_registry import NotRegistered as NotRegistered
|
ComponentRegistry as ComponentRegistry,
|
||||||
from django_components.component_registry import register as register
|
NotRegistered as NotRegistered,
|
||||||
from django_components.component_registry import registry as registry
|
register as register,
|
||||||
|
registry as registry,
|
||||||
|
)
|
||||||
|
from django_components.library import TagProtectedError as TagProtectedError
|
||||||
|
from django_components.tag_formatter import (
|
||||||
|
ComponentFormatter as ComponentFormatter,
|
||||||
|
ShorthandComponentFormatter as ShorthandComponentFormatter,
|
||||||
|
TagFormatterABC as TagFormatterABC,
|
||||||
|
TagResult as TagResult,
|
||||||
|
component_formatter as component_formatter,
|
||||||
|
component_shorthand_formatter as component_shorthand_formatter,
|
||||||
|
)
|
||||||
import django_components.types as types
|
import django_components.types as types
|
||||||
|
|
||||||
# isort: on
|
# isort: on
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, List
|
from typing import TYPE_CHECKING, Dict, List, Union
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django_components.tag_formatter import TagFormatterABC
|
||||||
|
|
||||||
|
|
||||||
class ContextBehavior(str, Enum):
|
class ContextBehavior(str, Enum):
|
||||||
DJANGO = "django"
|
DJANGO = "django"
|
||||||
|
@ -115,5 +118,9 @@ class AppSettings:
|
||||||
valid_values = [behavior.value for behavior in ContextBehavior]
|
valid_values = [behavior.value for behavior in ContextBehavior]
|
||||||
raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}")
|
raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def TAG_FORMATTER(self) -> Union["TagFormatterABC", str]:
|
||||||
|
return self.settings.get("tag_formatter", "django_components.component_formatter")
|
||||||
|
|
||||||
|
|
||||||
app_settings = AppSettings()
|
app_settings = AppSettings()
|
||||||
|
|
|
@ -5,69 +5,42 @@
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||||
|
|
||||||
from django.template import Context, Node
|
from django.template import Context, Node
|
||||||
from django.template.base import FilterExpression
|
|
||||||
from django.utils.html import conditional_escape, format_html
|
from django.utils.html import conditional_escape, format_html
|
||||||
from django.utils.safestring import SafeString, mark_safe
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
|
|
||||||
from django_components.template_parser import process_aggregate_kwargs
|
from django_components.expression import Expression, safe_resolve
|
||||||
|
|
||||||
HTML_ATTRS_DEFAULTS_KEY = "defaults"
|
HTML_ATTRS_DEFAULTS_KEY = "defaults"
|
||||||
HTML_ATTRS_ATTRS_KEY = "attrs"
|
HTML_ATTRS_ATTRS_KEY = "attrs"
|
||||||
|
|
||||||
|
|
||||||
def _default(val: Any, default_val: Any) -> Any:
|
|
||||||
return val if val is not None else default_val
|
|
||||||
|
|
||||||
|
|
||||||
class HtmlAttrsNode(Node):
|
class HtmlAttrsNode(Node):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
attributes: Optional[FilterExpression],
|
attributes: Optional[Expression],
|
||||||
default_attrs: Optional[FilterExpression],
|
defaults: Optional[Expression],
|
||||||
kwargs: List[Tuple[str, FilterExpression]],
|
kwargs: List[Tuple[str, Expression]],
|
||||||
):
|
):
|
||||||
self.attributes = attributes
|
self.attributes = attributes
|
||||||
self.default_attrs = default_attrs
|
self.defaults = defaults
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
|
|
||||||
def render(self, context: Context) -> str:
|
def render(self, context: Context) -> str:
|
||||||
append_attrs: List[Tuple[str, Any]] = []
|
append_attrs: List[Tuple[str, Any]] = []
|
||||||
attrs_and_defaults_from_kwargs = {}
|
|
||||||
|
|
||||||
# Resolve kwargs, while also extracting attrs and defaults keys
|
# Resolve all data
|
||||||
for key, value in self.kwargs:
|
for key, value in self.kwargs:
|
||||||
resolved_value = value.resolve(context)
|
resolved_value = safe_resolve(value, context)
|
||||||
if key.startswith(f"{HTML_ATTRS_ATTRS_KEY}:") or key.startswith(f"{HTML_ATTRS_DEFAULTS_KEY}:"):
|
|
||||||
attrs_and_defaults_from_kwargs[key] = resolved_value
|
|
||||||
continue
|
|
||||||
# NOTE: These were already extracted into separate variables, so
|
|
||||||
# ignore them here.
|
|
||||||
elif key == HTML_ATTRS_ATTRS_KEY or key == HTML_ATTRS_DEFAULTS_KEY:
|
|
||||||
continue
|
|
||||||
|
|
||||||
append_attrs.append((key, resolved_value))
|
append_attrs.append((key, resolved_value))
|
||||||
|
|
||||||
# NOTE: Here we delegate validation to `process_aggregate_kwargs`, which should
|
defaults = safe_resolve(self.defaults, context) if self.defaults else {}
|
||||||
# raise error if the dict includes both `attrs` and `attrs:` keys.
|
attrs = safe_resolve(self.attributes, context) if self.attributes else {}
|
||||||
#
|
|
||||||
# So by assigning the `attrs` and `defaults` keys, users are forced to use only
|
|
||||||
# one approach or the other, but not both simultaneously.
|
|
||||||
if self.attributes:
|
|
||||||
attrs_and_defaults_from_kwargs[HTML_ATTRS_ATTRS_KEY] = self.attributes.resolve(context)
|
|
||||||
if self.default_attrs:
|
|
||||||
attrs_and_defaults_from_kwargs[HTML_ATTRS_DEFAULTS_KEY] = self.default_attrs.resolve(context)
|
|
||||||
|
|
||||||
# Turn `{"attrs:blabla": 1}` into `{"attrs": {"blabla": 1}}`
|
# Merge it
|
||||||
attrs_and_defaults_from_kwargs = process_aggregate_kwargs(attrs_and_defaults_from_kwargs)
|
final_attrs = {**defaults, **attrs}
|
||||||
|
|
||||||
# NOTE: We want to allow to use `html_attrs` even without `attrs` or `defaults` params
|
|
||||||
# Or when they are None
|
|
||||||
attrs = _default(attrs_and_defaults_from_kwargs.get(HTML_ATTRS_ATTRS_KEY, None), {})
|
|
||||||
default_attrs = _default(attrs_and_defaults_from_kwargs.get(HTML_ATTRS_DEFAULTS_KEY, None), {})
|
|
||||||
|
|
||||||
final_attrs = {**default_attrs, **attrs}
|
|
||||||
final_attrs = append_attributes(*final_attrs.items(), *append_attrs)
|
final_attrs = append_attributes(*final_attrs.items(), *append_attrs)
|
||||||
|
|
||||||
|
# Render to HTML attributes
|
||||||
return attributes_to_string(final_attrs)
|
return attributes_to_string(final_attrs)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -477,7 +477,7 @@ class ComponentNode(Node):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name_fexp: FilterExpression,
|
name: str,
|
||||||
context_args: List[FilterExpression],
|
context_args: List[FilterExpression],
|
||||||
context_kwargs: Mapping[str, FilterExpression],
|
context_kwargs: Mapping[str, FilterExpression],
|
||||||
isolated_context: bool = False,
|
isolated_context: bool = False,
|
||||||
|
@ -485,7 +485,7 @@ class ComponentNode(Node):
|
||||||
component_id: Optional[str] = None,
|
component_id: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.component_id = component_id or gen_id()
|
self.component_id = component_id or gen_id()
|
||||||
self.name_fexp = name_fexp
|
self.name = name
|
||||||
self.context_args = context_args or []
|
self.context_args = context_args or []
|
||||||
self.context_kwargs = context_kwargs or {}
|
self.context_kwargs = context_kwargs or {}
|
||||||
self.isolated_context = isolated_context
|
self.isolated_context = isolated_context
|
||||||
|
@ -494,15 +494,14 @@ class ComponentNode(Node):
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return "<ComponentNode: {}. Contents: {!r}>".format(
|
return "<ComponentNode: {}. Contents: {!r}>".format(
|
||||||
self.name_fexp,
|
self.name,
|
||||||
getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later.
|
getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later.
|
||||||
)
|
)
|
||||||
|
|
||||||
def render(self, context: Context) -> str:
|
def render(self, context: Context) -> str:
|
||||||
trace_msg("RENDR", "COMP", self.name_fexp, self.component_id)
|
trace_msg("RENDR", "COMP", self.name, self.component_id)
|
||||||
|
|
||||||
resolved_component_name = self.name_fexp.resolve(context)
|
component_cls: Type[Component] = registry.get(self.name)
|
||||||
component_cls: Type[Component] = registry.get(resolved_component_name)
|
|
||||||
|
|
||||||
# Resolve FilterExpressions and Variables that were passed as args to the
|
# Resolve FilterExpressions and Variables that were passed as args to the
|
||||||
# component, then call component's context method
|
# component, then call component's context method
|
||||||
|
@ -532,8 +531,8 @@ class ComponentNode(Node):
|
||||||
f"Detected duplicate fill tag name '{resolved_name}'."
|
f"Detected duplicate fill tag name '{resolved_name}'."
|
||||||
)
|
)
|
||||||
|
|
||||||
resolved_slot_default_var = fill_node.resolve_slot_default(context, resolved_component_name)
|
resolved_slot_default_var = fill_node.resolve_slot_default(context, self.name)
|
||||||
resolved_slot_data_var = fill_node.resolve_slot_data(context, resolved_component_name)
|
resolved_slot_data_var = fill_node.resolve_slot_data(context, self.name)
|
||||||
fill_content[resolved_name] = FillContent(
|
fill_content[resolved_name] = FillContent(
|
||||||
content_func=_nodelist_to_slot_render_func(fill_node.nodelist),
|
content_func=_nodelist_to_slot_render_func(fill_node.nodelist),
|
||||||
slot_default_var=resolved_slot_default_var,
|
slot_default_var=resolved_slot_default_var,
|
||||||
|
@ -541,7 +540,7 @@ class ComponentNode(Node):
|
||||||
)
|
)
|
||||||
|
|
||||||
component: Component = component_cls(
|
component: Component = component_cls(
|
||||||
registered_name=resolved_component_name,
|
registered_name=self.name,
|
||||||
outer_context=context,
|
outer_context=context,
|
||||||
fill_content=fill_content,
|
fill_content=fill_content,
|
||||||
component_id=self.component_id,
|
component_id=self.component_id,
|
||||||
|
@ -557,7 +556,7 @@ class ComponentNode(Node):
|
||||||
kwargs=resolved_context_kwargs,
|
kwargs=resolved_context_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
trace_msg("RENDR", "COMP", self.name_fexp, self.component_id, "...Done!")
|
trace_msg("RENDR", "COMP", self.name, self.component_id, "...Done!")
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,25 +1,16 @@
|
||||||
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, TypeVar
|
from typing import TYPE_CHECKING, Callable, Dict, NamedTuple, Optional, Set, Type, TypeVar
|
||||||
|
|
||||||
from django.template import Library
|
from django.template import Library
|
||||||
|
|
||||||
|
from django_components.library import is_tag_protected, mark_protected_tags, register_tag_from_formatter
|
||||||
|
from django_components.tag_formatter import get_tag_formatter
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django_components.component import Component
|
from django_components.component import Component
|
||||||
|
|
||||||
_TComp = TypeVar("_TComp", bound=Type["Component"])
|
_TComp = TypeVar("_TComp", bound=Type["Component"])
|
||||||
|
|
||||||
|
|
||||||
PROTECTED_TAGS = [
|
|
||||||
"component",
|
|
||||||
"component_dependencies",
|
|
||||||
"component_css_dependencies",
|
|
||||||
"component_js_dependencies",
|
|
||||||
"fill",
|
|
||||||
"html_attrs",
|
|
||||||
"provide",
|
|
||||||
"slot",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class AlreadyRegistered(Exception):
|
class AlreadyRegistered(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -28,20 +19,10 @@ class NotRegistered(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Why do we store the tags with the component?
|
# Why do we store the tags with the components?
|
||||||
#
|
#
|
||||||
# Each component may be associated with two template tags - One for "block"
|
# With the addition of TagFormatter, each component class may have a unique
|
||||||
# and one for "inline" usage. E.g. in the following snippets, the template
|
# set of template tags.
|
||||||
# tags are `component` and `#component`:
|
|
||||||
#
|
|
||||||
# `{% component "abc" %}{% endcomponent %}`
|
|
||||||
# `{% #component "abc" %}`
|
|
||||||
#
|
|
||||||
# (NOTE: While `endcomponent` also looks like a template tag, we don't have to register
|
|
||||||
# it, because it simply marks the end of body.)
|
|
||||||
#
|
|
||||||
# With the component tag formatter (configurable tags per component class),
|
|
||||||
# each component may have a unique set of template tags.
|
|
||||||
#
|
#
|
||||||
# For user's convenience, we automatically add/remove the tags from Django's tag Library,
|
# For user's convenience, we automatically add/remove the tags from Django's tag Library,
|
||||||
# when a component is (un)registered.
|
# when a component is (un)registered.
|
||||||
|
@ -49,12 +30,7 @@ class NotRegistered(Exception):
|
||||||
# Thus we need to remember which component used which template tags.
|
# Thus we need to remember which component used which template tags.
|
||||||
class ComponentRegistryEntry(NamedTuple):
|
class ComponentRegistryEntry(NamedTuple):
|
||||||
cls: Type["Component"]
|
cls: Type["Component"]
|
||||||
block_tag: str
|
tag: str
|
||||||
inline_tag: str
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tags(self) -> List[str]:
|
|
||||||
return [self.block_tag, self.inline_tag]
|
|
||||||
|
|
||||||
|
|
||||||
class ComponentRegistry:
|
class ComponentRegistry:
|
||||||
|
@ -111,7 +87,7 @@ class ComponentRegistry:
|
||||||
# On the other hand, if user provided their own Library instance,
|
# On the other hand, if user provided their own Library instance,
|
||||||
# it is up to the user to use `mark_protected_tags` if they want
|
# it is up to the user to use `mark_protected_tags` if they want
|
||||||
# to protect any tags.
|
# to protect any tags.
|
||||||
mark_protected_tags(tag_library, PROTECTED_TAGS)
|
mark_protected_tags(tag_library)
|
||||||
lib = self._library = tag_library
|
lib = self._library = tag_library
|
||||||
return lib
|
return lib
|
||||||
|
|
||||||
|
@ -137,18 +113,11 @@ class ComponentRegistry:
|
||||||
if existing_component and existing_component.cls._class_hash != component._class_hash:
|
if existing_component and existing_component.cls._class_hash != component._class_hash:
|
||||||
raise AlreadyRegistered('The component "%s" has already been registered' % name)
|
raise AlreadyRegistered('The component "%s" has already been registered' % name)
|
||||||
|
|
||||||
block_tag = "component"
|
entry = self._register_to_library(name, component)
|
||||||
inline_tag = "#component"
|
|
||||||
|
|
||||||
entry = ComponentRegistryEntry(
|
|
||||||
cls=component,
|
|
||||||
block_tag=block_tag,
|
|
||||||
inline_tag=inline_tag,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Keep track of which components use which tags, because multiple components may
|
# Keep track of which components use which tags, because multiple components may
|
||||||
# use the same tag.
|
# use the same tag.
|
||||||
for tag in entry.tags:
|
tag = entry.tag
|
||||||
if tag not in self._tags:
|
if tag not in self._tags:
|
||||||
self._tags[tag] = set()
|
self._tags[tag] = set()
|
||||||
self._tags[tag].add(name)
|
self._tags[tag].add(name)
|
||||||
|
@ -180,9 +149,9 @@ class ComponentRegistry:
|
||||||
self.get(name)
|
self.get(name)
|
||||||
|
|
||||||
entry = self._registry[name]
|
entry = self._registry[name]
|
||||||
|
tag = entry.tag
|
||||||
|
|
||||||
# Unregister the tag from library if this was the last component using this tag
|
# Unregister the tag from library if this was the last component using this tag
|
||||||
for tag in entry.tags:
|
|
||||||
# Unlink component from tag
|
# Unlink component from tag
|
||||||
self._tags[tag].remove(name)
|
self._tags[tag].remove(name)
|
||||||
|
|
||||||
|
@ -191,11 +160,9 @@ class ComponentRegistry:
|
||||||
if is_tag_empty:
|
if is_tag_empty:
|
||||||
del self._tags[tag]
|
del self._tags[tag]
|
||||||
|
|
||||||
# Do NOT unregister tag if it's protected
|
# Only unregister a tag if it's NOT protected
|
||||||
is_protected = is_tag_protected(self.library, tag)
|
is_protected = is_tag_protected(self.library, tag)
|
||||||
if is_protected:
|
if not is_protected:
|
||||||
continue
|
|
||||||
|
|
||||||
# Unregister the tag from library if this was the last component using this tag
|
# Unregister the tag from library if this was the last component using this tag
|
||||||
if is_tag_empty and tag in self.library.tags:
|
if is_tag_empty and tag in self.library.tags:
|
||||||
del self.library.tags[tag]
|
del self.library.tags[tag]
|
||||||
|
@ -268,6 +235,19 @@ class ComponentRegistry:
|
||||||
self._registry = {}
|
self._registry = {}
|
||||||
self._tags = {}
|
self._tags = {}
|
||||||
|
|
||||||
|
def _register_to_library(
|
||||||
|
self,
|
||||||
|
comp_name: str,
|
||||||
|
component: Type["Component"],
|
||||||
|
) -> ComponentRegistryEntry:
|
||||||
|
# Lazily import to avoid circular dependencies
|
||||||
|
from django_components.templatetags.component_tags import component as do_component
|
||||||
|
|
||||||
|
formatter = get_tag_formatter()
|
||||||
|
tag = register_tag_from_formatter(self.library, do_component, formatter, comp_name)
|
||||||
|
|
||||||
|
return ComponentRegistryEntry(cls=component, tag=tag)
|
||||||
|
|
||||||
|
|
||||||
# This variable represents the global component registry
|
# This variable represents the global component registry
|
||||||
registry: ComponentRegistry = ComponentRegistry()
|
registry: ComponentRegistry = ComponentRegistry()
|
||||||
|
@ -326,13 +306,3 @@ def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callabl
|
||||||
return component
|
return component
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def mark_protected_tags(lib: Library, tags: List[str]) -> None:
|
|
||||||
# By marking the library as default,
|
|
||||||
lib._protected_tags = [*tags]
|
|
||||||
|
|
||||||
|
|
||||||
def is_tag_protected(lib: Library, tag: str) -> bool:
|
|
||||||
protected_tags = getattr(lib, "_protected_tags", [])
|
|
||||||
return tag in protected_tags
|
|
||||||
|
|
|
@ -4,6 +4,14 @@ from django.template import Context
|
||||||
from django.template.base import FilterExpression, Parser
|
from django.template.base import FilterExpression, Parser
|
||||||
|
|
||||||
|
|
||||||
|
class AggregateFilterExpression:
|
||||||
|
def __init__(self, dict: Dict[str, FilterExpression]) -> None:
|
||||||
|
self.dict = dict
|
||||||
|
|
||||||
|
|
||||||
|
Expression = Union[FilterExpression, AggregateFilterExpression]
|
||||||
|
|
||||||
|
|
||||||
def resolve_expression_as_identifier(
|
def resolve_expression_as_identifier(
|
||||||
context: Context,
|
context: Context,
|
||||||
fexp: FilterExpression,
|
fexp: FilterExpression,
|
||||||
|
@ -20,19 +28,22 @@ def resolve_expression_as_identifier(
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
def safe_resolve_list(args: List[FilterExpression], context: Context) -> List:
|
def safe_resolve_list(args: List[Expression], context: Context) -> List:
|
||||||
return [safe_resolve(arg, context) for arg in args]
|
return [safe_resolve(arg, context) for arg in args]
|
||||||
|
|
||||||
|
|
||||||
def safe_resolve_dict(
|
def safe_resolve_dict(
|
||||||
kwargs: Union[Mapping[str, FilterExpression], Dict[str, FilterExpression]],
|
kwargs: Union[Mapping[str, Expression], Dict[str, Expression]],
|
||||||
context: Context,
|
context: Context,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
return {key: safe_resolve(kwarg, context) for key, kwarg in kwargs.items()}
|
return {key: safe_resolve(kwarg, context) for key, kwarg in kwargs.items()}
|
||||||
|
|
||||||
|
|
||||||
def safe_resolve(context_item: FilterExpression, context: Context) -> Any:
|
def safe_resolve(context_item: Expression, context: Context) -> Any:
|
||||||
"""Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
|
"""Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
|
||||||
|
if isinstance(context_item, AggregateFilterExpression):
|
||||||
|
return safe_resolve_dict(context_item.dict, context)
|
||||||
|
|
||||||
return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item
|
return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item
|
||||||
|
|
||||||
|
|
||||||
|
@ -42,5 +53,11 @@ def resolve_string(
|
||||||
context: Optional[Mapping[str, Any]] = None,
|
context: Optional[Mapping[str, Any]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
parser = parser or Parser([])
|
parser = parser or Parser([])
|
||||||
context = context or {}
|
context = Context(context or {})
|
||||||
return parser.compile_filter(s).resolve(context)
|
return parser.compile_filter(s).resolve(context)
|
||||||
|
|
||||||
|
|
||||||
|
def is_aggregate_key(key: str) -> bool:
|
||||||
|
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
|
||||||
|
# This syntax is used by Vue and AlpineJS.
|
||||||
|
return ":" in key and not key.startswith(":")
|
||||||
|
|
60
src/django_components/library.py
Normal file
60
src/django_components/library.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
"""Module for interfacing with Django's Library (`django.template.library`)"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class TagProtectedError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
PROTECTED_TAGS = [
|
||||||
|
"component_dependencies",
|
||||||
|
"component_css_dependencies",
|
||||||
|
"component_js_dependencies",
|
||||||
|
"fill",
|
||||||
|
"html_attrs",
|
||||||
|
"provide",
|
||||||
|
"slot",
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
These are the names that users cannot choose for their components,
|
||||||
|
as they would conflict with other tags in the Library.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def register_tag(
|
||||||
|
library: Library,
|
||||||
|
tag: str,
|
||||||
|
tag_fn: Callable[[Parser, Token, str], Node],
|
||||||
|
) -> None:
|
||||||
|
# Register inline tag
|
||||||
|
if is_tag_protected(library, tag):
|
||||||
|
raise TagProtectedError('Cannot register tag "%s", this tag name is protected' % tag)
|
||||||
|
else:
|
||||||
|
library.tag(tag, lambda parser, token: tag_fn(parser, token, tag))
|
||||||
|
|
||||||
|
|
||||||
|
def register_tag_from_formatter(
|
||||||
|
library: Library,
|
||||||
|
tag_fn: Callable[[Parser, Token, str], Node],
|
||||||
|
formatter: InternalTagFormatter,
|
||||||
|
component_name: str,
|
||||||
|
) -> str:
|
||||||
|
tag = formatter.start_tag(component_name)
|
||||||
|
register_tag(library, tag, tag_fn)
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
def mark_protected_tags(lib: Library, tags: Optional[List[str]] = None) -> None:
|
||||||
|
protected_tags = tags if tags is not None else PROTECTED_TAGS
|
||||||
|
lib._protected_tags = [*protected_tags]
|
||||||
|
|
||||||
|
|
||||||
|
def is_tag_protected(lib: Library, tag: str) -> bool:
|
||||||
|
protected_tags = getattr(lib, "_protected_tags", [])
|
||||||
|
return tag in protected_tags
|
|
@ -16,10 +16,9 @@ from django_components.context import (
|
||||||
_INJECT_CONTEXT_KEY_PREFIX,
|
_INJECT_CONTEXT_KEY_PREFIX,
|
||||||
_ROOT_CTX_CONTEXT_KEY,
|
_ROOT_CTX_CONTEXT_KEY,
|
||||||
)
|
)
|
||||||
from django_components.expression import resolve_expression_as_identifier, safe_resolve_dict
|
from django_components.expression import Expression, resolve_expression_as_identifier, safe_resolve_dict
|
||||||
from django_components.logger import trace_msg
|
from django_components.logger import trace_msg
|
||||||
from django_components.node import NodeTraverse, nodelist_has_content, walk_nodelist
|
from django_components.node import NodeTraverse, nodelist_has_content, walk_nodelist
|
||||||
from django_components.template_parser import process_aggregate_kwargs
|
|
||||||
from django_components.utils import gen_id
|
from django_components.utils import gen_id
|
||||||
|
|
||||||
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
|
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
|
||||||
|
@ -119,7 +118,7 @@ class SlotNode(Node):
|
||||||
is_required: bool = False,
|
is_required: bool = False,
|
||||||
is_default: bool = False,
|
is_default: bool = False,
|
||||||
node_id: Optional[str] = None,
|
node_id: Optional[str] = None,
|
||||||
slot_kwargs: Optional[Dict[str, FilterExpression]] = None,
|
slot_kwargs: Optional[Dict[str, Expression]] = None,
|
||||||
):
|
):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.nodelist = nodelist
|
self.nodelist = nodelist
|
||||||
|
@ -173,7 +172,6 @@ class SlotNode(Node):
|
||||||
# are made available through a variable name that was set on the `{% fill %}`
|
# are made available through a variable name that was set on the `{% fill %}`
|
||||||
# tag.
|
# tag.
|
||||||
slot_kwargs = safe_resolve_dict(self.slot_kwargs, context)
|
slot_kwargs = safe_resolve_dict(self.slot_kwargs, context)
|
||||||
slot_kwargs = process_aggregate_kwargs(slot_kwargs)
|
|
||||||
data_var = slot_fill.slot_data_var
|
data_var = slot_fill.slot_data_var
|
||||||
if data_var:
|
if data_var:
|
||||||
if not data_var.isidentifier():
|
if not data_var.isidentifier():
|
||||||
|
|
219
src/django_components/tag_formatter.py
Normal file
219
src/django_components/tag_formatter.py
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
import abc
|
||||||
|
import re
|
||||||
|
from typing import List, NamedTuple
|
||||||
|
|
||||||
|
from django.template import TemplateSyntaxError
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
from django_components.app_settings import app_settings
|
||||||
|
from django_components.expression import resolve_string
|
||||||
|
from django_components.template_parser import VAR_CHARS
|
||||||
|
from django_components.utils import is_str_wrapped_in_quotes
|
||||||
|
|
||||||
|
TAG_RE = re.compile(r"^[{chars}]+$".format(chars=VAR_CHARS))
|
||||||
|
|
||||||
|
|
||||||
|
class TagResult(NamedTuple):
|
||||||
|
"""The return value from `TagFormatter.parse()`"""
|
||||||
|
|
||||||
|
component_name: str
|
||||||
|
"""Component name extracted from the template tag"""
|
||||||
|
tokens: List[str]
|
||||||
|
"""Remaining tokens (words) that were passed to the tag, with component name removed"""
|
||||||
|
|
||||||
|
|
||||||
|
class TagFormatterABC(abc.ABC):
|
||||||
|
@abc.abstractmethod
|
||||||
|
def start_tag(self, name: str) -> str:
|
||||||
|
"""Formats the start tag of a component."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def end_tag(self, name: str) -> str:
|
||||||
|
"""Formats the end tag of a block component."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def parse(self, tokens: List[str]) -> TagResult:
|
||||||
|
"""
|
||||||
|
Given the tokens (words) of a component start tag, this function extracts
|
||||||
|
the component name from the tokens list, and returns `TagResult`, which
|
||||||
|
is a tuple of `(component_name, remaining_tokens)`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
Given a component declarations:
|
||||||
|
|
||||||
|
`{% component "my_comp" key=val key2=val2 %}`
|
||||||
|
|
||||||
|
This function receives a list of tokens
|
||||||
|
|
||||||
|
`['component', '"my_comp"', 'key=val', 'key2=val2']`
|
||||||
|
|
||||||
|
`component` is the tag name, which we drop. `"my_comp"` is the component name,
|
||||||
|
but we must remove the extra quotes. And we pass remaining tokens unmodified,
|
||||||
|
as that's the input to the component.
|
||||||
|
|
||||||
|
So in the end, we return a tuple:
|
||||||
|
|
||||||
|
`('my_comp', ['key=val', 'key2=val2'])`
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class InternalTagFormatter:
|
||||||
|
"""
|
||||||
|
Internal wrapper around user-provided TagFormatters, so that we validate the outputs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, tag_formatter: TagFormatterABC):
|
||||||
|
self.tag_formatter = tag_formatter
|
||||||
|
|
||||||
|
def start_tag(self, name: str) -> str:
|
||||||
|
tag = self.tag_formatter.start_tag(name)
|
||||||
|
self._validate_tag(tag, "start_tag")
|
||||||
|
return tag
|
||||||
|
|
||||||
|
def end_tag(self, name: str) -> str:
|
||||||
|
tag = self.tag_formatter.end_tag(name)
|
||||||
|
self._validate_tag(tag, "end_tag")
|
||||||
|
return tag
|
||||||
|
|
||||||
|
def parse(self, tokens: List[str]) -> TagResult:
|
||||||
|
return self.tag_formatter.parse(tokens)
|
||||||
|
|
||||||
|
# NOTE: We validate the generated tags, so they contain only valid characters (\w - : . @ #)
|
||||||
|
# and NO SPACE. Otherwise we wouldn't be able to distinguish a "multi-word" tag from several
|
||||||
|
# single-word tags.
|
||||||
|
def _validate_tag(self, tag: str, tag_type: str) -> None:
|
||||||
|
if not tag:
|
||||||
|
raise ValueError(
|
||||||
|
f"{self.tag_formatter.__class__.__name__} returned an invalid tag for {tag_type}: '{tag}'."
|
||||||
|
f" Tag cannot be empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not TAG_RE.match(tag):
|
||||||
|
raise ValueError(
|
||||||
|
f"{self.tag_formatter.__class__.__name__} returned an invalid tag for {tag_type}: '{tag}'."
|
||||||
|
f" Tag must contain only following chars: {VAR_CHARS}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentFormatter(TagFormatterABC):
|
||||||
|
"""
|
||||||
|
The original django_component's component tag formatter, it uses the `component`
|
||||||
|
and `endcomponent` tags, and the component name is gives as the first positional arg.
|
||||||
|
|
||||||
|
Example as block:
|
||||||
|
```django
|
||||||
|
{% component "mycomp" abc=123 %}
|
||||||
|
{% fill "myfill" %}
|
||||||
|
...
|
||||||
|
{% endfill %}
|
||||||
|
{% endcomponent %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example as inlined tag:
|
||||||
|
```django
|
||||||
|
{% component "mycomp" abc=123 / %}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, tag: str):
|
||||||
|
self.tag = tag
|
||||||
|
|
||||||
|
def start_tag(self, name: str) -> str:
|
||||||
|
return self.tag
|
||||||
|
|
||||||
|
def end_tag(self, name: str) -> str:
|
||||||
|
return f"end{self.tag}"
|
||||||
|
|
||||||
|
def parse(self, tokens: List[str]) -> TagResult:
|
||||||
|
tag, *args = tokens
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
raise TemplateSyntaxError(f"{self.__class__.__name__}: Component tag did not receive tag name")
|
||||||
|
|
||||||
|
# If the first arg is a kwarg, not a positional arg, then look for the "name" kwarg
|
||||||
|
# for component name.
|
||||||
|
if "=" in args[0]:
|
||||||
|
comp_name = None
|
||||||
|
final_args = []
|
||||||
|
for kwarg in args:
|
||||||
|
if not kwarg.startswith("name="):
|
||||||
|
final_args.append(kwarg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if comp_name:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
f"ComponentFormatter: 'name' kwarg for component '{comp_name}'" " was defined more than once."
|
||||||
|
)
|
||||||
|
|
||||||
|
# NOTE: We intentionally do NOT add to `final_args` here
|
||||||
|
# because we want to remove the the `name=` kwarg from args list
|
||||||
|
comp_name = kwarg[5:]
|
||||||
|
else:
|
||||||
|
comp_name = args.pop(0)
|
||||||
|
final_args = args
|
||||||
|
|
||||||
|
if not comp_name:
|
||||||
|
raise TemplateSyntaxError("Component name must be a non-empty quoted string, e.g. 'my_comp'")
|
||||||
|
|
||||||
|
if not is_str_wrapped_in_quotes(comp_name):
|
||||||
|
raise TemplateSyntaxError(f"Component name must be a string 'literal', got: {comp_name}")
|
||||||
|
|
||||||
|
# Remove the quotes
|
||||||
|
comp_name = resolve_string(comp_name)
|
||||||
|
|
||||||
|
return TagResult(comp_name, final_args)
|
||||||
|
|
||||||
|
|
||||||
|
class ShorthandComponentFormatter(TagFormatterABC):
|
||||||
|
"""
|
||||||
|
The component tag formatter that uses `<name>` / `end<name>` tags.
|
||||||
|
|
||||||
|
This is similar to django-web-components and django-slippers syntax.
|
||||||
|
|
||||||
|
Example as block:
|
||||||
|
```django
|
||||||
|
{% mycomp abc=123 %}
|
||||||
|
{% fill "myfill" %}
|
||||||
|
...
|
||||||
|
{% endfill %}
|
||||||
|
{% endmycomp %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example as inlined tag:
|
||||||
|
```django
|
||||||
|
{% mycomp abc=123 / %}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
def start_tag(self, name: str) -> str:
|
||||||
|
return name
|
||||||
|
|
||||||
|
def end_tag(self, name: str) -> str:
|
||||||
|
return f"end{name}"
|
||||||
|
|
||||||
|
def parse(self, tokens: List[str]) -> TagResult:
|
||||||
|
tokens = [*tokens]
|
||||||
|
name = tokens.pop(0)
|
||||||
|
return TagResult(name, tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tag_formatter() -> InternalTagFormatter:
|
||||||
|
"""Returns an instance of the currently configured component tag formatter."""
|
||||||
|
# Allow users to configure the component TagFormatter
|
||||||
|
formatter_cls_or_str = app_settings.TAG_FORMATTER
|
||||||
|
|
||||||
|
if isinstance(formatter_cls_or_str, str):
|
||||||
|
tag_formatter: TagFormatterABC = import_string(formatter_cls_or_str)
|
||||||
|
else:
|
||||||
|
tag_formatter = formatter_cls_or_str
|
||||||
|
|
||||||
|
return InternalTagFormatter(tag_formatter)
|
||||||
|
|
||||||
|
|
||||||
|
# Default formatters
|
||||||
|
component_formatter = ComponentFormatter("component")
|
||||||
|
component_shorthand_formatter = ShorthandComponentFormatter()
|
|
@ -25,6 +25,9 @@ from django.utils.regex_helper import _lazy_re_compile
|
||||||
# This is a copy of the original FilterExpression. The only difference is to allow variable names to have extra special
|
# This is a copy of the original FilterExpression. The only difference is to allow variable names to have extra special
|
||||||
# characters: - : . @ #
|
# characters: - : . @ #
|
||||||
######################################################################################################################
|
######################################################################################################################
|
||||||
|
|
||||||
|
VAR_CHARS = r"\w\-\:\@\.\#"
|
||||||
|
|
||||||
filter_raw_string = r"""
|
filter_raw_string = r"""
|
||||||
^(?P<constant>{constant})|
|
^(?P<constant>{constant})|
|
||||||
^(?P<var>[{var_chars}]+|{num})|
|
^(?P<var>[{var_chars}]+|{num})|
|
||||||
|
@ -41,7 +44,7 @@ filter_raw_string = r"""
|
||||||
num=r"[-+\.]?\d[\d\.e]*",
|
num=r"[-+\.]?\d[\d\.e]*",
|
||||||
# The following is the only difference from the original FilterExpression. We allow variable names to have extra
|
# The following is the only difference from the original FilterExpression. We allow variable names to have extra
|
||||||
# special characters: - : . @ #
|
# special characters: - : . @ #
|
||||||
var_chars=r"\w\-\:\@\.\#",
|
var_chars=VAR_CHARS,
|
||||||
filter_sep=re.escape(FILTER_SEPARATOR),
|
filter_sep=re.escape(FILTER_SEPARATOR),
|
||||||
arg_sep=re.escape(FILTER_ARGUMENT_SEPARATOR),
|
arg_sep=re.escape(FILTER_ARGUMENT_SEPARATOR),
|
||||||
)
|
)
|
||||||
|
@ -102,7 +105,7 @@ class ComponentsFilterExpression(FilterExpression):
|
||||||
######################################################################################################################
|
######################################################################################################################
|
||||||
|
|
||||||
# Regex for token keyword arguments
|
# Regex for token keyword arguments
|
||||||
kwarg_re = _lazy_re_compile(r"(?:([\w\-\:\@\.\#]+)=)?(.+)")
|
kwarg_re = _lazy_re_compile(r"(?:([{var_chars}]+)=)?(.+)".format(var_chars=VAR_CHARS))
|
||||||
|
|
||||||
|
|
||||||
def token_kwargs(bits: List[str], parser: Parser) -> Dict[str, FilterExpression]:
|
def token_kwargs(bits: List[str], parser: Parser) -> Dict[str, FilterExpression]:
|
||||||
|
@ -261,9 +264,7 @@ def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
processed_kwargs = {}
|
processed_kwargs = {}
|
||||||
nested_kwargs: Dict[str, Dict[str, Any]] = {}
|
nested_kwargs: Dict[str, Dict[str, Any]] = {}
|
||||||
for key, val in kwargs.items():
|
for key, val in kwargs.items():
|
||||||
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
|
if not is_aggregate_key(key):
|
||||||
# This syntax is used by Vue and AlpineJS.
|
|
||||||
if ":" not in key or key.startswith(":"):
|
|
||||||
processed_kwargs[key] = val
|
processed_kwargs[key] = val
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
@ -283,3 +284,9 @@ def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
processed_kwargs[key] = val
|
processed_kwargs[key] = val
|
||||||
|
|
||||||
return processed_kwargs
|
return processed_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def is_aggregate_key(key: str) -> bool:
|
||||||
|
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
|
||||||
|
# This syntax is used by Vue and AlpineJS.
|
||||||
|
return ":" in key and not key.startswith(":")
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import TYPE_CHECKING, Dict, List, Mapping, Optional, Tuple
|
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, Union
|
||||||
|
|
||||||
import django.template
|
import django.template
|
||||||
from django.template.base import FilterExpression, NodeList, Parser, Token
|
from django.template.base import FilterExpression, NodeList, Parser, Token
|
||||||
|
@ -10,7 +10,7 @@ from django_components.attributes import HtmlAttrsNode
|
||||||
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
|
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
|
||||||
from django_components.component_registry import ComponentRegistry
|
from django_components.component_registry import ComponentRegistry
|
||||||
from django_components.component_registry import registry as component_registry
|
from django_components.component_registry import registry as component_registry
|
||||||
from django_components.expression import resolve_string
|
from django_components.expression import AggregateFilterExpression, Expression, resolve_string
|
||||||
from django_components.logger import trace_msg
|
from django_components.logger import trace_msg
|
||||||
from django_components.middleware import (
|
from django_components.middleware import (
|
||||||
CSS_DEPENDENCY_PLACEHOLDER,
|
CSS_DEPENDENCY_PLACEHOLDER,
|
||||||
|
@ -19,13 +19,16 @@ from django_components.middleware import (
|
||||||
)
|
)
|
||||||
from django_components.provide import ProvideNode
|
from django_components.provide import ProvideNode
|
||||||
from django_components.slots import FillNode, SlotNode, parse_slot_fill_nodes_from_component_nodelist
|
from django_components.slots import FillNode, SlotNode, parse_slot_fill_nodes_from_component_nodelist
|
||||||
from django_components.template_parser import parse_bits
|
from django_components.tag_formatter import get_tag_formatter
|
||||||
from django_components.utils import gen_id
|
from django_components.template_parser import is_aggregate_key, parse_bits, process_aggregate_kwargs
|
||||||
|
from django_components.utils import gen_id, is_str_wrapped_in_quotes
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django_components.component import Component
|
from django_components.component import Component
|
||||||
|
|
||||||
|
|
||||||
|
# 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()
|
register = django.template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@ -35,7 +38,7 @@ SLOT_DATA_ATTR = "data"
|
||||||
SLOT_DEFAULT_ATTR = "default"
|
SLOT_DEFAULT_ATTR = "default"
|
||||||
|
|
||||||
|
|
||||||
def get_components_from_registry(registry: ComponentRegistry) -> List["Component"]:
|
def _get_components_from_registry(registry: ComponentRegistry) -> List["Component"]:
|
||||||
"""Returns a list unique components from the registry."""
|
"""Returns a list unique components from the registry."""
|
||||||
|
|
||||||
unique_component_classes = set(registry.all().values())
|
unique_component_classes = set(registry.all().values())
|
||||||
|
@ -47,7 +50,7 @@ def get_components_from_registry(registry: ComponentRegistry) -> List["Component
|
||||||
return components
|
return components
|
||||||
|
|
||||||
|
|
||||||
def get_components_from_preload_str(preload_str: str) -> List["Component"]:
|
def _get_components_from_preload_str(preload_str: str) -> List["Component"]:
|
||||||
"""Returns a list of unique components from a comma-separated str"""
|
"""Returns a list of unique components from a comma-separated str"""
|
||||||
|
|
||||||
components = []
|
components = []
|
||||||
|
@ -62,83 +65,89 @@ def get_components_from_preload_str(preload_str: str) -> List["Component"]:
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag(name="component_dependencies")
|
@register.simple_tag(name="component_dependencies")
|
||||||
def component_dependencies_tag(preload: str = "") -> SafeString:
|
def component_dependencies(preload: str = "") -> SafeString:
|
||||||
"""Marks location where CSS link and JS script tags should be rendered."""
|
"""Marks location where CSS link and JS script tags should be rendered."""
|
||||||
|
|
||||||
if is_dependency_middleware_active():
|
if is_dependency_middleware_active():
|
||||||
preloaded_dependencies = []
|
preloaded_dependencies = []
|
||||||
for component in get_components_from_preload_str(preload):
|
for component in _get_components_from_preload_str(preload):
|
||||||
preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name))
|
preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name))
|
||||||
return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER + JS_DEPENDENCY_PLACEHOLDER)
|
return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER + JS_DEPENDENCY_PLACEHOLDER)
|
||||||
else:
|
else:
|
||||||
rendered_dependencies = []
|
rendered_dependencies = []
|
||||||
for component in get_components_from_registry(component_registry):
|
for component in _get_components_from_registry(component_registry):
|
||||||
rendered_dependencies.append(component.render_dependencies())
|
rendered_dependencies.append(component.render_dependencies())
|
||||||
|
|
||||||
return mark_safe("\n".join(rendered_dependencies))
|
return mark_safe("\n".join(rendered_dependencies))
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag(name="component_css_dependencies")
|
@register.simple_tag(name="component_css_dependencies")
|
||||||
def component_css_dependencies_tag(preload: str = "") -> SafeString:
|
def component_css_dependencies(preload: str = "") -> SafeString:
|
||||||
"""Marks location where CSS link tags should be rendered."""
|
"""Marks location where CSS link tags should be rendered."""
|
||||||
|
|
||||||
if is_dependency_middleware_active():
|
if is_dependency_middleware_active():
|
||||||
preloaded_dependencies = []
|
preloaded_dependencies = []
|
||||||
for component in get_components_from_preload_str(preload):
|
for component in _get_components_from_preload_str(preload):
|
||||||
preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name))
|
preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name))
|
||||||
return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER)
|
return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER)
|
||||||
else:
|
else:
|
||||||
rendered_dependencies = []
|
rendered_dependencies = []
|
||||||
for component in get_components_from_registry(component_registry):
|
for component in _get_components_from_registry(component_registry):
|
||||||
rendered_dependencies.append(component.render_css_dependencies())
|
rendered_dependencies.append(component.render_css_dependencies())
|
||||||
|
|
||||||
return mark_safe("\n".join(rendered_dependencies))
|
return mark_safe("\n".join(rendered_dependencies))
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag(name="component_js_dependencies")
|
@register.simple_tag(name="component_js_dependencies")
|
||||||
def component_js_dependencies_tag(preload: str = "") -> SafeString:
|
def component_js_dependencies(preload: str = "") -> SafeString:
|
||||||
"""Marks location where JS script tags should be rendered."""
|
"""Marks location where JS script tags should be rendered."""
|
||||||
|
|
||||||
if is_dependency_middleware_active():
|
if is_dependency_middleware_active():
|
||||||
preloaded_dependencies = []
|
preloaded_dependencies = []
|
||||||
for component in get_components_from_preload_str(preload):
|
for component in _get_components_from_preload_str(preload):
|
||||||
preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name))
|
preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name))
|
||||||
return mark_safe("\n".join(preloaded_dependencies) + JS_DEPENDENCY_PLACEHOLDER)
|
return mark_safe("\n".join(preloaded_dependencies) + JS_DEPENDENCY_PLACEHOLDER)
|
||||||
else:
|
else:
|
||||||
rendered_dependencies = []
|
rendered_dependencies = []
|
||||||
for component in get_components_from_registry(component_registry):
|
for component in _get_components_from_registry(component_registry):
|
||||||
rendered_dependencies.append(component.render_js_dependencies())
|
rendered_dependencies.append(component.render_js_dependencies())
|
||||||
|
|
||||||
return mark_safe("\n".join(rendered_dependencies))
|
return mark_safe("\n".join(rendered_dependencies))
|
||||||
|
|
||||||
|
|
||||||
@register.tag("slot")
|
@register.tag("slot")
|
||||||
def do_slot(parser: Parser, token: Token) -> SlotNode:
|
def slot(parser: Parser, token: Token) -> SlotNode:
|
||||||
# e.g. {% slot <name> ... %}
|
bits = token.split_contents()
|
||||||
tag_name, *args = token.split_contents()
|
tag = _parse_tag(
|
||||||
slot_name, is_default, is_required, slot_kwargs = _parse_slot_args(parser, args, tag_name)
|
"slot",
|
||||||
# Use a unique ID to be able to tie the fill nodes with components and slots
|
parser,
|
||||||
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
|
bits,
|
||||||
slot_id = gen_id()
|
params=["name"],
|
||||||
trace_msg("PARSE", "SLOT", slot_name, slot_id)
|
flags=[SLOT_DEFAULT_OPTION_KEYWORD, SLOT_REQUIRED_OPTION_KEYWORD],
|
||||||
|
keywordonly_kwargs=True,
|
||||||
|
repeatable_kwargs=False,
|
||||||
|
end_tag="endslot",
|
||||||
|
)
|
||||||
|
data = _parse_slot_args(parser, tag)
|
||||||
|
|
||||||
nodelist = parser.parse(parse_until=["endslot"])
|
trace_msg("PARSE", "SLOT", data.name, tag.id)
|
||||||
parser.delete_first_token()
|
|
||||||
|
body = tag.parse_body()
|
||||||
slot_node = SlotNode(
|
slot_node = SlotNode(
|
||||||
slot_name,
|
name=data.name,
|
||||||
nodelist,
|
nodelist=body,
|
||||||
is_required=is_required,
|
is_required=tag.flags[SLOT_REQUIRED_OPTION_KEYWORD],
|
||||||
is_default=is_default,
|
is_default=tag.flags[SLOT_DEFAULT_OPTION_KEYWORD],
|
||||||
node_id=slot_id,
|
node_id=tag.id,
|
||||||
slot_kwargs=slot_kwargs,
|
slot_kwargs=tag.kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
trace_msg("PARSE", "SLOT", slot_name, slot_id, "...Done!")
|
trace_msg("PARSE", "SLOT", data.name, tag.id, "...Done!")
|
||||||
return slot_node
|
return slot_node
|
||||||
|
|
||||||
|
|
||||||
@register.tag("fill")
|
@register.tag("fill")
|
||||||
def do_fill(parser: Parser, token: Token) -> FillNode:
|
def fill(parser: Parser, token: Token) -> FillNode:
|
||||||
"""
|
"""
|
||||||
Block tag whose contents 'fill' (are inserted into) an identically named
|
Block tag whose contents 'fill' (are inserted into) an identically named
|
||||||
'slot'-block in the component template referred to by a parent component.
|
'slot'-block in the component template referred to by a parent component.
|
||||||
|
@ -147,32 +156,34 @@ def do_fill(parser: Parser, token: Token) -> FillNode:
|
||||||
This tag is available only within a {% component %}..{% endcomponent %} block.
|
This tag is available only within a {% component %}..{% endcomponent %} block.
|
||||||
Runtime checks should prohibit other usages.
|
Runtime checks should prohibit other usages.
|
||||||
"""
|
"""
|
||||||
# e.g. {% fill <name> %}
|
bits = token.split_contents()
|
||||||
tag_name, *args = token.split_contents()
|
tag = _parse_tag(
|
||||||
slot_name_fexp, slot_default_var_fexp, slot_data_var_fexp = _parse_fill_args(parser, args, tag_name)
|
"fill",
|
||||||
|
parser,
|
||||||
|
bits,
|
||||||
|
params=["name"],
|
||||||
|
keywordonly_kwargs=[SLOT_DATA_ATTR, SLOT_DEFAULT_ATTR],
|
||||||
|
repeatable_kwargs=False,
|
||||||
|
end_tag="endfill",
|
||||||
|
)
|
||||||
|
data = _parse_fill_args(parser, tag)
|
||||||
|
|
||||||
# Use a unique ID to be able to tie the fill nodes with components and slots
|
trace_msg("PARSE", "FILL", str(data.slot_name), tag.id)
|
||||||
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
|
|
||||||
fill_id = gen_id()
|
|
||||||
trace_msg("PARSE", "FILL", str(slot_name_fexp), fill_id)
|
|
||||||
|
|
||||||
nodelist = parser.parse(parse_until=["endfill"])
|
|
||||||
parser.delete_first_token()
|
|
||||||
|
|
||||||
|
body = tag.parse_body()
|
||||||
fill_node = FillNode(
|
fill_node = FillNode(
|
||||||
nodelist,
|
nodelist=body,
|
||||||
name_fexp=slot_name_fexp,
|
name_fexp=data.slot_name,
|
||||||
slot_default_var_fexp=slot_default_var_fexp,
|
slot_default_var_fexp=data.slot_default_var,
|
||||||
slot_data_var_fexp=slot_data_var_fexp,
|
slot_data_var_fexp=data.slot_data_var,
|
||||||
node_id=fill_id,
|
node_id=tag.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
trace_msg("PARSE", "FILL", str(slot_name_fexp), fill_id, "...Done!")
|
trace_msg("PARSE", "FILL", str(data.slot_name), tag.id, "...Done!")
|
||||||
return fill_node
|
return fill_node
|
||||||
|
|
||||||
|
|
||||||
@register.tag(name="component")
|
def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
|
||||||
def do_component(parser: Parser, token: Token) -> ComponentNode:
|
|
||||||
"""
|
"""
|
||||||
To give the component access to the template context:
|
To give the component access to the template context:
|
||||||
{% component "name" positional_arg keyword_arg=value ... %}
|
{% component "name" positional_arg keyword_arg=value ... %}
|
||||||
|
@ -185,64 +196,83 @@ def do_component(parser: Parser, token: Token) -> ComponentNode:
|
||||||
be either the first positional argument or, if there are no positional
|
be either the first positional argument or, if there are no positional
|
||||||
arguments, passed as 'name'.
|
arguments, passed as 'name'.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
bits = token.split_contents()
|
bits = token.split_contents()
|
||||||
bits, isolated_context = _check_for_isolated_context_keyword(bits)
|
|
||||||
component_name, context_args, context_kwargs = _parse_component_with_args(parser, bits, "component")
|
|
||||||
|
|
||||||
# Use a unique ID to be able to tie the fill nodes with components and slots
|
# Let the TagFormatter pre-process the tokens
|
||||||
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
|
formatter = get_tag_formatter()
|
||||||
component_id = gen_id()
|
result = formatter.parse([*bits])
|
||||||
trace_msg("PARSE", "COMP", component_name, component_id)
|
end_tag = formatter.end_tag(result.component_name)
|
||||||
|
|
||||||
body: NodeList = parser.parse(parse_until=["endcomponent"])
|
# NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself
|
||||||
parser.delete_first_token()
|
bits = [bits[0], *result.tokens]
|
||||||
|
|
||||||
|
tag = _parse_tag(
|
||||||
|
tag_name,
|
||||||
|
parser,
|
||||||
|
bits,
|
||||||
|
params=True, # Allow many args
|
||||||
|
flags=["only"],
|
||||||
|
keywordonly_kwargs=True,
|
||||||
|
repeatable_kwargs=False,
|
||||||
|
end_tag=end_tag,
|
||||||
|
)
|
||||||
|
data = _parse_component_args(parser, tag)
|
||||||
|
|
||||||
|
trace_msg("PARSE", "COMP", result.component_name, tag.id)
|
||||||
|
|
||||||
|
body = tag.parse_body()
|
||||||
fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode)
|
fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode)
|
||||||
|
|
||||||
# Tag all fill nodes as children of this particular component instance
|
# Tag all fill nodes as children of this particular component instance
|
||||||
for node in fill_nodes:
|
for node in fill_nodes:
|
||||||
trace_msg("ASSOC", "FILL", node.name_fexp, node.node_id, component_id=component_id)
|
trace_msg("ASSOC", "FILL", node.name_fexp, node.node_id, component_id=tag.id)
|
||||||
node.component_id = component_id
|
node.component_id = tag.id
|
||||||
|
|
||||||
component_node = ComponentNode(
|
component_node = ComponentNode(
|
||||||
FilterExpression(component_name, parser),
|
name=result.component_name,
|
||||||
context_args,
|
context_args=tag.args,
|
||||||
context_kwargs,
|
context_kwargs=tag.kwargs,
|
||||||
isolated_context=isolated_context,
|
isolated_context=data.isolated_context,
|
||||||
fill_nodes=fill_nodes,
|
fill_nodes=fill_nodes,
|
||||||
component_id=component_id,
|
component_id=tag.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
trace_msg("PARSE", "COMP", component_name, component_id, "...Done!")
|
trace_msg("PARSE", "COMP", result.component_name, tag.id, "...Done!")
|
||||||
return component_node
|
return component_node
|
||||||
|
|
||||||
|
|
||||||
@register.tag("provide")
|
@register.tag("provide")
|
||||||
def do_provide(parser: Parser, token: Token) -> SlotNode:
|
def provide(parser: Parser, token: Token) -> ProvideNode:
|
||||||
# e.g. {% provide <name> key=val key2=val2 %}
|
# e.g. {% provide <name> key=val key2=val2 %}
|
||||||
tag_name, *args = token.split_contents()
|
bits = token.split_contents()
|
||||||
provide_key, kwargs = _parse_provide_args(parser, args, tag_name)
|
tag = _parse_tag(
|
||||||
|
"provide",
|
||||||
|
parser,
|
||||||
|
bits,
|
||||||
|
params=["name"],
|
||||||
|
flags=[],
|
||||||
|
keywordonly_kwargs=True,
|
||||||
|
repeatable_kwargs=False,
|
||||||
|
end_tag="endprovide",
|
||||||
|
)
|
||||||
|
data = _parse_provide_args(parser, tag)
|
||||||
|
|
||||||
# Use a unique ID to be able to tie the fill nodes with components and slots
|
trace_msg("PARSE", "PROVIDE", data.key, tag.id)
|
||||||
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
|
|
||||||
slot_id = gen_id()
|
|
||||||
trace_msg("PARSE", "PROVIDE", provide_key, slot_id)
|
|
||||||
|
|
||||||
nodelist = parser.parse(parse_until=["endprovide"])
|
body = tag.parse_body()
|
||||||
parser.delete_first_token()
|
|
||||||
slot_node = ProvideNode(
|
slot_node = ProvideNode(
|
||||||
provide_key,
|
name=data.key,
|
||||||
nodelist,
|
nodelist=body,
|
||||||
node_id=slot_id,
|
node_id=tag.id,
|
||||||
provide_kwargs=kwargs,
|
provide_kwargs=tag.kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
trace_msg("PARSE", "PROVIDE", provide_key, slot_id, "...Done!")
|
trace_msg("PARSE", "PROVIDE", data.key, tag.id, "...Done!")
|
||||||
return slot_node
|
return slot_node
|
||||||
|
|
||||||
|
|
||||||
@register.tag("html_attrs")
|
@register.tag("html_attrs")
|
||||||
def do_html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
|
def html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
|
||||||
"""
|
"""
|
||||||
This tag takes:
|
This tag takes:
|
||||||
- Optional dictionary of attributes (`attrs`)
|
- Optional dictionary of attributes (`attrs`)
|
||||||
|
@ -270,243 +300,315 @@ def do_html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
bits = token.split_contents()
|
bits = token.split_contents()
|
||||||
attributes, default_attrs, append_attrs = _parse_html_attrs_args(parser, bits, "html_attrs")
|
|
||||||
return HtmlAttrsNode(attributes, default_attrs, append_attrs)
|
tag = _parse_tag(
|
||||||
|
"html_attrs",
|
||||||
|
parser,
|
||||||
|
bits,
|
||||||
|
params=["attrs", "defaults"],
|
||||||
|
optional_params=["attrs", "defaults"],
|
||||||
|
flags=[],
|
||||||
|
keywordonly_kwargs=True,
|
||||||
|
repeatable_kwargs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return HtmlAttrsNode(
|
||||||
|
attributes=tag.kwargs.get("attrs"),
|
||||||
|
defaults=tag.kwargs.get("defaults"),
|
||||||
|
kwargs=[(key, val) for key, val in tag.kwarg_pairs if key != "attrs" and key != "defaults"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _check_for_isolated_context_keyword(bits: List[str]) -> Tuple[List[str], bool]:
|
class ParsedTag(NamedTuple):
|
||||||
"""Return True and strip the last word if token ends with 'only' keyword or if CONTEXT_BEHAVIOR is 'isolated'."""
|
id: str
|
||||||
|
name: str
|
||||||
if bits[-1] == "only":
|
bits: List[str]
|
||||||
return bits[:-1], True
|
flags: Dict[str, bool]
|
||||||
|
args: List[FilterExpression]
|
||||||
if app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED:
|
named_args: Dict[str, FilterExpression]
|
||||||
return bits, True
|
kwargs: Dict[str, Expression]
|
||||||
|
kwarg_pairs: List[Tuple[str, Expression]]
|
||||||
return bits, False
|
is_inline: bool
|
||||||
|
parse_body: Callable[[], NodeList]
|
||||||
|
|
||||||
|
|
||||||
def _parse_component_with_args(
|
def _parse_tag(
|
||||||
parser: Parser, bits: List[str], tag_name: str
|
tag: str,
|
||||||
) -> Tuple[str, List[FilterExpression], Mapping[str, FilterExpression]]:
|
parser: Parser,
|
||||||
tag_args, tag_kwarg_pairs = parse_bits(
|
bits: List[str],
|
||||||
|
params: Union[List[str], bool] = False,
|
||||||
|
flags: Optional[List[str]] = None,
|
||||||
|
end_tag: Optional[str] = None,
|
||||||
|
optional_params: Optional[List[str]] = None,
|
||||||
|
keywordonly_kwargs: Optional[Union[bool, List[str]]] = False,
|
||||||
|
repeatable_kwargs: Optional[Union[bool, List[str]]] = False,
|
||||||
|
) -> ParsedTag:
|
||||||
|
# Use a unique ID to be able to tie the fill nodes with components and slots
|
||||||
|
# NOTE: MUST be called BEFORE `parse_body()` to ensure predictable numbering
|
||||||
|
tag_id = gen_id()
|
||||||
|
|
||||||
|
params = params or []
|
||||||
|
|
||||||
|
# e.g. {% slot <name> ... %}
|
||||||
|
tag_name, *bits = bits
|
||||||
|
if tag_name != tag:
|
||||||
|
raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag}'")
|
||||||
|
|
||||||
|
# Decide if the template tag is inline or block and strip the trailing slash
|
||||||
|
last_token = bits[-1] if len(bits) else None
|
||||||
|
if last_token == "/":
|
||||||
|
bits.pop()
|
||||||
|
is_inline = True
|
||||||
|
else:
|
||||||
|
# If no end tag was given, we assume that the tag is inline-only
|
||||||
|
is_inline = not end_tag
|
||||||
|
|
||||||
|
parsed_flags = {flag: False for flag in (flags or [])}
|
||||||
|
bits_without_flags: List[str] = []
|
||||||
|
seen_kwargs: Set[str] = set()
|
||||||
|
seen_agg_keys: Set[str] = set()
|
||||||
|
|
||||||
|
def mark_kwarg_key(key: str, is_agg_key: bool) -> None:
|
||||||
|
if (is_agg_key and key in seen_kwargs) or (not is_agg_key and key in seen_agg_keys):
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
f"Received argument '{key}' both as a regular input ({key}=...)"
|
||||||
|
f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two"
|
||||||
|
)
|
||||||
|
if is_agg_key:
|
||||||
|
seen_agg_keys.add(key)
|
||||||
|
else:
|
||||||
|
seen_kwargs.add(key)
|
||||||
|
|
||||||
|
for bit in bits:
|
||||||
|
# Extract flags, which are like keywords but without the value part
|
||||||
|
if bit in parsed_flags:
|
||||||
|
parsed_flags[bit] = True
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
bits_without_flags.append(bit)
|
||||||
|
|
||||||
|
# Record which kwargs we've seen, to detect if kwargs were passed in
|
||||||
|
# as both aggregate and regular kwargs
|
||||||
|
if "=" in bit:
|
||||||
|
key = bit.split("=")[0]
|
||||||
|
|
||||||
|
# Also pick up on aggregate keys like `attr:key=val`
|
||||||
|
if is_aggregate_key(key):
|
||||||
|
key = key.split(":")[0]
|
||||||
|
mark_kwarg_key(key, True)
|
||||||
|
else:
|
||||||
|
mark_kwarg_key(key, False)
|
||||||
|
|
||||||
|
bits = bits_without_flags
|
||||||
|
|
||||||
|
# To support optional args, we need to convert these to kwargs, so `parse_bits`
|
||||||
|
# can handle them. So we assign the keys to matched positional args,
|
||||||
|
# and then move the kwarg AFTER the pos args.
|
||||||
|
#
|
||||||
|
# TODO: This following section should live in `parse_bits`, but I don't want to
|
||||||
|
# modify it much to maintain some sort of compatibility with Django's version of
|
||||||
|
# `parse_bits`.
|
||||||
|
# Ideally, Django's parser would be expanded to support our use cases.
|
||||||
|
if params != True: # noqa F712
|
||||||
|
params_to_sort = [param for param in params if param not in seen_kwargs]
|
||||||
|
new_args = []
|
||||||
|
new_params = []
|
||||||
|
new_kwargs = []
|
||||||
|
for index, bit in enumerate(bits):
|
||||||
|
if "=" in bit or not len(params_to_sort):
|
||||||
|
# Pass all remaining bits (including current one) as kwargs
|
||||||
|
new_kwargs.extend(bits[index:])
|
||||||
|
break
|
||||||
|
|
||||||
|
param = params_to_sort.pop(0)
|
||||||
|
if optional_params and param in optional_params:
|
||||||
|
mark_kwarg_key(param, False)
|
||||||
|
new_kwargs.append(f"{param}={bit}")
|
||||||
|
continue
|
||||||
|
new_args.append(bit)
|
||||||
|
new_params.append(param)
|
||||||
|
|
||||||
|
bits = [*new_args, *new_kwargs]
|
||||||
|
params = [*new_params, *params_to_sort]
|
||||||
|
|
||||||
|
# Remove any remaining optional positional args if they were not given
|
||||||
|
if optional_params:
|
||||||
|
params = [param for param in params_to_sort if param not in optional_params]
|
||||||
|
|
||||||
|
# Parse args/kwargs that will be passed to the fill
|
||||||
|
args, raw_kwarg_pairs = parse_bits(
|
||||||
parser=parser,
|
parser=parser,
|
||||||
bits=bits,
|
bits=bits,
|
||||||
params=["tag_name", "name"],
|
params=[] if isinstance(params, bool) else params,
|
||||||
name=tag_name,
|
name=tag_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
tag_kwargs = {}
|
# Post-process args/kwargs - Mark special cases like aggregate dicts
|
||||||
for key, val in tag_kwarg_pairs:
|
# or dynamic expressions
|
||||||
if key in tag_kwargs:
|
pre_aggregate_kwargs: Dict[str, FilterExpression] = {}
|
||||||
|
kwarg_pairs: List[Tuple[str, Expression]] = []
|
||||||
|
for key, val in raw_kwarg_pairs:
|
||||||
|
# NOTE: If a tag allows mutliple kwargs, and we provide a same aggregate key
|
||||||
|
# multiple times (e.g. `attr:class="hidden" and `attr:class="another"`), then
|
||||||
|
# we take only the last instance.
|
||||||
|
if is_aggregate_key(key):
|
||||||
|
pre_aggregate_kwargs[key] = val
|
||||||
|
else:
|
||||||
|
kwarg_pairs.append((key, val))
|
||||||
|
aggregate_kwargs: Dict[str, Dict[str, FilterExpression]] = process_aggregate_kwargs(pre_aggregate_kwargs)
|
||||||
|
|
||||||
|
for key, agg_dict in aggregate_kwargs.items():
|
||||||
|
entry = (key, AggregateFilterExpression(agg_dict))
|
||||||
|
kwarg_pairs.append(entry)
|
||||||
|
|
||||||
|
# Allow only as many positional args as given
|
||||||
|
if params != True and len(args) > len(params): # noqa F712
|
||||||
|
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {args[len(params):]}")
|
||||||
|
|
||||||
|
# For convenience, allow to access named args by their name instead of index
|
||||||
|
if params != True: # noqa F712
|
||||||
|
named_args = {param: args[index] for index, param in enumerate(params)}
|
||||||
|
else:
|
||||||
|
named_args = {}
|
||||||
|
|
||||||
|
# Validate kwargs
|
||||||
|
kwargs: Dict[str, Expression] = {}
|
||||||
|
extra_keywords: Set[str] = set()
|
||||||
|
for key, val in kwarg_pairs:
|
||||||
|
# Check if key allowed
|
||||||
|
if not keywordonly_kwargs:
|
||||||
|
is_key_allowed = False
|
||||||
|
else:
|
||||||
|
is_key_allowed = keywordonly_kwargs == True or key in keywordonly_kwargs # noqa: E712
|
||||||
|
if not is_key_allowed:
|
||||||
|
extra_keywords.add(key)
|
||||||
|
|
||||||
|
# Check for repeated keys
|
||||||
|
if key in kwargs:
|
||||||
|
if not repeatable_kwargs:
|
||||||
|
is_key_repeatable = False
|
||||||
|
else:
|
||||||
|
is_key_repeatable = repeatable_kwargs == True or key in repeatable_kwargs # noqa: E712
|
||||||
|
if not is_key_repeatable:
|
||||||
# The keyword argument has already been supplied once
|
# The keyword argument has already been supplied once
|
||||||
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
|
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
|
||||||
tag_kwargs[key] = val
|
# All ok
|
||||||
|
kwargs[key] = val
|
||||||
|
|
||||||
if tag_name != tag_args[0].token:
|
if len(extra_keywords):
|
||||||
raise RuntimeError(f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}")
|
extra_keys = ", ".join(extra_keywords)
|
||||||
|
raise TemplateSyntaxError(f"'{tag_name}' received unexpected kwargs: {extra_keys}")
|
||||||
|
|
||||||
component_name = _get_positional_param(tag_name, "name", 1, tag_args, tag_kwargs).token
|
return ParsedTag(
|
||||||
if len(tag_args) > 1:
|
id=tag_id,
|
||||||
# Positional args given. Skip tag and component name and take the rest
|
|
||||||
context_args = tag_args[2:]
|
|
||||||
else: # No positional args
|
|
||||||
context_args = []
|
|
||||||
|
|
||||||
return component_name, context_args, tag_kwargs
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_html_attrs_args(
|
|
||||||
parser: Parser, bits: List[str], tag_name: str
|
|
||||||
) -> Tuple[Optional[FilterExpression], Optional[FilterExpression], List[Tuple[str, FilterExpression]]]:
|
|
||||||
tag_args, tag_kwarg_pairs = parse_bits(
|
|
||||||
parser=parser,
|
|
||||||
bits=bits,
|
|
||||||
params=["tag_name"],
|
|
||||||
name=tag_name,
|
name=tag_name,
|
||||||
|
bits=bits,
|
||||||
|
flags=parsed_flags,
|
||||||
|
args=args,
|
||||||
|
named_args=named_args,
|
||||||
|
kwargs=kwargs,
|
||||||
|
kwarg_pairs=kwarg_pairs,
|
||||||
|
# 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, end_tag, is_inline) if end_tag else NodeList(),
|
||||||
|
is_inline=is_inline,
|
||||||
)
|
)
|
||||||
|
|
||||||
# NOTE: Unlike in the `component` tag, in this case we don't care about duplicates,
|
|
||||||
# as we're constructing the dict simply to find the `attrs` kwarg.
|
|
||||||
tag_kwargs = {key: val for key, val in tag_kwarg_pairs}
|
|
||||||
|
|
||||||
if tag_name != tag_args[0].token:
|
def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
|
||||||
raise RuntimeError(f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}")
|
if inline:
|
||||||
|
body = NodeList()
|
||||||
|
else:
|
||||||
|
body = parser.parse(parse_until=[end_tag])
|
||||||
|
parser.delete_first_token()
|
||||||
|
return body
|
||||||
|
|
||||||
# Allow to optioanlly provide `attrs` as positional arg `{% html_attrs attrs %}`
|
|
||||||
try:
|
|
||||||
attrs = _get_positional_param(tag_name, "attrs", 1, tag_args, tag_kwargs)
|
|
||||||
except TemplateSyntaxError:
|
|
||||||
attrs = None
|
|
||||||
|
|
||||||
# Allow to optionally provide `defaults` as positional arg `{% html_attrs attrs defaults %}`
|
class ParsedComponentTag(NamedTuple):
|
||||||
try:
|
isolated_context: bool
|
||||||
defaults = _get_positional_param(tag_name, "defaults", 2, tag_args, tag_kwargs)
|
|
||||||
except TemplateSyntaxError:
|
|
||||||
defaults = None
|
|
||||||
|
|
||||||
# Allow only up to 2 positional args - [0] == tag name, [1] == attrs, [2] == defaults
|
|
||||||
if len(tag_args) > 3:
|
|
||||||
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {tag_args[2:]}")
|
|
||||||
|
|
||||||
return attrs, defaults, tag_kwarg_pairs
|
def _parse_component_args(
|
||||||
|
parser: Parser,
|
||||||
|
tag: ParsedTag,
|
||||||
|
) -> ParsedComponentTag:
|
||||||
|
# Check for isolated context keyword
|
||||||
|
isolated_context = tag.flags["only"] or app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED
|
||||||
|
|
||||||
|
return ParsedComponentTag(isolated_context=isolated_context)
|
||||||
|
|
||||||
|
|
||||||
|
class ParsedSlotTag(NamedTuple):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
def _parse_slot_args(
|
def _parse_slot_args(
|
||||||
parser: Parser,
|
parser: Parser,
|
||||||
bits: List[str],
|
tag: ParsedTag,
|
||||||
tag_name: str,
|
) -> ParsedSlotTag:
|
||||||
) -> Tuple[str, bool, bool, Dict[str, FilterExpression]]:
|
slot_name = tag.named_args["name"].token
|
||||||
if not len(bits):
|
if not is_str_wrapped_in_quotes(slot_name):
|
||||||
raise TemplateSyntaxError(
|
raise TemplateSyntaxError(f"'{tag.name}' name must be a string 'literal', got {slot_name}.")
|
||||||
"'slot' tag does not match pattern "
|
|
||||||
"{% slot <name> ['default'] ['required'] [key=val, ...] %}. "
|
|
||||||
"Order of options is free."
|
|
||||||
)
|
|
||||||
|
|
||||||
slot_name, *options = bits
|
|
||||||
if not is_wrapped_in_quotes(slot_name):
|
|
||||||
raise TemplateSyntaxError(f"'{tag_name}' name must be a string 'literal'.")
|
|
||||||
|
|
||||||
slot_name = resolve_string(slot_name, parser)
|
slot_name = resolve_string(slot_name, parser)
|
||||||
|
|
||||||
# Parse flags - Since `parse_bits` doesn't handle "shorthand" kwargs
|
return ParsedSlotTag(name=slot_name)
|
||||||
# (AKA `required` for `required=True`), we have to first get the flags out
|
|
||||||
# of the way.
|
|
||||||
def extract_value(lst: List[str], value: str) -> bool:
|
|
||||||
"""Check if value exists in list, and if so, remove it from said list"""
|
|
||||||
try:
|
|
||||||
lst.remove(value)
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
is_default = extract_value(options, SLOT_DEFAULT_OPTION_KEYWORD)
|
|
||||||
is_required = extract_value(options, SLOT_REQUIRED_OPTION_KEYWORD)
|
|
||||||
|
|
||||||
# Parse kwargs that will be passed to the fill
|
class ParsedFillTag(NamedTuple):
|
||||||
_, tag_kwarg_pairs = parse_bits(
|
slot_name: FilterExpression
|
||||||
parser=parser,
|
slot_default_var: Optional[FilterExpression]
|
||||||
bits=options,
|
slot_data_var: Optional[FilterExpression]
|
||||||
params=[],
|
|
||||||
name=tag_name,
|
|
||||||
)
|
|
||||||
tag_kwargs: Dict[str, FilterExpression] = {}
|
|
||||||
for key, val in tag_kwarg_pairs:
|
|
||||||
if key in tag_kwargs:
|
|
||||||
# The keyword argument has already been supplied once
|
|
||||||
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
|
|
||||||
tag_kwargs[key] = val
|
|
||||||
|
|
||||||
return slot_name, is_default, is_required, tag_kwargs
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_fill_args(
|
def _parse_fill_args(
|
||||||
parser: Parser,
|
parser: Parser,
|
||||||
bits: List[str],
|
tag: ParsedTag,
|
||||||
tag_name: str,
|
) -> ParsedFillTag:
|
||||||
) -> Tuple[FilterExpression, Optional[FilterExpression], Optional[FilterExpression]]:
|
slot_name_fexp = tag.named_args["name"]
|
||||||
if not len(bits):
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
"'fill' tag does not match pattern "
|
|
||||||
f"{{% fill <name> [{SLOT_DATA_ATTR}=val] [{SLOT_DEFAULT_ATTR}=slot_var] %}} "
|
|
||||||
)
|
|
||||||
|
|
||||||
slot_name = bits.pop(0)
|
|
||||||
slot_name_fexp = parser.compile_filter(slot_name)
|
|
||||||
|
|
||||||
# Even tho we want to parse only single kwarg, we use the same logic for parsing
|
|
||||||
# as we use for other tags, for consistency.
|
|
||||||
_, tag_kwarg_pairs = parse_bits(
|
|
||||||
parser=parser,
|
|
||||||
bits=bits,
|
|
||||||
params=[],
|
|
||||||
name=tag_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
tag_kwargs: Dict[str, FilterExpression] = {}
|
|
||||||
for key, val in tag_kwarg_pairs:
|
|
||||||
if key in tag_kwargs:
|
|
||||||
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
|
|
||||||
tag_kwargs[key] = val
|
|
||||||
|
|
||||||
# Extract known kwargs
|
# Extract known kwargs
|
||||||
slot_data_var_fexp: Optional[FilterExpression] = None
|
slot_data_var_fexp: Optional[FilterExpression] = tag.kwargs.get(SLOT_DATA_ATTR)
|
||||||
if SLOT_DATA_ATTR in tag_kwargs:
|
if slot_data_var_fexp and not is_str_wrapped_in_quotes(slot_data_var_fexp.token):
|
||||||
slot_data_var_fexp = tag_kwargs.pop(SLOT_DATA_ATTR)
|
|
||||||
if not is_wrapped_in_quotes(slot_data_var_fexp.token):
|
|
||||||
raise TemplateSyntaxError(
|
raise TemplateSyntaxError(
|
||||||
f"Value of '{SLOT_DATA_ATTR}' in '{tag_name}' tag must be a string literal, got '{slot_data_var_fexp}'"
|
f"Value of '{SLOT_DATA_ATTR}' in '{tag.name}' tag must be a string literal, got '{slot_data_var_fexp}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
slot_default_var_fexp: Optional[FilterExpression] = None
|
slot_default_var_fexp: Optional[FilterExpression] = tag.kwargs.get(SLOT_DEFAULT_ATTR)
|
||||||
if SLOT_DEFAULT_ATTR in tag_kwargs:
|
if slot_default_var_fexp and not is_str_wrapped_in_quotes(slot_default_var_fexp.token):
|
||||||
slot_default_var_fexp = tag_kwargs.pop(SLOT_DEFAULT_ATTR)
|
|
||||||
if not is_wrapped_in_quotes(slot_default_var_fexp.token):
|
|
||||||
raise TemplateSyntaxError(
|
raise TemplateSyntaxError(
|
||||||
f"Value of '{SLOT_DEFAULT_ATTR}' in '{tag_name}' tag must be a string literal,"
|
f"Value of '{SLOT_DEFAULT_ATTR}' in '{tag.name}' tag must be a string literal,"
|
||||||
f" got '{slot_default_var_fexp}'"
|
f" got '{slot_default_var_fexp}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# data and default cannot be bound to the same variable
|
# data and default cannot be bound to the same variable
|
||||||
if slot_data_var_fexp and slot_default_var_fexp and slot_data_var_fexp.token == slot_default_var_fexp.token:
|
if slot_data_var_fexp and slot_default_var_fexp and slot_data_var_fexp.token == slot_default_var_fexp.token:
|
||||||
raise TemplateSyntaxError(
|
raise TemplateSyntaxError(
|
||||||
f"'{tag_name}' received the same string for slot default ({SLOT_DEFAULT_ATTR}=...)"
|
f"'{tag.name}' received the same string for slot default ({SLOT_DEFAULT_ATTR}=...)"
|
||||||
f" and slot data ({SLOT_DATA_ATTR}=...)"
|
f" and slot data ({SLOT_DATA_ATTR}=...)"
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(tag_kwargs):
|
return ParsedFillTag(
|
||||||
extra_keywords = tag_kwargs.keys()
|
slot_name=slot_name_fexp,
|
||||||
extra_keys = ", ".join(extra_keywords)
|
slot_default_var=slot_default_var_fexp,
|
||||||
raise TemplateSyntaxError(f"'{tag_name}' received unexpected kwargs: {extra_keys}")
|
slot_data_var=slot_data_var_fexp,
|
||||||
|
)
|
||||||
|
|
||||||
return slot_name_fexp, slot_default_var_fexp, slot_data_var_fexp
|
|
||||||
|
class ParsedProvideTag(NamedTuple):
|
||||||
|
key: str
|
||||||
|
|
||||||
|
|
||||||
def _parse_provide_args(
|
def _parse_provide_args(
|
||||||
parser: Parser,
|
parser: Parser,
|
||||||
bits: List[str],
|
tag: ParsedTag,
|
||||||
tag_name: str,
|
) -> ParsedProvideTag:
|
||||||
) -> Tuple[str, Dict[str, FilterExpression]]:
|
provide_key = tag.named_args["name"].token
|
||||||
if not len(bits):
|
if not is_str_wrapped_in_quotes(provide_key):
|
||||||
raise TemplateSyntaxError("'provide' tag does not match pattern {% provide <key> [key=val, ...] %}. ")
|
raise TemplateSyntaxError(f"'{tag.name}' key must be a string 'literal'.")
|
||||||
|
|
||||||
provide_key, *options = bits
|
|
||||||
if not is_wrapped_in_quotes(provide_key):
|
|
||||||
raise TemplateSyntaxError(f"'{tag_name}' key must be a string 'literal'.")
|
|
||||||
|
|
||||||
provide_key = resolve_string(provide_key, parser)
|
provide_key = resolve_string(provide_key, parser)
|
||||||
|
|
||||||
# Parse kwargs that will be 'provided' under the given key
|
return ParsedProvideTag(key=provide_key)
|
||||||
_, tag_kwarg_pairs = parse_bits(parser=parser, bits=options, params=[], name=tag_name)
|
|
||||||
tag_kwargs: Dict[str, FilterExpression] = {}
|
|
||||||
for key, val in tag_kwarg_pairs:
|
|
||||||
if key in tag_kwargs:
|
|
||||||
# The keyword argument has already been supplied once
|
|
||||||
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
|
|
||||||
tag_kwargs[key] = val
|
|
||||||
|
|
||||||
return provide_key, tag_kwargs
|
|
||||||
|
|
||||||
|
|
||||||
def _get_positional_param(
|
|
||||||
tag_name: str,
|
|
||||||
param_name: str,
|
|
||||||
param_index: int,
|
|
||||||
args: List[FilterExpression],
|
|
||||||
kwargs: Dict[str, FilterExpression],
|
|
||||||
) -> FilterExpression:
|
|
||||||
# Param is given as positional arg, e.g. `{% tag param %}`
|
|
||||||
if len(args) > param_index:
|
|
||||||
param = args[param_index]
|
|
||||||
return param
|
|
||||||
# Check if param was given as kwarg, e.g. `{% tag param_name=param %}`
|
|
||||||
elif param_name in kwargs:
|
|
||||||
param = kwargs.pop(param_name)
|
|
||||||
return param
|
|
||||||
|
|
||||||
raise TemplateSyntaxError(f"Param '{param_name}' not found in '{tag_name}' tag")
|
|
||||||
|
|
||||||
|
|
||||||
def is_wrapped_in_quotes(s: str) -> bool:
|
|
||||||
return s.startswith(('"', "'")) and s[0] == s[-1]
|
|
||||||
|
|
|
@ -19,3 +19,7 @@ def find_last_index(lst: List, predicate: Callable[[Any], bool]) -> Any:
|
||||||
if predicate(elem):
|
if predicate(elem):
|
||||||
return len(lst) - 1 - r_idx
|
return len(lst) - 1 - r_idx
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def is_str_wrapped_in_quotes(s: str) -> bool:
|
||||||
|
return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2
|
||||||
|
|
|
@ -128,7 +128,7 @@ class HtmlAttrsTests(BaseTestCase):
|
||||||
template = Template(self.template_str)
|
template = Template(self.template_str)
|
||||||
|
|
||||||
with self.assertRaisesMessage(
|
with self.assertRaisesMessage(
|
||||||
TemplateSyntaxError, "Tag 'html_attrs' received unexpected positional arguments"
|
TemplateSyntaxError, "'html_attrs' received some positional argument(s) after some keyword"
|
||||||
):
|
):
|
||||||
template.render(Context({"class_var": "padding-top-8"}))
|
template.render(Context({"class_var": "padding-top-8"}))
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ from django_components import Component, registry, types
|
||||||
from .django_test_setup import setup_test_config
|
from .django_test_setup import setup_test_config
|
||||||
from .testutils import BaseTestCase, parametrize_context_behavior
|
from .testutils import BaseTestCase, parametrize_context_behavior
|
||||||
|
|
||||||
setup_test_config()
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
#########################
|
#########################
|
||||||
|
|
|
@ -1,12 +1,21 @@
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from django.template import Library
|
from django.template import Library
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
from django_components import AlreadyRegistered, Component, ComponentRegistry, NotRegistered, register, registry
|
from django_components import (
|
||||||
|
AlreadyRegistered,
|
||||||
|
Component,
|
||||||
|
ComponentRegistry,
|
||||||
|
NotRegistered,
|
||||||
|
TagProtectedError,
|
||||||
|
register,
|
||||||
|
registry,
|
||||||
|
)
|
||||||
|
|
||||||
from .django_test_setup import setup_test_config
|
from .django_test_setup import setup_test_config
|
||||||
|
|
||||||
setup_test_config()
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
class MockComponent(Component):
|
class MockComponent(Component):
|
||||||
|
@ -69,7 +78,7 @@ class ComponentRegistryTest(unittest.TestCase):
|
||||||
def test_unregisters_only_unused_tags(self):
|
def test_unregisters_only_unused_tags(self):
|
||||||
self.assertDictEqual(self.registry._tags, {})
|
self.assertDictEqual(self.registry._tags, {})
|
||||||
# NOTE: We preserve the default component tags
|
# NOTE: We preserve the default component tags
|
||||||
self.assertIn("component", self.registry.library.tags)
|
self.assertNotIn("component", self.registry.library.tags)
|
||||||
|
|
||||||
# Register two components that use the same tag
|
# Register two components that use the same tag
|
||||||
self.registry.register(name="testcomponent", component=MockComponent)
|
self.registry.register(name="testcomponent", component=MockComponent)
|
||||||
|
@ -78,7 +87,6 @@ class ComponentRegistryTest(unittest.TestCase):
|
||||||
self.assertDictEqual(
|
self.assertDictEqual(
|
||||||
self.registry._tags,
|
self.registry._tags,
|
||||||
{
|
{
|
||||||
"#component": {"testcomponent", "testcomponent2"},
|
|
||||||
"component": {"testcomponent", "testcomponent2"},
|
"component": {"testcomponent", "testcomponent2"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -91,7 +99,6 @@ class ComponentRegistryTest(unittest.TestCase):
|
||||||
self.assertDictEqual(
|
self.assertDictEqual(
|
||||||
self.registry._tags,
|
self.registry._tags,
|
||||||
{
|
{
|
||||||
"#component": {"testcomponent2"},
|
|
||||||
"component": {"testcomponent2"},
|
"component": {"testcomponent2"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -102,7 +109,7 @@ class ComponentRegistryTest(unittest.TestCase):
|
||||||
self.registry.unregister(name="testcomponent2")
|
self.registry.unregister(name="testcomponent2")
|
||||||
|
|
||||||
self.assertDictEqual(self.registry._tags, {})
|
self.assertDictEqual(self.registry._tags, {})
|
||||||
self.assertIn("component", self.registry.library.tags)
|
self.assertNotIn("component", self.registry.library.tags)
|
||||||
|
|
||||||
def test_prevent_registering_different_components_with_the_same_name(self):
|
def test_prevent_registering_different_components_with_the_same_name(self):
|
||||||
self.registry.register(name="testcomponent", component=MockComponent)
|
self.registry.register(name="testcomponent", component=MockComponent)
|
||||||
|
@ -124,3 +131,35 @@ class ComponentRegistryTest(unittest.TestCase):
|
||||||
def test_raises_on_failed_unregister(self):
|
def test_raises_on_failed_unregister(self):
|
||||||
with self.assertRaises(NotRegistered):
|
with self.assertRaises(NotRegistered):
|
||||||
self.registry.unregister(name="testcomponent")
|
self.registry.unregister(name="testcomponent")
|
||||||
|
|
||||||
|
|
||||||
|
class ProtectedTagsTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.registry = ComponentRegistry()
|
||||||
|
|
||||||
|
# NOTE: Use the `component_shorthand_formatter` formatter, so the components
|
||||||
|
# are registered under that tag
|
||||||
|
@override_settings(COMPONENTS={"tag_formatter": "django_components.component_shorthand_formatter"})
|
||||||
|
def test_raises_on_overriding_our_tags(self):
|
||||||
|
for tag in [
|
||||||
|
"component_dependencies",
|
||||||
|
"component_css_dependencies",
|
||||||
|
"component_js_dependencies",
|
||||||
|
"fill",
|
||||||
|
"html_attrs",
|
||||||
|
"provide",
|
||||||
|
"slot",
|
||||||
|
]:
|
||||||
|
with self.assertRaises(TagProtectedError):
|
||||||
|
|
||||||
|
@register(tag)
|
||||||
|
class TestComponent(Component):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@register("sth_else")
|
||||||
|
class TestComponent2(Component):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
registry.unregister("sth_else")
|
||||||
|
|
402
tests/test_tag_formatter.py
Normal file
402
tests/test_tag_formatter.py
Normal file
|
@ -0,0 +1,402 @@
|
||||||
|
from django.template import Context, Template
|
||||||
|
|
||||||
|
from django_components import Component, register, types
|
||||||
|
from django_components.tag_formatter import ShorthandComponentFormatter
|
||||||
|
|
||||||
|
from .django_test_setup import setup_test_config
|
||||||
|
from .testutils import BaseTestCase, parametrize_context_behavior
|
||||||
|
|
||||||
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
|
class MultiwordStartTagFormatter(ShorthandComponentFormatter):
|
||||||
|
def start_tag(self, name):
|
||||||
|
return f"{name} comp"
|
||||||
|
|
||||||
|
|
||||||
|
class MultiwordBlockEndTagFormatter(ShorthandComponentFormatter):
|
||||||
|
def end_tag(self, name):
|
||||||
|
return f"end {name}"
|
||||||
|
|
||||||
|
|
||||||
|
# Create a TagFormatter class to validate the public interface
|
||||||
|
def create_validator_tag_formatter(tag_name: str):
|
||||||
|
class ValidatorTagFormatter(ShorthandComponentFormatter):
|
||||||
|
def start_tag(self, name):
|
||||||
|
assert name == tag_name
|
||||||
|
return super().start_tag(name)
|
||||||
|
|
||||||
|
def end_tag(self, name):
|
||||||
|
assert name == tag_name
|
||||||
|
return super().end_tag(name)
|
||||||
|
|
||||||
|
def parse(self, tokens):
|
||||||
|
assert isinstance(tokens, list)
|
||||||
|
assert tokens[0] == tag_name
|
||||||
|
return super().parse(tokens)
|
||||||
|
|
||||||
|
return ValidatorTagFormatter()
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentTagTests(BaseTestCase):
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_formatter_default_inline(self):
|
||||||
|
@register("simple")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
"""
|
||||||
|
|
||||||
|
template = Template(
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component "simple" / %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rendered = template.render(Context())
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
SLOT_DEFAULT
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_formatter_default_block(self):
|
||||||
|
@register("simple")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
"""
|
||||||
|
|
||||||
|
template = Template(
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component "simple" %}
|
||||||
|
OVERRIDEN!
|
||||||
|
{% endcomponent %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rendered = template.render(Context())
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
OVERRIDEN!
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(
|
||||||
|
cases=["django", "isolated"],
|
||||||
|
settings={
|
||||||
|
"COMPONENTS": {
|
||||||
|
"tag_formatter": "django_components.component_formatter",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_formatter_component_inline(self):
|
||||||
|
@register("simple")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
"""
|
||||||
|
|
||||||
|
template = Template(
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component "simple" / %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rendered = template.render(Context())
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
SLOT_DEFAULT
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(
|
||||||
|
cases=["django", "isolated"],
|
||||||
|
settings={
|
||||||
|
"COMPONENTS": {
|
||||||
|
"tag_formatter": "django_components.component_formatter",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_formatter_component_block(self):
|
||||||
|
@register("simple")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
"""
|
||||||
|
|
||||||
|
template = Template(
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component "simple" %}
|
||||||
|
OVERRIDEN!
|
||||||
|
{% endcomponent %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rendered = template.render(Context())
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
OVERRIDEN!
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(
|
||||||
|
cases=["django", "isolated"],
|
||||||
|
settings={
|
||||||
|
"COMPONENTS": {
|
||||||
|
"tag_formatter": "django_components.component_shorthand_formatter",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_formatter_shorthand_inline(self):
|
||||||
|
@register("simple")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
"""
|
||||||
|
|
||||||
|
template = Template(
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% simple / %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rendered = template.render(Context())
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
SLOT_DEFAULT
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(
|
||||||
|
cases=["django", "isolated"],
|
||||||
|
settings={
|
||||||
|
"COMPONENTS": {
|
||||||
|
"tag_formatter": "django_components.component_shorthand_formatter",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_formatter_shorthand_block(self):
|
||||||
|
@register("simple")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
"""
|
||||||
|
|
||||||
|
template = Template(
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% simple %}
|
||||||
|
OVERRIDEN!
|
||||||
|
{% endsimple %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rendered = template.render(Context())
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
OVERRIDEN!
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(
|
||||||
|
cases=["django", "isolated"],
|
||||||
|
settings={
|
||||||
|
"COMPONENTS": {
|
||||||
|
"tag_formatter": ShorthandComponentFormatter(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_import_formatter_by_value(self):
|
||||||
|
@register("simple")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
<div>
|
||||||
|
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
template = Template(
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% simple %}
|
||||||
|
OVERRIDEN!
|
||||||
|
{% endsimple %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rendered = template.render(Context())
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
<div>
|
||||||
|
OVERRIDEN!
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(
|
||||||
|
cases=["django", "isolated"],
|
||||||
|
settings={
|
||||||
|
"COMPONENTS": {
|
||||||
|
"tag_formatter": MultiwordStartTagFormatter(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_raises_on_invalid_start_tag(self):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
ValueError, "MultiwordStartTagFormatter returned an invalid tag for start_tag: 'simple comp'"
|
||||||
|
):
|
||||||
|
|
||||||
|
@register("simple")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
template = """{% load component_tags %}"""
|
||||||
|
|
||||||
|
@parametrize_context_behavior(
|
||||||
|
cases=["django", "isolated"],
|
||||||
|
settings={
|
||||||
|
"COMPONENTS": {
|
||||||
|
"tag_formatter": MultiwordBlockEndTagFormatter(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_raises_on_invalid_block_end_tag(self):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
ValueError, "MultiwordBlockEndTagFormatter returned an invalid tag for end_tag: 'end simple'"
|
||||||
|
):
|
||||||
|
|
||||||
|
@register("simple")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
<div>
|
||||||
|
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
Template(
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% simple %}
|
||||||
|
OVERRIDEN!
|
||||||
|
{% bar %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(
|
||||||
|
cases=["django", "isolated"],
|
||||||
|
settings={
|
||||||
|
"COMPONENTS": {
|
||||||
|
"tag_formatter": create_validator_tag_formatter("simple"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_method_args(self):
|
||||||
|
@register("simple")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
"""
|
||||||
|
|
||||||
|
template = Template(
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% simple / %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rendered = template.render(Context())
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
SLOT_DEFAULT
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
template = Template(
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% simple %}
|
||||||
|
OVERRIDEN!
|
||||||
|
{% endsimple %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rendered = template.render(Context())
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
hello1
|
||||||
|
<div>
|
||||||
|
OVERRIDEN!
|
||||||
|
</div>
|
||||||
|
hello2
|
||||||
|
""",
|
||||||
|
)
|
|
@ -3,50 +3,49 @@ from django.template.base import Parser
|
||||||
|
|
||||||
from django_components import Component, registry, types
|
from django_components import Component, registry, types
|
||||||
from django_components.component import safe_resolve_dict, safe_resolve_list
|
from django_components.component import safe_resolve_dict, safe_resolve_list
|
||||||
from django_components.template_parser import process_aggregate_kwargs
|
from django_components.template_parser import is_aggregate_key, process_aggregate_kwargs
|
||||||
from django_components.templatetags.component_tags import _parse_component_with_args
|
from django_components.templatetags.component_tags import _parse_tag
|
||||||
|
|
||||||
from .django_test_setup import setup_test_config
|
from .django_test_setup import setup_test_config
|
||||||
from .testutils import BaseTestCase, parametrize_context_behavior
|
from .testutils import BaseTestCase, parametrize_context_behavior
|
||||||
|
|
||||||
setup_test_config()
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
class ParserTest(BaseTestCase):
|
class ParserTest(BaseTestCase):
|
||||||
def test_parses_args_kwargs(self):
|
def test_parses_args_kwargs(self):
|
||||||
bits = ["component", "my_component", "42", "myvar", "key='val'", "key2=val2"]
|
bits = ["component", "42", "myvar", "key='val'", "key2=val2"]
|
||||||
name, raw_args, raw_kwargs = _parse_component_with_args(Parser(""), bits, "component")
|
tag = _parse_tag("component", Parser(""), bits, params=["num", "var"], keywordonly_kwargs=True)
|
||||||
|
|
||||||
ctx = {"myvar": {"a": "b"}, "val2": 1}
|
ctx = {"myvar": {"a": "b"}, "val2": 1}
|
||||||
args = safe_resolve_list(raw_args, ctx)
|
args = safe_resolve_list(tag.args, ctx)
|
||||||
kwargs = safe_resolve_dict(raw_kwargs, ctx)
|
named_args = safe_resolve_dict(tag.named_args, ctx)
|
||||||
|
kwargs = safe_resolve_dict(tag.kwargs, ctx)
|
||||||
|
|
||||||
self.assertEqual(name, "my_component")
|
|
||||||
self.assertListEqual(args, [42, {"a": "b"}])
|
self.assertListEqual(args, [42, {"a": "b"}])
|
||||||
|
self.assertDictEqual(named_args, {"num": 42, "var": {"a": "b"}})
|
||||||
self.assertDictEqual(kwargs, {"key": "val", "key2": 1})
|
self.assertDictEqual(kwargs, {"key": "val", "key2": 1})
|
||||||
|
|
||||||
def test_parses_special_kwargs(self):
|
def test_parses_special_kwargs(self):
|
||||||
bits = [
|
bits = [
|
||||||
"component",
|
"component",
|
||||||
"my_component",
|
|
||||||
"date=date",
|
"date=date",
|
||||||
"@lol=2",
|
"@lol=2",
|
||||||
"na-me=bzz",
|
"na-me=bzz",
|
||||||
"@event:na-me.mod=bzz",
|
"@event:na-me.mod=bzz",
|
||||||
"#my-id=True",
|
"#my-id=True",
|
||||||
]
|
]
|
||||||
name, raw_args, raw_kwargs = _parse_component_with_args(Parser(""), bits, "component")
|
tag = _parse_tag("component", Parser(""), bits, keywordonly_kwargs=True)
|
||||||
|
|
||||||
ctx = Context({"date": 2024, "bzz": "fzz"})
|
ctx = Context({"date": 2024, "bzz": "fzz"})
|
||||||
args = safe_resolve_list(raw_args, ctx)
|
args = safe_resolve_list(tag.args, ctx)
|
||||||
kwargs = safe_resolve_dict(raw_kwargs, ctx)
|
kwargs = safe_resolve_dict(tag.kwargs, ctx)
|
||||||
|
|
||||||
self.assertEqual(name, "my_component")
|
|
||||||
self.assertListEqual(args, [])
|
self.assertListEqual(args, [])
|
||||||
self.assertDictEqual(
|
self.assertDictEqual(
|
||||||
kwargs,
|
kwargs,
|
||||||
{
|
{
|
||||||
"@event:na-me.mod": "fzz",
|
"@event": {"na-me.mod": "fzz"},
|
||||||
"@lol": 2,
|
"@lol": 2,
|
||||||
"date": 2024,
|
"date": 2024,
|
||||||
"na-me": "fzz",
|
"na-me": "fzz",
|
||||||
|
@ -117,3 +116,15 @@ class AggregateKwargsTest(BaseTestCase):
|
||||||
":placeholder": "No text",
|
":placeholder": "No text",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def is_aggregate_key(self):
|
||||||
|
self.assertEqual(is_aggregate_key(""), False)
|
||||||
|
self.assertEqual(is_aggregate_key(" "), False)
|
||||||
|
self.assertEqual(is_aggregate_key(" : "), False)
|
||||||
|
self.assertEqual(is_aggregate_key("attrs"), False)
|
||||||
|
self.assertEqual(is_aggregate_key(":attrs"), False)
|
||||||
|
self.assertEqual(is_aggregate_key(" :attrs "), False)
|
||||||
|
self.assertEqual(is_aggregate_key("attrs:"), False)
|
||||||
|
self.assertEqual(is_aggregate_key(":attrs:"), False)
|
||||||
|
self.assertEqual(is_aggregate_key("at:trs"), True)
|
||||||
|
self.assertEqual(is_aggregate_key(":at:trs"), False)
|
||||||
|
|
|
@ -35,7 +35,9 @@ class SlottedComponentWithContext(Component):
|
||||||
|
|
||||||
class ComponentTemplateTagTest(BaseTestCase):
|
class ComponentTemplateTagTest(BaseTestCase):
|
||||||
class SimpleComponent(Component):
|
class SimpleComponent(Component):
|
||||||
template_name = "simple_template.html"
|
template: types.django_html = """
|
||||||
|
Variable: <strong>{{ variable }}</strong>
|
||||||
|
"""
|
||||||
|
|
||||||
def get_context_data(self, variable, variable2="default"):
|
def get_context_data(self, variable, variable2="default"):
|
||||||
return {
|
return {
|
||||||
|
@ -61,7 +63,20 @@ class ComponentTemplateTagTest(BaseTestCase):
|
||||||
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n")
|
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n")
|
||||||
|
|
||||||
@parametrize_context_behavior(["django", "isolated"])
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
def test_call_with_invalid_name(self):
|
def test_single_component_self_closing(self):
|
||||||
|
registry.register(name="test", component=self.SimpleComponent)
|
||||||
|
|
||||||
|
simple_tag_template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component name="test" variable="variable" /%}
|
||||||
|
"""
|
||||||
|
|
||||||
|
template = Template(simple_tag_template)
|
||||||
|
rendered = template.render(Context({}))
|
||||||
|
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n")
|
||||||
|
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_raises_on_no_registered_components(self):
|
||||||
# Note: No tag registered
|
# Note: No tag registered
|
||||||
|
|
||||||
simple_tag_template: types.django_html = """
|
simple_tag_template: types.django_html = """
|
||||||
|
@ -69,6 +84,18 @@ class ComponentTemplateTagTest(BaseTestCase):
|
||||||
{% component name="test" variable="variable" %}{% endcomponent %}
|
{% component name="test" variable="variable" %}{% endcomponent %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
with self.assertRaisesMessage(TemplateSyntaxError, "Invalid block tag on line 3: 'component'"):
|
||||||
|
Template(simple_tag_template)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_call_with_invalid_name(self):
|
||||||
|
registry.register(name="test_one", component=self.SimpleComponent)
|
||||||
|
|
||||||
|
simple_tag_template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component name="test" variable="variable" %}{% endcomponent %}
|
||||||
|
"""
|
||||||
|
|
||||||
template = Template(simple_tag_template)
|
template = Template(simple_tag_template)
|
||||||
with self.assertRaises(NotRegistered):
|
with self.assertRaises(NotRegistered):
|
||||||
template.render(Context({}))
|
template.render(Context({}))
|
||||||
|
@ -131,7 +158,7 @@ class ComponentTemplateTagTest(BaseTestCase):
|
||||||
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n")
|
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n")
|
||||||
|
|
||||||
@parametrize_context_behavior(["django", "isolated"])
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
def test_component_called_with_variable_as_name(self):
|
def test_raises_on_component_called_with_variable_as_name(self):
|
||||||
registry.register(name="test", component=self.SimpleComponent)
|
registry.register(name="test", component=self.SimpleComponent)
|
||||||
|
|
||||||
simple_tag_template: types.django_html = """
|
simple_tag_template: types.django_html = """
|
||||||
|
@ -141,24 +168,11 @@ class ComponentTemplateTagTest(BaseTestCase):
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template = Template(simple_tag_template)
|
with self.assertRaisesMessage(
|
||||||
rendered = template.render(Context({}))
|
TemplateSyntaxError,
|
||||||
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n")
|
"Component name must be a string 'literal', got: component_name",
|
||||||
|
):
|
||||||
@parametrize_context_behavior(["django", "isolated"])
|
Template(simple_tag_template)
|
||||||
def test_component_called_with_invalid_variable_as_name(self):
|
|
||||||
registry.register(name="test", component=self.SimpleComponent)
|
|
||||||
|
|
||||||
simple_tag_template: types.django_html = """
|
|
||||||
{% load component_tags %}
|
|
||||||
{% with component_name="BLAHONGA" %}
|
|
||||||
{% component component_name variable="variable" %}{% endcomponent %}
|
|
||||||
{% endwith %}
|
|
||||||
"""
|
|
||||||
|
|
||||||
template = Template(simple_tag_template)
|
|
||||||
with self.assertRaises(NotRegistered):
|
|
||||||
template.render(Context({}))
|
|
||||||
|
|
||||||
@parametrize_context_behavior(["django", "isolated"])
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
def test_component_accepts_provided_and_default_parameters(self):
|
def test_component_accepts_provided_and_default_parameters(self):
|
||||||
|
|
|
@ -5,7 +5,7 @@ from django_components import Component, register, types
|
||||||
from .django_test_setup import setup_test_config
|
from .django_test_setup import setup_test_config
|
||||||
from .testutils import BaseTestCase, parametrize_context_behavior
|
from .testutils import BaseTestCase, parametrize_context_behavior
|
||||||
|
|
||||||
setup_test_config()
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
class ProvideTemplateTagTest(BaseTestCase):
|
class ProvideTemplateTagTest(BaseTestCase):
|
||||||
|
@ -38,6 +38,24 @@ class ProvideTemplateTagTest(BaseTestCase):
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_provide_basic_self_closing(self):
|
||||||
|
template_str: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
<div>
|
||||||
|
{% provide "my_provide" key="hi" another=123 / %}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
template = Template(template_str)
|
||||||
|
rendered = template.render(Context({}))
|
||||||
|
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
<div></div>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
@parametrize_context_behavior(["django", "isolated"])
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
def test_provide_access_keys_in_python(self):
|
def test_provide_access_keys_in_python(self):
|
||||||
@register("injectee")
|
@register("injectee")
|
||||||
|
|
|
@ -7,7 +7,7 @@ from django_components import Component, register, registry, types
|
||||||
from .django_test_setup import setup_test_config
|
from .django_test_setup import setup_test_config
|
||||||
from .testutils import BaseTestCase, parametrize_context_behavior
|
from .testutils import BaseTestCase, parametrize_context_behavior
|
||||||
|
|
||||||
setup_test_config()
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
class SlottedComponent(Component):
|
class SlottedComponent(Component):
|
||||||
|
@ -75,6 +75,56 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_slotted_template_basic_self_closing(self):
|
||||||
|
@register("test1")
|
||||||
|
class SlottedComponent(Component):
|
||||||
|
template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
<custom-template>
|
||||||
|
<header>{% slot "header" / %}</header>
|
||||||
|
<main>{% slot "main" %}Default main{% endslot %}</main>
|
||||||
|
<footer>{% slot "footer" / %}</footer>
|
||||||
|
</custom-template>
|
||||||
|
"""
|
||||||
|
|
||||||
|
registry.register(name="test1", component=SlottedComponent)
|
||||||
|
|
||||||
|
@register("test2")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
template = """Variable: <strong>{{ variable }}</strong>"""
|
||||||
|
|
||||||
|
def get_context_data(self, variable, variable2="default"):
|
||||||
|
return {
|
||||||
|
"variable": variable,
|
||||||
|
"variable2": variable2,
|
||||||
|
}
|
||||||
|
|
||||||
|
template_str: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component "test1" %}
|
||||||
|
{% fill "header" %}
|
||||||
|
{% component "test2" variable="variable" / %}
|
||||||
|
{% endfill %}
|
||||||
|
{% fill "main" / %}
|
||||||
|
{% fill "footer" / %}
|
||||||
|
{% endcomponent %}
|
||||||
|
"""
|
||||||
|
template = Template(template_str)
|
||||||
|
rendered = template.render(Context({}))
|
||||||
|
|
||||||
|
# NOTE: <main> is empty, because the fill is provided, even if empty
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
<custom-template>
|
||||||
|
<header> Variable: <strong>variable</strong> </header>
|
||||||
|
<main></main>
|
||||||
|
<footer></footer>
|
||||||
|
</custom-template>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
# NOTE: Second arg is the expected output of `{{ variable }}`
|
# NOTE: Second arg is the expected output of `{{ variable }}`
|
||||||
@parametrize_context_behavior([("django", "test456"), ("isolated", "")])
|
@parametrize_context_behavior([("django", "test456"), ("isolated", "")])
|
||||||
def test_slotted_template_with_context_var(self, context_behavior_data):
|
def test_slotted_template_with_context_var(self, context_behavior_data):
|
||||||
|
|
|
@ -9,7 +9,7 @@ from django_components import Component, registry, types
|
||||||
from .django_test_setup import setup_test_config
|
from .django_test_setup import setup_test_config
|
||||||
from .testutils import BaseTestCase, parametrize_context_behavior
|
from .testutils import BaseTestCase, parametrize_context_behavior
|
||||||
|
|
||||||
setup_test_config()
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
class SlottedComponent(Component):
|
class SlottedComponent(Component):
|
||||||
|
|
20
tests/test_utils.py
Normal file
20
tests/test_utils.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
from django_components.utils import is_str_wrapped_in_quotes
|
||||||
|
|
||||||
|
from .django_test_setup import setup_test_config
|
||||||
|
from .testutils import BaseTestCase
|
||||||
|
|
||||||
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
|
class UtilsTest(BaseTestCase):
|
||||||
|
def test_is_str_wrapped_in_quotes(self):
|
||||||
|
self.assertEqual(is_str_wrapped_in_quotes("word"), False)
|
||||||
|
self.assertEqual(is_str_wrapped_in_quotes('word"'), False)
|
||||||
|
self.assertEqual(is_str_wrapped_in_quotes('"word'), False)
|
||||||
|
self.assertEqual(is_str_wrapped_in_quotes('"word"'), True)
|
||||||
|
self.assertEqual(is_str_wrapped_in_quotes("\"word'"), False)
|
||||||
|
self.assertEqual(is_str_wrapped_in_quotes('"word" '), False)
|
||||||
|
self.assertEqual(is_str_wrapped_in_quotes('"'), False)
|
||||||
|
self.assertEqual(is_str_wrapped_in_quotes(""), False)
|
||||||
|
self.assertEqual(is_str_wrapped_in_quotes('""'), True)
|
||||||
|
self.assertEqual(is_str_wrapped_in_quotes("\"'"), False)
|
Loading…
Add table
Add a link
Reference in a new issue