mirror of
https://github.com/django-components/django-components.git
synced 2025-08-09 16:57:59 +00:00
feat: Scoped slots + Updated docs (#495)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
bdeb9c4e32
commit
b1b66fd751
7 changed files with 804 additions and 254 deletions
518
README.md
518
README.md
|
@ -18,6 +18,30 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
|
|||
|
||||
Read on to learn about the details!
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Release notes](#release-notes)
|
||||
- [Security notes 🚨](#security-notes-)
|
||||
- [Installation](#installation)
|
||||
- [Compatiblity](#compatiblity)
|
||||
- [Create your first component](#create-your-first-component)
|
||||
- [Using single-file components](#using-single-file-components)
|
||||
- [Use the component in a template](#use-the-component-in-a-template)
|
||||
- [Use components as views](#use-components-as-views)
|
||||
- [Registering components](#registering-components)
|
||||
- [Autodiscovery](#autodiscovery)
|
||||
- [Using slots in templates](#using-slots-in-templates)
|
||||
- [Passing data to components](#passing-data-to-components)
|
||||
- [Rendering HTML attributes](#rendering-html-attributes)
|
||||
- [Component context and scope](#component-context-and-scope)
|
||||
- [Rendering JS and CSS dependencies](#rendering-js-and-css-dependencies)
|
||||
- [Available settings](#available-settings)
|
||||
- [Logging and debugging](#logging-and-debugging)
|
||||
- [Management Command](#management-command)
|
||||
- [Community examples](#community-examples)
|
||||
- [Running django-components project locally](#running-django-components-project-locally)
|
||||
- [Development guides](#development-guides)
|
||||
|
||||
## Release notes
|
||||
|
||||
**Version 0.74** introduces `html_attrs` tag and `prefix:key=val` construct for passing dicts to components.
|
||||
|
@ -33,7 +57,7 @@ Read on to learn about the details!
|
|||
|
||||
This change is done to simplify the API in anticipation of a 1.0 release of django_components. After 1.0 we intend to be stricter with big changes like this in point releases.
|
||||
|
||||
**Version 0.34** adds components as views, which allows you to handle requests and render responses from within a component. See the [documentation](#components-as-views) for more details.
|
||||
**Version 0.34** adds components as views, which allows you to handle requests and render responses from within a component. See the [documentation](#use-components-as-views) for more details.
|
||||
|
||||
**Version 0.28** introduces 'implicit' slot filling and the `default` option for `slot` tags.
|
||||
|
||||
|
@ -237,19 +261,43 @@ class Calendar(component.Component):
|
|||
|
||||
And voilá!! We've created our first component.
|
||||
|
||||
## Autodiscovery
|
||||
## Using single-file components
|
||||
|
||||
By default, the Python files in the `components` app are auto-imported in order to auto-register the components (e.g. `components/button/button.py`).
|
||||
Components can also be defined in a single file, which is useful for small components. To do this, you can use the `template`, `js`, and `css` class attributes instead of the `template_name` and `Media`. For example, here's the calendar component from above, defined in a single file:
|
||||
|
||||
Autodiscovery occurs when Django is loaded, during the `ready` hook of the `apps.py` file.
|
||||
```python
|
||||
# In a file called [project root]/components/calendar.py
|
||||
from django_components import component
|
||||
from django_components import types as t
|
||||
|
||||
If you are using autodiscovery, keep a few points in mind:
|
||||
@component.register("calendar")
|
||||
class Calendar(component.Component):
|
||||
def get_context_data(self, date):
|
||||
return {
|
||||
"date": date,
|
||||
}
|
||||
|
||||
- Avoid defining any logic on the module-level inside the `components` dir, that you would not want to run anyway.
|
||||
- Components inside the auto-imported files still need to be registered with `@component.register()`
|
||||
- Auto-imported component files must be valid Python modules, they must use suffix `.py`, and module name should follow [PEP-8](https://peps.python.org/pep-0008/#package-and-module-names).
|
||||
template: t.django_html = """
|
||||
<div class="calendar-component">Today's date is <span>{{ date }}</span></div>
|
||||
"""
|
||||
|
||||
Autodiscovery can be disabled via in the [settings](#disable-autodiscovery).
|
||||
css: t.css = """
|
||||
.calendar-component { width: 200px; background: pink; }
|
||||
.calendar-component span { font-weight: bold; }
|
||||
"""
|
||||
|
||||
js: t.js = """
|
||||
(function(){
|
||||
if (document.querySelector(".calendar-component")) {
|
||||
document.querySelector(".calendar-component").onclick = function(){ alert("Clicked calendar!"); };
|
||||
}
|
||||
})()
|
||||
"""
|
||||
```
|
||||
|
||||
This makes it easy to create small components without having to create a separate template, CSS, and JS file.
|
||||
|
||||
Note that the `t.django_html`, `t.css`, and `t.js` types are used to specify the type of the template, CSS, and JS files, respectively. This is not necessary, but if you're using VSCode with the [Python Inline Source Syntax Highlighting](https://marketplace.visualstudio.com/items?itemName=samwillis.python-inline-source) extension, it will give you syntax highlighting for the template, CSS, and JS.
|
||||
|
||||
## Use the component in a template
|
||||
|
||||
|
@ -296,43 +344,86 @@ The output from the above template will be:
|
|||
|
||||
This makes it possible to organize your front-end around reusable components. Instead of relying on template tags and keeping your CSS and Javascript in the static directory.
|
||||
|
||||
## Using single-file components
|
||||
## Use components as views
|
||||
|
||||
Components can also be defined in a single file, which is useful for small components. To do this, you can use the `template`, `js`, and `css` class attributes instead of the `template_name` and `Media`. For example, here's the calendar component from above, defined in a single file:
|
||||
_New in version 0.34_
|
||||
|
||||
Components can now be used as views. To do this, `Component` subclasses Django's `View` class. This means that you can use all of the [methods](https://docs.djangoproject.com/en/5.0/ref/class-based-views/base/#view) of `View` in your component. For example, you can override `get` and `post` to handle GET and POST requests, respectively.
|
||||
|
||||
In addition, `Component` now has a `render_to_response` method that renders the component template based on the provided context and slots' data and returns an `HttpResponse` object.
|
||||
|
||||
Here's an example of a calendar component defined as a view:
|
||||
|
||||
```python
|
||||
# In a file called [project root]/components/calendar.py
|
||||
from django_components import component
|
||||
from django_components import types as t
|
||||
|
||||
@component.register("calendar")
|
||||
class Calendar(component.Component):
|
||||
def get_context_data(self, date):
|
||||
return {
|
||||
"date": date,
|
||||
|
||||
template = """
|
||||
<div class="calendar-component">
|
||||
<div class="header">
|
||||
{% slot "header" %}{% endslot %}
|
||||
</div>
|
||||
<div class="body">
|
||||
Today's date is <span>{{ date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = {
|
||||
"date": request.GET.get("date", "2020-06-06"),
|
||||
}
|
||||
|
||||
template: t.django_html = """
|
||||
<div class="calendar-component">Today's date is <span>{{ date }}</span></div>
|
||||
"""
|
||||
|
||||
css: t.css = """
|
||||
.calendar-component { width: 200px; background: pink; }
|
||||
.calendar-component span { font-weight: bold; }
|
||||
"""
|
||||
|
||||
js: t.js = """
|
||||
(function(){
|
||||
if (document.querySelector(".calendar-component")) {
|
||||
document.querySelector(".calendar-component").onclick = function(){ alert("Clicked calendar!"); };
|
||||
}
|
||||
})()
|
||||
"""
|
||||
slots = {
|
||||
"header": "Calendar header",
|
||||
}
|
||||
return self.render_to_response(context, slots)
|
||||
```
|
||||
|
||||
This makes it easy to create small components without having to create a separate template, CSS, and JS file.
|
||||
Then, to use this component as a view, you should create a `urls.py` file in your components directory, and add a path to the component's view:
|
||||
|
||||
Note that the `t.django_html`, `t.css`, and `t.js` types are used to specify the type of the template, CSS, and JS files, respectively. This is not necessary, but if you're using VSCode with the [Python Inline Source Syntax Highlighting](https://marketplace.visualstudio.com/items?itemName=samwillis.python-inline-source) extension, it will give you syntax highlighting for the template, CSS, and JS.
|
||||
```python
|
||||
# In a file called [project root]/components/urls.py
|
||||
from django.urls import path
|
||||
from components.calendar.calendar import Calendar
|
||||
|
||||
urlpatterns = [
|
||||
path("calendar/", Calendar.as_view()),
|
||||
]
|
||||
```
|
||||
|
||||
Remember to add `__init__.py` to your components directory, so that Django can find the `urls.py` file.
|
||||
|
||||
Finally, include the component's urls in your project's `urls.py` file:
|
||||
|
||||
```python
|
||||
# In a file called [project root]/urls.py
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("components/", include("components.urls")),
|
||||
]
|
||||
```
|
||||
|
||||
Note: slots content are automatically escaped by default to prevent XSS attacks. To disable escaping, set `escape_slots_content=False` in the `render_to_response` method. If you do so, you should make sure that any content you pass to the slots is safe, especially if it comes from user input.
|
||||
|
||||
If you're planning on passing an HTML string, check Django's use of [`format_html`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.html.format_html) and [`mark_safe`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.safestring.mark_safe).
|
||||
|
||||
## Autodiscovery
|
||||
|
||||
By default, the Python files in the `components` app are auto-imported in order to auto-register the components (e.g. `components/button/button.py`).
|
||||
|
||||
Autodiscovery occurs when Django is loaded, during the `ready` hook of the `apps.py` file.
|
||||
|
||||
If you are using autodiscovery, keep a few points in mind:
|
||||
|
||||
- Avoid defining any logic on the module-level inside the `components` dir, that you would not want to run anyway.
|
||||
- Components inside the auto-imported files still need to be registered with `@component.register()`
|
||||
- Auto-imported component files must be valid Python modules, they must use suffix `.py`, and module name should follow [PEP-8](https://peps.python.org/pep-0008/#package-and-module-names).
|
||||
|
||||
Autodiscovery can be disabled via in the [settings](#disable-autodiscovery).
|
||||
|
||||
## Using slots in templates
|
||||
|
||||
|
@ -348,6 +439,7 @@ _New in version 0.26_:
|
|||
Components support something called 'slots'.
|
||||
When a component is used inside another template, slots allow the parent template to override specific parts of the child component by passing in different content.
|
||||
This mechanism makes components more reusable and composable.
|
||||
This behavior is similar to [slots in Vue](https://vuejs.org/guide/components/slots.html).
|
||||
|
||||
In the example below we introduce two block tags that work hand in hand to make this work. These are...
|
||||
|
||||
|
@ -375,7 +467,7 @@ When using the component, you specify which slots you want to fill and where you
|
|||
{% endcomponent %}
|
||||
```
|
||||
|
||||
Since the header block is unspecified, it's taken from the base template. If you put this in a template, and pass in `date=2020-06-06`, this is what gets rendered:
|
||||
Since the 'header' fill is unspecified, it's taken from the base template. If you put this in a template, and pass in `date=2020-06-06`, this is what gets rendered:
|
||||
|
||||
```htmldjango
|
||||
<div class="calendar-component">
|
||||
|
@ -388,6 +480,10 @@ Since the header block is unspecified, it's taken from the base template. If you
|
|||
</div>
|
||||
```
|
||||
|
||||
### Default slot
|
||||
|
||||
_Added in version 0.28_
|
||||
|
||||
As you can see, component slots lets you write reusable containers that you fill in when you use a component. This makes for highly reusable components that can be used in different circumstances.
|
||||
|
||||
It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`. When added to the end of the tag (as shown below), this option lets you pass filling content directly in the body of a `component` tag pair – without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`.
|
||||
|
@ -460,86 +556,88 @@ This is fine too:
|
|||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Components as views
|
||||
### Render fill in multiple places
|
||||
|
||||
_New in version 0.34_
|
||||
_Added in version 0.70_
|
||||
|
||||
Components can now be used as views. To do this, `Component` subclasses Django's `View` class. This means that you can use all of the [methods](https://docs.djangoproject.com/en/5.0/ref/class-based-views/base/#view) of `View` in your component. For example, you can override `get` and `post` to handle GET and POST requests, respectively.
|
||||
You can render the same content in multiple places by defining multiple slots with
|
||||
identical names:
|
||||
|
||||
In addition, `Component` now has a `render_to_response` method that renders the component template based on the provided context and slots' data and returns an `HttpResponse` object.
|
||||
|
||||
Here's an example of a calendar component defined as a view:
|
||||
|
||||
```python
|
||||
# In a file called [project root]/components/calendar.py
|
||||
from django_components import component
|
||||
|
||||
@component.register("calendar")
|
||||
class Calendar(component.Component):
|
||||
|
||||
template = """
|
||||
<div class="calendar-component">
|
||||
<div class="header">
|
||||
{% slot "header" %}{% endslot %}
|
||||
</div>
|
||||
<div class="body">
|
||||
Today's date is <span>{{ date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = {
|
||||
"date": request.GET.get("date", "2020-06-06"),
|
||||
}
|
||||
slots = {
|
||||
"header": "Calendar header",
|
||||
}
|
||||
return self.render_to_response(context, slots)
|
||||
```htmldjango
|
||||
<div class="calendar-component">
|
||||
<div class="header">
|
||||
{% slot "image" %}Image here{% endslot %}
|
||||
</div>
|
||||
<div class="body">
|
||||
{% slot "image" %}Image here{% endslot %}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Then, to use this component as a view, you should create a `urls.py` file in your components directory, and add a path to the component's view:
|
||||
So if used like:
|
||||
|
||||
```python
|
||||
# In a file called [project root]/components/urls.py
|
||||
from django.urls import path
|
||||
from components.calendar.calendar import Calendar
|
||||
|
||||
urlpatterns = [
|
||||
path("calendar/", Calendar.as_view()),
|
||||
]
|
||||
```htmldjango
|
||||
{% component "calendar" date="2020-06-06" %}
|
||||
{% fill "image" %}
|
||||
<img src="..." />
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
Remember to add `__init__.py` to your components directory, so that Django can find the `urls.py` file.
|
||||
This renders:
|
||||
|
||||
Finally, include the component's urls in your project's `urls.py` file:
|
||||
|
||||
```python
|
||||
# In a file called [project root]/urls.py
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("components/", include("components.urls")),
|
||||
]
|
||||
```htmldjango
|
||||
<div class="calendar-component">
|
||||
<div class="header">
|
||||
<img src="..." />
|
||||
</div>
|
||||
<div class="body">
|
||||
<img src="..." />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Note: slots content are automatically escaped by default to prevent XSS attacks. To disable escaping, set `escape_slots_content=False` in the `render_to_response` method. If you do so, you should make sure that any content you pass to the slots is safe, especially if it comes from user input.
|
||||
#### Default and required slots
|
||||
|
||||
If you're planning on passing an HTML string, check Django's use of [`format_html`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.html.format_html) and [`mark_safe`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.safestring.mark_safe).
|
||||
If you use a slot multiple times, you can still mark the slot as `default` or `required`.
|
||||
For that, you must mark ONLY ONE of the identical slots.
|
||||
|
||||
### Advanced
|
||||
We recommend to mark the first occurence for consistency, e.g.:
|
||||
|
||||
#### Re-using content defined in the original slot
|
||||
```htmldjango
|
||||
<div class="calendar-component">
|
||||
<div class="header">
|
||||
{% slot "image" default required %}Image here{% endslot %}
|
||||
</div>
|
||||
<div class="body">
|
||||
{% slot "image" %}Image here{% endslot %}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Which you can then use are regular default slot:
|
||||
|
||||
```htmldjango
|
||||
{% component "calendar" date="2020-06-06" %}
|
||||
<img src="..." />
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Accessing original content of slots
|
||||
|
||||
_Added in version 0.26_
|
||||
|
||||
Certain properties of a slot can be accessed from within a 'fill' context. They are provided as attributes on a user-defined alias of the targeted slot. For instance, let's say you're filling a slot called 'body'. To access properties of this slot, alias it using the 'as' keyword to a new name -- or keep the original name. With the new slot alias, you can call `<alias>.default` to insert the default content.
|
||||
|
||||
Notice the use of `as "body"` below:
|
||||
|
||||
```htmldjango
|
||||
{% component "calendar" date="2020-06-06" %}
|
||||
{% fill "body" as "body" %}{{ body.default }}. Have a great day!{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
Produces:
|
||||
This produces:
|
||||
|
||||
```htmldjango
|
||||
<div class="calendar-component">
|
||||
|
@ -552,7 +650,7 @@ Produces:
|
|||
</div>
|
||||
```
|
||||
|
||||
#### Conditional slots
|
||||
### Conditional slots
|
||||
|
||||
_Added in version 0.26._
|
||||
|
||||
|
@ -638,7 +736,7 @@ To negate the meaning of `component_vars.is_filled`, simply treat it as boolean
|
|||
{% endif %}
|
||||
```
|
||||
|
||||
**Accessing slot names with special characters**
|
||||
#### Accessing `is_filled` of slot names with special characters
|
||||
|
||||
To be able to access a slot name via `component_vars.is_filled`, the slot name needs to be composed of only alphanumeric characters and underscores (e.g. `this__isvalid_123`).
|
||||
|
||||
|
@ -646,27 +744,103 @@ However, you can still define slots with other special characters. In such case,
|
|||
|
||||
So a slot named `"my super-slot :)"` will be available as `component_vars.is_filled.my_super_slot___`.
|
||||
|
||||
### Setting Up `ComponentDependencyMiddleware`
|
||||
### Scoped slots
|
||||
|
||||
`ComponentDependencyMiddleware` is a Django middleware designed to manage and inject CSS/JS dependencies for rendered components dynamically. It ensures that only the necessary stylesheets and scripts are loaded in your HTML responses, based on the components used in your Django templates.
|
||||
_Added in version 0.76_:
|
||||
|
||||
To set it up, add the middleware to your `MIDDLEWARE` in settings.py:
|
||||
Consider a component with slot(s). This component may do some processing on the inputs, and then use the processed variable in the slot's default template:
|
||||
|
||||
```python
|
||||
MIDDLEWARE = [
|
||||
# ... other middleware classes ...
|
||||
'django_components.middleware.ComponentDependencyMiddleware'
|
||||
# ... other middleware classes ...
|
||||
]
|
||||
```py
|
||||
@component.register("my_comp")
|
||||
class MyComp(component.Component):
|
||||
template = """
|
||||
<div>
|
||||
{% slot "content" default %}
|
||||
input: {{ input }}
|
||||
{% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_context_data(self, input):
|
||||
processed_input = do_something(input)
|
||||
return {"input": processed_input}
|
||||
```
|
||||
|
||||
Then, enable `RENDER_DEPENDENCIES` in setting.py:
|
||||
You may want to design a component so that users of your component can still access the `input` variable, so they don't have to recompute it.
|
||||
|
||||
```python
|
||||
COMPONENTS = {
|
||||
"RENDER_DEPENDENCIES": True,
|
||||
# ... other component settings ...
|
||||
}
|
||||
This behavior is called "scoped slots". This is inspired by [Vue scoped slots](https://vuejs.org/guide/components/slots.html#scoped-slots) and [scoped slots of django-web-components](https://github.com/Xzya/django-web-components/tree/master?tab=readme-ov-file#scoped-slots).
|
||||
|
||||
Using scoped slots consists of two steps:
|
||||
|
||||
1. Passing data to `slot` tag
|
||||
2. Accessing data in `fill` tag
|
||||
|
||||
#### Passing data to slots
|
||||
|
||||
To pass the data to the `slot` tag, simply pass them as keyword attributes (`key=value`):
|
||||
|
||||
```py
|
||||
@component.register("my_comp")
|
||||
class MyComp(component.Component):
|
||||
template = """
|
||||
<div>
|
||||
{% slot "content" default input=input %}
|
||||
input: {{ input }}
|
||||
{% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_context_data(self, input):
|
||||
processed_input = do_something(input)
|
||||
return {
|
||||
"input": processed_input,
|
||||
}
|
||||
```
|
||||
|
||||
#### Accessing slot data in fill
|
||||
|
||||
Next, we head over to where we define a fill for this slot. Here, to access the slot data
|
||||
we set the `data` attribute to the name of the variable through which we want to access
|
||||
the slot data. In the example below, we set it to `data`:
|
||||
|
||||
```django
|
||||
{% component "my_comp" %}
|
||||
{% fill "content" data="data" %}
|
||||
{{ data.input }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
To access slot data on a default slot, you have to explictly define the `{% fill %}` tags.
|
||||
|
||||
So this works:
|
||||
|
||||
```django
|
||||
{% component "my_comp" %}
|
||||
{% fill "content" data="data" %}
|
||||
{{ data.input }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
While this does not:
|
||||
|
||||
```django
|
||||
{% component "my_comp" data="data" %}
|
||||
{{ data.input }}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
Note: You cannot set the `data` attribute and
|
||||
[slot alias (`as var` syntax)](#accessing-original-content-of-slots)
|
||||
to the same name. This raises an error:
|
||||
|
||||
```django
|
||||
{% component "my_comp" %}
|
||||
{% fill "content" data="slot_var" as "slot_var" %}
|
||||
{{ slot_var.input }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
## Passing data to components
|
||||
|
@ -777,7 +951,7 @@ Sweet! Now all the relevant HTML is inside the template, and we can move it to a
|
|||
```
|
||||
|
||||
> Note: It is NOT possible to define nested dictionaries, so
|
||||
`attrs:my_key:two=2` would be interpreted as:
|
||||
> `attrs:my_key:two=2` would be interpreted as:
|
||||
>
|
||||
> ```py
|
||||
> {"attrs": {"my_key:two": 2}}
|
||||
|
@ -907,6 +1081,7 @@ We can achieve this by adding extra kwargs. These values
|
|||
will be appended, instead of overwriting the previous value.
|
||||
|
||||
So if we have a variable `attrs`:
|
||||
|
||||
```py
|
||||
attrs = {
|
||||
"class": "my-class pa-4",
|
||||
|
@ -938,19 +1113,20 @@ To simplify merging of variables, you can supply the same key multiple times, an
|
|||
Renders:
|
||||
|
||||
```html
|
||||
<div data-value="my-class pa-4 some-class another-class class-from-var text-red">
|
||||
</div>
|
||||
<div
|
||||
data-value="my-class pa-4 some-class another-class class-from-var text-red"
|
||||
></div>
|
||||
```
|
||||
|
||||
### Rules for `html_attrs`
|
||||
|
||||
1. Both `attrs` and `defaults` can be passed as positional args
|
||||
|
||||
`{% html_attrs attrs defaults key=val %}`
|
||||
|
||||
or as kwargs
|
||||
|
||||
`{% html_attrs key=val defaults=defaults attrs=attrs %}`
|
||||
|
||||
`{% html_attrs attrs defaults key=val %}`
|
||||
|
||||
or as kwargs
|
||||
|
||||
`{% html_attrs key=val defaults=defaults attrs=attrs %}`
|
||||
|
||||
2. Both `attrs` and `defaults` are optional (can be omitted)
|
||||
|
||||
|
@ -979,64 +1155,64 @@ defaults = {
|
|||
Then:
|
||||
|
||||
- Empty tag <br/>
|
||||
`{% html_attr %}`
|
||||
`{% html_attr %}`
|
||||
|
||||
renders (empty string): <br/>
|
||||
` `
|
||||
renders (empty string): <br/>
|
||||
` `
|
||||
|
||||
- Only kwargs <br/>
|
||||
`{% html_attr class="some-class" class=class_from_var data-id="123" %}`
|
||||
`{% html_attr class="some-class" class=class_from_var data-id="123" %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="some-class from-var" data-id="123"`
|
||||
renders: <br/>
|
||||
`class="some-class from-var" data-id="123"`
|
||||
|
||||
- Only attrs <br/>
|
||||
`{% html_attr attrs %}`
|
||||
`{% html_attr attrs %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-attrs" type="submit"`
|
||||
renders: <br/>
|
||||
`class="from-attrs" type="submit"`
|
||||
|
||||
- Attrs as kwarg <br/>
|
||||
`{% html_attr attrs=attrs %}`
|
||||
`{% html_attr attrs=attrs %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-attrs" type="submit"`
|
||||
renders: <br/>
|
||||
`class="from-attrs" type="submit"`
|
||||
|
||||
- Only defaults (as kwarg) <br/>
|
||||
`{% html_attr defaults=defaults %}`
|
||||
`{% html_attr defaults=defaults %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-defaults" role="button"`
|
||||
renders: <br/>
|
||||
`class="from-defaults" role="button"`
|
||||
|
||||
- Attrs using the `prefix:key=value` construct <br/>
|
||||
`{% html_attr attrs:class="from-attrs" attrs:type="submit" %}`
|
||||
`{% html_attr attrs:class="from-attrs" attrs:type="submit" %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-attrs" type="submit"`
|
||||
renders: <br/>
|
||||
`class="from-attrs" type="submit"`
|
||||
|
||||
- Defaults using the `prefix:key=value` construct <br/>
|
||||
`{% html_attr defaults:class="from-defaults" %}`
|
||||
`{% html_attr defaults:class="from-defaults" %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-defaults" role="button"`
|
||||
renders: <br/>
|
||||
`class="from-defaults" role="button"`
|
||||
|
||||
- All together (1) - attrs and defaults as positional args: <br/>
|
||||
`{% html_attrs attrs defaults class="added_class" class=class_from_var data-id=123 %}`
|
||||
`{% html_attrs attrs defaults class="added_class" class=class_from_var data-id=123 %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-attrs added_class from-var" type="submit" role="button" data-id=123`
|
||||
renders: <br/>
|
||||
`class="from-attrs added_class from-var" type="submit" role="button" data-id=123`
|
||||
|
||||
- All together (2) - attrs and defaults as kwargs args: <br/>
|
||||
`{% html_attrs class="added_class" class=class_from_var data-id=123 attrs=attrs defaults=defaults %}`
|
||||
`{% html_attrs class="added_class" class=class_from_var data-id=123 attrs=attrs defaults=defaults %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-attrs added_class from-var" type="submit" role="button" data-id=123`
|
||||
renders: <br/>
|
||||
`class="from-attrs added_class from-var" type="submit" role="button" data-id=123`
|
||||
|
||||
- All together (3) - mixed: <br/>
|
||||
`{% html_attrs attrs defaults:class="default-class" class="added_class" class=class_from_var data-id=123 %}`
|
||||
`{% html_attrs attrs defaults:class="default-class" class="added_class" class=class_from_var data-id=123 %}`
|
||||
|
||||
renders: <br/>
|
||||
`class="from-attrs added_class from-var" type="submit" data-id=123`
|
||||
renders: <br/>
|
||||
`class="from-attrs added_class from-var" type="submit" data-id=123`
|
||||
|
||||
### Full example for `html_attrs`
|
||||
|
||||
|
@ -1132,14 +1308,16 @@ attrs = {
|
|||
will be merged.
|
||||
|
||||
So in the end `MyComp` will render:
|
||||
|
||||
```html
|
||||
<div
|
||||
class="pa-0 border-solid my-comp-date extra-class"
|
||||
data-id="123"
|
||||
data-json='{"value": 456}'
|
||||
@click="(e) => onClick(e, 'from_parent')"
|
||||
class="pa-0 border-solid my-comp-date extra-class"
|
||||
data-id="123"
|
||||
data-json='{"value": 456}'
|
||||
@click="(e) => onClick(e, 'from_parent')"
|
||||
>
|
||||
...
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
### Rendering HTML attributes outside of templates
|
||||
|
@ -1175,6 +1353,40 @@ NOTE: `{% csrf_token %}` tags need access to the top-level context, and they wil
|
|||
|
||||
Components can also access the outer context in their context methods by accessing the property `outer_context`.
|
||||
|
||||
## Rendering JS and CSS dependencies
|
||||
|
||||
The JS and CSS files included in components are not automatically rendered.
|
||||
Instead, use the following tags to specify where to render the dependencies:
|
||||
- `component_dependencies` - Renders both JS and CSS
|
||||
- `component_js_dependencies` - Renders only JS
|
||||
- `component_css_dependencies` - Reneders only CSS
|
||||
|
||||
JS files are rendered as `<script>` tags.<br/>
|
||||
CSS files are rendered as `<style>` tags.
|
||||
|
||||
### Setting Up `ComponentDependencyMiddleware`
|
||||
|
||||
`ComponentDependencyMiddleware` is a Django middleware designed to manage and inject CSS/JS dependencies for rendered components dynamically. It ensures that only the necessary stylesheets and scripts are loaded in your HTML responses, based on the components used in your Django templates.
|
||||
|
||||
To set it up, add the middleware to your `MIDDLEWARE` in settings.py:
|
||||
|
||||
```python
|
||||
MIDDLEWARE = [
|
||||
# ... other middleware classes ...
|
||||
'django_components.middleware.ComponentDependencyMiddleware'
|
||||
# ... other middleware classes ...
|
||||
]
|
||||
```
|
||||
|
||||
Then, enable `RENDER_DEPENDENCIES` in setting.py:
|
||||
|
||||
```python
|
||||
COMPONENTS = {
|
||||
"RENDER_DEPENDENCIES": True,
|
||||
# ... other component settings ...
|
||||
}
|
||||
```
|
||||
|
||||
## Available settings
|
||||
|
||||
All library settings are handled from a global `COMPONENTS` variable that is read from `settings.py`. By default you don't need it set, there are resonable defaults.
|
||||
|
|
|
@ -31,6 +31,7 @@ from django_components.context import (
|
|||
make_isolated_context_copy,
|
||||
prepare_context,
|
||||
)
|
||||
from django_components.expression import safe_resolve_dict, safe_resolve_list
|
||||
from django_components.logger import logger, trace_msg
|
||||
from django_components.middleware import is_dependency_middleware_active
|
||||
from django_components.slots import DEFAULT_SLOT_KEY, FillContent, FillNode, SlotName, resolve_slots
|
||||
|
@ -358,6 +359,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
|||
slot_name: FillContent(
|
||||
nodes=NodeList([TextNode(escape(content) if escape_content else content)]),
|
||||
alias=None,
|
||||
scope=None,
|
||||
)
|
||||
for (slot_name, content) in slots_data.items()
|
||||
}
|
||||
|
@ -405,7 +407,9 @@ class ComponentNode(Node):
|
|||
|
||||
is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
|
||||
if is_default_slot:
|
||||
fill_content: Dict[str, FillContent] = {DEFAULT_SLOT_KEY: FillContent(self.fill_nodes[0].nodelist, None)}
|
||||
fill_content: Dict[str, FillContent] = {
|
||||
DEFAULT_SLOT_KEY: FillContent(self.fill_nodes[0].nodelist, None, None),
|
||||
}
|
||||
else:
|
||||
fill_content = {}
|
||||
for fill_node in self.fill_nodes:
|
||||
|
@ -419,7 +423,12 @@ class ComponentNode(Node):
|
|||
)
|
||||
|
||||
resolved_fill_alias = fill_node.resolve_alias(context, resolved_component_name)
|
||||
fill_content[resolved_name] = FillContent(fill_node.nodelist, resolved_fill_alias)
|
||||
resolved_scope_var = fill_node.resolve_scope(context, resolved_component_name)
|
||||
fill_content[resolved_name] = FillContent(
|
||||
nodes=fill_node.nodelist,
|
||||
alias=resolved_fill_alias,
|
||||
scope=resolved_scope_var,
|
||||
)
|
||||
|
||||
component: Component = component_cls(
|
||||
registered_name=resolved_component_name,
|
||||
|
@ -436,20 +445,3 @@ class ComponentNode(Node):
|
|||
|
||||
trace_msg("RENDR", "COMP", self.name_fexp, self.component_id, "...Done!")
|
||||
return output
|
||||
|
||||
|
||||
def safe_resolve_list(args: List[FilterExpression], context: Context) -> List:
|
||||
return [safe_resolve(arg, context) for arg in args]
|
||||
|
||||
|
||||
def safe_resolve_dict(
|
||||
kwargs: Union[Mapping[str, FilterExpression], Dict[str, FilterExpression]],
|
||||
context: Context,
|
||||
) -> Dict:
|
||||
return {key: safe_resolve(kwarg, context) for key, kwarg in kwargs.items()}
|
||||
|
||||
|
||||
def safe_resolve(context_item: FilterExpression, context: Context) -> Any:
|
||||
"""Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
|
||||
|
||||
return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item
|
||||
|
|
46
src/django_components/expression.py
Normal file
46
src/django_components/expression.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
from typing import Any, Dict, List, Mapping, Optional, Union
|
||||
|
||||
from django.template import Context
|
||||
from django.template.base import FilterExpression, Parser
|
||||
|
||||
|
||||
def resolve_expression_as_identifier(
|
||||
context: Context,
|
||||
fexp: FilterExpression,
|
||||
) -> str:
|
||||
resolved = fexp.resolve(context)
|
||||
if not isinstance(resolved, str):
|
||||
raise ValueError(
|
||||
f"FilterExpression '{fexp}' was expected to resolve to string, instead got '{type(resolved)}'"
|
||||
)
|
||||
if not resolved.isidentifier():
|
||||
raise ValueError(
|
||||
f"FilterExpression '{fexp}' was expected to resolve to valid identifier, instead got '{resolved}'"
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def safe_resolve_list(args: List[FilterExpression], context: Context) -> List:
|
||||
return [safe_resolve(arg, context) for arg in args]
|
||||
|
||||
|
||||
def safe_resolve_dict(
|
||||
kwargs: Union[Mapping[str, FilterExpression], Dict[str, FilterExpression]],
|
||||
context: Context,
|
||||
) -> Dict:
|
||||
return {key: safe_resolve(kwarg, context) for key, kwarg in kwargs.items()}
|
||||
|
||||
|
||||
def safe_resolve(context_item: FilterExpression, context: Context) -> Any:
|
||||
"""Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
|
||||
return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item
|
||||
|
||||
|
||||
def resolve_string(
|
||||
s: str,
|
||||
parser: Optional[Parser] = None,
|
||||
context: Optional[Mapping[str, Any]] = None,
|
||||
) -> str:
|
||||
parser = parser or Parser([])
|
||||
context = context or {}
|
||||
return parser.compile_filter(s).resolve(context)
|
|
@ -12,8 +12,10 @@ from django.utils.safestring import SafeString, mark_safe
|
|||
|
||||
from django_components.app_settings import ContextBehavior, app_settings
|
||||
from django_components.context import _FILLED_SLOTS_CONTENT_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY
|
||||
from django_components.expression import resolve_expression_as_identifier, safe_resolve_dict
|
||||
from django_components.logger import trace_msg
|
||||
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
|
||||
|
||||
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
|
||||
|
@ -23,6 +25,7 @@ DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
|
|||
SlotId = str
|
||||
SlotName = str
|
||||
AliasName = str
|
||||
ScopeName = str
|
||||
|
||||
|
||||
class FillContent(NamedTuple):
|
||||
|
@ -42,6 +45,7 @@ class FillContent(NamedTuple):
|
|||
|
||||
nodes: NodeList
|
||||
alias: Optional[AliasName]
|
||||
scope: Optional[ScopeName]
|
||||
|
||||
|
||||
class Slot(NamedTuple):
|
||||
|
@ -77,6 +81,7 @@ class SlotFill(NamedTuple):
|
|||
nodelist: NodeList
|
||||
context_data: Dict
|
||||
alias: Optional[AliasName]
|
||||
scope: Optional[ScopeName]
|
||||
|
||||
|
||||
class UserSlotVar:
|
||||
|
@ -107,12 +112,14 @@ class SlotNode(Node):
|
|||
is_required: bool = False,
|
||||
is_default: bool = False,
|
||||
node_id: Optional[str] = None,
|
||||
slot_kwargs: Optional[Dict[str, FilterExpression]] = None,
|
||||
):
|
||||
self.name = name
|
||||
self.nodelist = nodelist
|
||||
self.is_required = is_required
|
||||
self.is_default = is_default
|
||||
self.node_id = node_id or gen_id()
|
||||
self.slot_kwargs = slot_kwargs or {}
|
||||
|
||||
@property
|
||||
def active_flags(self) -> List[str]:
|
||||
|
@ -132,14 +139,23 @@ class SlotNode(Node):
|
|||
# NOTE: Slot entry MUST be present. If it's missing, there was an issue upstream.
|
||||
slot_fill = slots[self.node_id]
|
||||
|
||||
extra_context: Dict[str, Any] = {}
|
||||
|
||||
# If slot is using alias `{% slot "myslot" as "abc" %}`, then set the "abc" to
|
||||
# the context, so users can refer to the slot from within the slot.
|
||||
extra_context = {}
|
||||
if slot_fill.alias:
|
||||
if not slot_fill.alias.isidentifier():
|
||||
raise TemplateSyntaxError(f"Invalid fill alias. Must be a valid identifier. Got '{slot_fill.alias}'")
|
||||
extra_context[slot_fill.alias] = UserSlotVar(self, context)
|
||||
|
||||
# Expose the kwargs that were passed to the `{% slot %}` tag. These kwargs
|
||||
# are made available through a variable name that was set on the `{% fill %}`
|
||||
# tag.
|
||||
if slot_fill.scope:
|
||||
slot_kwargs = safe_resolve_dict(self.slot_kwargs, context)
|
||||
slot_kwargs = process_aggregate_kwargs(slot_kwargs)
|
||||
extra_context[slot_fill.scope] = slot_kwargs
|
||||
|
||||
# For the user-provided slot fill, we want to use the context of where the slot
|
||||
# came from (or current context if configured so)
|
||||
used_ctx = self._resolve_slot_context(context, slot_fill)
|
||||
|
@ -177,6 +193,7 @@ class FillNode(Node):
|
|||
nodelist: NodeList,
|
||||
name_fexp: FilterExpression,
|
||||
alias_fexp: Optional[FilterExpression] = None,
|
||||
scope_fexp: Optional[FilterExpression] = None,
|
||||
is_implicit: bool = False,
|
||||
node_id: Optional[str] = None,
|
||||
):
|
||||
|
@ -185,6 +202,7 @@ class FillNode(Node):
|
|||
self.name_fexp = name_fexp
|
||||
self.alias_fexp = alias_fexp
|
||||
self.is_implicit = is_implicit
|
||||
self.scope_fexp = scope_fexp
|
||||
self.component_id: Optional[str] = None
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
|
@ -198,16 +216,29 @@ class FillNode(Node):
|
|||
return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>"
|
||||
|
||||
def resolve_alias(self, context: Context, component_name: Optional[str] = None) -> Optional[str]:
|
||||
if not self.alias_fexp:
|
||||
return self.resolve_fexp("alias", self.alias_fexp, context, component_name)
|
||||
|
||||
def resolve_scope(self, context: Context, component_name: Optional[str] = None) -> Optional[str]:
|
||||
return self.resolve_fexp("scope", self.scope_fexp, context, component_name)
|
||||
|
||||
def resolve_fexp(
|
||||
self,
|
||||
name: str,
|
||||
fexp: Optional[FilterExpression],
|
||||
context: Context,
|
||||
component_name: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
if not fexp:
|
||||
return None
|
||||
|
||||
resolved_alias: Optional[str] = self.alias_fexp.resolve(context)
|
||||
if resolved_alias and not resolved_alias.isidentifier():
|
||||
try:
|
||||
resolved_alias = resolve_expression_as_identifier(context, fexp)
|
||||
except ValueError as err:
|
||||
raise TemplateSyntaxError(
|
||||
f"Fill tag alias '{self.alias_fexp.var}' in component "
|
||||
f"{component_name} does not resolve to "
|
||||
f"a valid Python identifier. Got: '{resolved_alias}'."
|
||||
)
|
||||
f"Fill tag {name} '{fexp.var}' in component {component_name}"
|
||||
f"does not resolve to a valid Python identifier."
|
||||
) from err
|
||||
|
||||
return resolved_alias
|
||||
|
||||
|
||||
|
@ -333,6 +364,7 @@ def resolve_slots(
|
|||
nodelist=fill.nodes,
|
||||
context_data=context_data,
|
||||
alias=fill.alias,
|
||||
scope=fill.scope,
|
||||
)
|
||||
for name, fill in fill_content.items()
|
||||
}
|
||||
|
@ -421,6 +453,7 @@ def resolve_slots(
|
|||
nodelist=slot.nodelist,
|
||||
context_data=context_data,
|
||||
alias=None,
|
||||
scope=None,
|
||||
)
|
||||
# Since the slot's default CAN include other slots (because it's defined in
|
||||
# the same template), we need to enqueue the slot's children
|
||||
|
@ -468,6 +501,7 @@ def _resolve_default_slot(
|
|||
nodelist=default_fill.nodelist,
|
||||
context_data=default_fill.context_data,
|
||||
alias=default_fill.alias,
|
||||
scope=default_fill.scope,
|
||||
# Updated fields
|
||||
name=slot.name,
|
||||
escaped_name=_escape_slot_name(slot.name),
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from typing import TYPE_CHECKING, Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
import django.template
|
||||
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode, Token, TokenType
|
||||
from django.template.base import FilterExpression, NodeList, Parser, Token
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
|
@ -10,6 +10,7 @@ from django_components.attributes import HtmlAttrsNode
|
|||
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
|
||||
from django_components.component_registry import ComponentRegistry
|
||||
from django_components.component_registry import registry as component_registry
|
||||
from django_components.expression import resolve_string
|
||||
from django_components.logger import trace_msg
|
||||
from django_components.middleware import (
|
||||
CSS_DEPENDENCY_PLACEHOLDER,
|
||||
|
@ -29,6 +30,7 @@ register = django.template.Library()
|
|||
|
||||
SLOT_REQUIRED_OPTION_KEYWORD = "required"
|
||||
SLOT_DEFAULT_OPTION_KEYWORD = "default"
|
||||
SLOT_DATA_ATTR = "data"
|
||||
|
||||
|
||||
def get_components_from_registry(registry: ComponentRegistry) -> List["Component"]:
|
||||
|
@ -110,36 +112,9 @@ def component_js_dependencies_tag(preload: str = "") -> SafeString:
|
|||
|
||||
@register.tag("slot")
|
||||
def do_slot(parser: Parser, token: Token) -> SlotNode:
|
||||
bits = token.split_contents()
|
||||
args = bits[1:]
|
||||
# e.g. {% slot <name> %}
|
||||
is_required = False
|
||||
is_default = False
|
||||
if 1 <= len(args) <= 3:
|
||||
slot_name, *options = args
|
||||
if not is_wrapped_in_quotes(slot_name):
|
||||
raise TemplateSyntaxError(f"'{bits[0]}' name must be a string 'literal'.")
|
||||
slot_name = strip_quotes(slot_name)
|
||||
modifiers_count = len(options)
|
||||
if SLOT_REQUIRED_OPTION_KEYWORD in options:
|
||||
is_required = True
|
||||
modifiers_count -= 1
|
||||
if SLOT_DEFAULT_OPTION_KEYWORD in options:
|
||||
is_default = True
|
||||
modifiers_count -= 1
|
||||
if modifiers_count != 0:
|
||||
keywords = [
|
||||
SLOT_REQUIRED_OPTION_KEYWORD,
|
||||
SLOT_DEFAULT_OPTION_KEYWORD,
|
||||
]
|
||||
raise TemplateSyntaxError(f"Invalid options passed to 'slot' tag. Valid choices: {keywords}.")
|
||||
else:
|
||||
raise TemplateSyntaxError(
|
||||
"'slot' tag does not match pattern "
|
||||
"{% slot <name> ['default'] ['required'] %}. "
|
||||
"Order of options is free."
|
||||
)
|
||||
|
||||
# e.g. {% slot <name> ... %}
|
||||
tag_name, *args = token.split_contents()
|
||||
slot_name, is_default, is_required, slot_kwargs = _parse_slot_args(parser, args, tag_name)
|
||||
# Use a unique ID to be able to tie the fill nodes with components and slots
|
||||
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
|
||||
slot_id = gen_id()
|
||||
|
@ -153,6 +128,7 @@ def do_slot(parser: Parser, token: Token) -> SlotNode:
|
|||
is_required=is_required,
|
||||
is_default=is_default,
|
||||
node_id=slot_id,
|
||||
slot_kwargs=slot_kwargs,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", "SLOT", slot_name, slot_id, "...Done!")
|
||||
|
@ -161,45 +137,35 @@ def do_slot(parser: Parser, token: Token) -> SlotNode:
|
|||
|
||||
@register.tag("fill")
|
||||
def do_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.
|
||||
It exists to make component nesting easier.
|
||||
|
||||
This tag is available only within a {% component %}..{% endcomponent %} block.
|
||||
Runtime checks should prohibit other usages.
|
||||
"""
|
||||
bits = token.split_contents()
|
||||
tag = bits[0]
|
||||
args = bits[1:]
|
||||
# e.g. {% fill <name> %}
|
||||
alias_fexp: Optional[FilterExpression] = None
|
||||
if len(args) == 1:
|
||||
tgt_slot_name: str = args[0]
|
||||
# e.g. {% fill <name> as <alias> %}
|
||||
elif len(args) == 3:
|
||||
tgt_slot_name, as_keyword, alias = args
|
||||
if as_keyword.lower() != "as":
|
||||
raise TemplateSyntaxError(f"{tag} tag args do not conform to pattern '<target slot> as <alias>'")
|
||||
alias_fexp = FilterExpression(alias, parser)
|
||||
else:
|
||||
raise TemplateSyntaxError(f"'{tag}' tag takes either 1 or 3 arguments: Received {len(args)}.")
|
||||
tag_name, *args = token.split_contents()
|
||||
slot_name_fexp, alias_fexp, scope_var_fexp = _parse_fill_args(parser, args, tag_name)
|
||||
|
||||
# Use a unique ID to be able to tie the fill nodes with components and slots
|
||||
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
|
||||
fill_id = gen_id()
|
||||
trace_msg("PARSE", "FILL", tgt_slot_name, fill_id)
|
||||
trace_msg("PARSE", "FILL", str(slot_name_fexp), fill_id)
|
||||
|
||||
nodelist = parser.parse(parse_until=["endfill"])
|
||||
parser.delete_first_token()
|
||||
|
||||
fill_node = FillNode(
|
||||
nodelist,
|
||||
name_fexp=FilterExpression(tgt_slot_name, tag),
|
||||
name_fexp=slot_name_fexp,
|
||||
alias_fexp=alias_fexp,
|
||||
scope_fexp=scope_var_fexp,
|
||||
node_id=fill_id,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", "FILL", tgt_slot_name, fill_id, "...Done!")
|
||||
trace_msg("PARSE", "FILL", str(slot_name_fexp), fill_id, "...Done!")
|
||||
return fill_node
|
||||
|
||||
|
||||
|
@ -219,8 +185,8 @@ def do_component(parser: Parser, token: Token) -> ComponentNode:
|
|||
"""
|
||||
|
||||
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")
|
||||
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
|
||||
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
|
||||
|
@ -278,23 +244,11 @@ def do_html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
|
|||
```
|
||||
"""
|
||||
bits = token.split_contents()
|
||||
attributes, default_attrs, append_attrs = parse_html_attrs_args(parser, bits, "html_attrs")
|
||||
attributes, default_attrs, append_attrs = _parse_html_attrs_args(parser, bits, "html_attrs")
|
||||
return HtmlAttrsNode(attributes, default_attrs, append_attrs)
|
||||
|
||||
|
||||
def is_whitespace_node(node: Node) -> bool:
|
||||
return isinstance(node, TextNode) and node.s.isspace()
|
||||
|
||||
|
||||
def is_whitespace_token(token: Token) -> bool:
|
||||
return token.token_type == TokenType.TEXT and not token.contents.strip()
|
||||
|
||||
|
||||
def is_block_tag_token(token: Token, name: str) -> bool:
|
||||
return token.token_type == TokenType.BLOCK and token.split_contents()[0] == name
|
||||
|
||||
|
||||
def check_for_isolated_context_keyword(bits: List[str]) -> Tuple[List[str], bool]:
|
||||
def _check_for_isolated_context_keyword(bits: List[str]) -> Tuple[List[str], bool]:
|
||||
"""Return True and strip the last word if token ends with 'only' keyword or if CONTEXT_BEHAVIOR is 'isolated'."""
|
||||
|
||||
if bits[-1] == "only":
|
||||
|
@ -306,7 +260,7 @@ def check_for_isolated_context_keyword(bits: List[str]) -> Tuple[List[str], bool
|
|||
return bits, False
|
||||
|
||||
|
||||
def parse_component_with_args(
|
||||
def _parse_component_with_args(
|
||||
parser: Parser, bits: List[str], tag_name: str
|
||||
) -> Tuple[str, List[FilterExpression], Mapping[str, FilterExpression]]:
|
||||
tag_args, tag_kwarg_pairs = parse_bits(
|
||||
|
@ -336,7 +290,7 @@ def parse_component_with_args(
|
|||
return component_name, context_args, tag_kwargs
|
||||
|
||||
|
||||
def parse_html_attrs_args(
|
||||
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(
|
||||
|
@ -372,6 +326,111 @@ def parse_html_attrs_args(
|
|||
return attrs, defaults, tag_kwarg_pairs
|
||||
|
||||
|
||||
def _parse_slot_args(
|
||||
parser: Parser,
|
||||
bits: List[str],
|
||||
tag_name: str,
|
||||
) -> Tuple[str, bool, bool, Dict[str, FilterExpression]]:
|
||||
if not len(bits):
|
||||
raise TemplateSyntaxError(
|
||||
"'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)
|
||||
|
||||
# Parse flags - Since `parse_bits` doesn't handle "shorthand" kwargs
|
||||
# (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
|
||||
_, 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 slot_name, is_default, is_required, tag_kwargs
|
||||
|
||||
|
||||
def _parse_fill_args(
|
||||
parser: Parser,
|
||||
bits: List[str],
|
||||
tag_name: str,
|
||||
) -> Tuple[FilterExpression, Optional[FilterExpression], Optional[FilterExpression]]:
|
||||
if not len(bits):
|
||||
raise TemplateSyntaxError(
|
||||
"'fill' tag does not match pattern " f"{{% fill <name> [{SLOT_DATA_ATTR}=val] [as alias] %}}. "
|
||||
)
|
||||
|
||||
slot_name = bits.pop(0)
|
||||
slot_name_fexp = parser.compile_filter(slot_name)
|
||||
|
||||
alias_fexp: Optional[FilterExpression] = None
|
||||
# e.g. {% fill <name> as <alias> %}
|
||||
if len(bits) >= 2 and bits[-2].lower() == "as":
|
||||
alias = bits.pop()
|
||||
bits.pop() # Remove the "as" keyword
|
||||
alias_fexp = parser.compile_filter(alias)
|
||||
|
||||
# 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
|
||||
|
||||
scope_var_fexp: Optional[FilterExpression] = None
|
||||
if SLOT_DATA_ATTR in tag_kwargs:
|
||||
scope_var_fexp = tag_kwargs.pop(SLOT_DATA_ATTR)
|
||||
if not is_wrapped_in_quotes(scope_var_fexp.token):
|
||||
raise TemplateSyntaxError(
|
||||
f"Value of '{SLOT_DATA_ATTR}' in '{tag_name}' tag must be a string literal, got '{scope_var_fexp}'"
|
||||
)
|
||||
|
||||
if scope_var_fexp and alias_fexp and scope_var_fexp.token == alias_fexp.token:
|
||||
raise TemplateSyntaxError(
|
||||
f"'{tag_name}' received the same string for slot alias (as ...) and slot data ({SLOT_DATA_ATTR}=...)"
|
||||
)
|
||||
|
||||
if len(tag_kwargs):
|
||||
extra_keywords = tag_kwargs.keys()
|
||||
extra_keys = ", ".join(extra_keywords)
|
||||
raise TemplateSyntaxError(f"'{tag_name}' received unexpected kwargs: {extra_keys}")
|
||||
|
||||
return slot_name_fexp, alias_fexp, scope_var_fexp
|
||||
|
||||
|
||||
def _get_positional_param(
|
||||
tag_name: str,
|
||||
param_name: str,
|
||||
|
@ -393,7 +452,3 @@ def _get_positional_param(
|
|||
|
||||
def is_wrapped_in_quotes(s: str) -> bool:
|
||||
return s.startswith(('"', "'")) and s[0] == s[-1]
|
||||
|
||||
|
||||
def strip_quotes(s: str) -> str:
|
||||
return s.strip("\"'")
|
||||
|
|
|
@ -9,13 +9,13 @@ from .testutils import BaseTestCase
|
|||
|
||||
from django_components import component, types
|
||||
from django_components.component import safe_resolve_dict, safe_resolve_list
|
||||
from django_components.templatetags.component_tags import parse_component_with_args
|
||||
from django_components.templatetags.component_tags import _parse_component_with_args
|
||||
|
||||
|
||||
class ParserTest(BaseTestCase):
|
||||
def test_parses_args_kwargs(self):
|
||||
bits = ["component", "my_component", "42", "myvar", "key='val'", "key2=val2"]
|
||||
name, raw_args, raw_kwargs = parse_component_with_args(Parser(""), bits, "component")
|
||||
name, raw_args, raw_kwargs = _parse_component_with_args(Parser(""), bits, "component")
|
||||
|
||||
ctx = {"myvar": {"a": "b"}, "val2": 1}
|
||||
args = safe_resolve_list(raw_args, ctx)
|
||||
|
@ -35,7 +35,7 @@ class ParserTest(BaseTestCase):
|
|||
"@event:na-me.mod=bzz",
|
||||
"#my-id=True",
|
||||
]
|
||||
name, raw_args, raw_kwargs = parse_component_with_args(Parser(""), bits, "component")
|
||||
name, raw_args, raw_kwargs = _parse_component_with_args(Parser(""), bits, "component")
|
||||
|
||||
ctx = Context({"date": 2024, "bzz": "fzz"})
|
||||
args = safe_resolve_list(raw_args, ctx)
|
||||
|
|
|
@ -2392,3 +2392,214 @@ class IterationFillTest(BaseTestCase):
|
|||
{'inner': ['ITER2_OBJ1', 'ITER2_OBJ2']} default
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
class ScopedSlotTest(BaseTestCase):
|
||||
def test_slot_data(self):
|
||||
@component.register("test")
|
||||
class TestComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
{% slot "my_slot" abc=abc def=var123 %}Default text{% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_context_data(self):
|
||||
return {
|
||||
"abc": "def",
|
||||
"var123": 456,
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "test" %}
|
||||
{% fill "my_slot" data="slot_data_in_fill" %}
|
||||
{{ slot_data_in_fill.abc }}
|
||||
{{ slot_data_in_fill.def }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
rendered = Template(template).render(Context())
|
||||
expected = """
|
||||
<div>
|
||||
def
|
||||
456
|
||||
</div>
|
||||
"""
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
def test_slot_data_with_flags(self):
|
||||
@component.register("test")
|
||||
class TestComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
{% slot "my_slot" default abc=abc 123=var123 required %}Default text{% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_context_data(self):
|
||||
return {
|
||||
"abc": "def",
|
||||
"var123": 456,
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "test" %}
|
||||
{% fill "my_slot" data="slot_data_in_fill" %}
|
||||
{{ slot_data_in_fill.abc }}
|
||||
{{ slot_data_in_fill.123 }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
rendered = Template(template).render(Context())
|
||||
expected = """
|
||||
<div>
|
||||
def
|
||||
456
|
||||
</div>
|
||||
"""
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
def test_slot_data_fill_with_as(self):
|
||||
@component.register("test")
|
||||
class TestComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
{% slot "my_slot" abc=abc 123=var123 %}Default text{% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_context_data(self):
|
||||
return {
|
||||
"abc": "def",
|
||||
"var123": 456,
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "test" %}
|
||||
{% fill "my_slot" data="slot_data_in_fill" as "slot_var" %}
|
||||
{{ slot_var.default }}
|
||||
{{ slot_data_in_fill.abc }}
|
||||
{{ slot_data_in_fill.123 }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
rendered = Template(template).render(Context())
|
||||
expected = """
|
||||
<div>
|
||||
Default text
|
||||
def
|
||||
456
|
||||
</div>
|
||||
"""
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
def test_slot_data_raises_on_slot_data_and_as_same_var(self):
|
||||
@component.register("test")
|
||||
class TestComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
{% slot "my_slot" abc=abc 123=var123 %}Default text{% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_context_data(self):
|
||||
return {
|
||||
"abc": "def",
|
||||
"var123": 456,
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "test" %}
|
||||
{% fill "my_slot" data="slot_var" as "slot_var" %}
|
||||
{{ slot_var.default }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
with self.assertRaisesMessage(
|
||||
TemplateSyntaxError,
|
||||
"'fill' received the same string for slot alias (as ...) and slot data (data=...)",
|
||||
):
|
||||
Template(template).render(Context())
|
||||
|
||||
def test_slot_data_fill_without_data(self):
|
||||
@component.register("test")
|
||||
class TestComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
{% slot "my_slot" abc=abc 123=var123 %}Default text{% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_context_data(self):
|
||||
return {
|
||||
"abc": "def",
|
||||
"var123": 456,
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "test" %}
|
||||
{% fill "my_slot" %}
|
||||
overriden
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
rendered = Template(template).render(Context())
|
||||
expected = "<div> overriden </div>"
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
def test_slot_data_fill_without_slot_data(self):
|
||||
@component.register("test")
|
||||
class TestComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
{% slot "my_slot" %}Default text{% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "test" %}
|
||||
{% fill "my_slot" data="data" %}
|
||||
{{ data|safe }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
rendered = Template(template).render(Context())
|
||||
expected = "<div> {} </div>"
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
def test_slot_data_no_fill(self):
|
||||
@component.register("test")
|
||||
class TestComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
{% slot "my_slot" abc=abc 123=var123 %}Default text{% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_context_data(self):
|
||||
return {
|
||||
"abc": "def",
|
||||
"var123": 456,
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "test" %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
rendered = Template(template).render(Context())
|
||||
expected = "<div> Default text </div>"
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue