mirror of
https://github.com/django-components/django-components.git
synced 2025-08-09 00:37:59 +00:00
feat: merge context settings, replace if_filled tag with var
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
0f3491850b
commit
3fc90e4956
17 changed files with 1394 additions and 838 deletions
208
README.md
208
README.md
|
@ -20,7 +20,12 @@ Read on to learn about the details!
|
|||
|
||||
## Release notes
|
||||
|
||||
**Version 0.67** CHANGED the default way how context variables are resolved in slots. See the [documentation](#isolate-components-slots) for more details.
|
||||
🚨📢 **Version 0.70**
|
||||
|
||||
- `{% if_filled "my_slot" %}` tags were replaced with `{{ component_vars.is_filled.my_slot }}` variables.
|
||||
- Simplified settings - `slot_context_behavior` and `context_behavior` were merged. See the [documentation](#context-behavior) for more details.
|
||||
|
||||
**Version 0.67** CHANGED the default way how context variables are resolved in slots. See the [documentation](https://github.com/EmilStenstrom/django-components/tree/0.67#isolate-components-slots) for more details.
|
||||
|
||||
🚨📢 **Version 0.5** CHANGES THE SYNTAX for components. `component_block` is now `component`, and `component` blocks need an ending `endcomponent` tag. The new `python manage.py upgradecomponent` command can be used to upgrade a directory (use --path argument to point to each dir) of components to the new syntax automatically.
|
||||
|
||||
|
@ -72,7 +77,7 @@ Both routes are described in the official [docs of the _staticfiles_ app](https:
|
|||
|
||||
Install the app into your environment:
|
||||
|
||||
> ```pip install django_components```
|
||||
> `pip install django_components`
|
||||
|
||||
Then add the app into INSTALLED_APPS in settings.py
|
||||
|
||||
|
@ -118,7 +123,7 @@ STATICFILES_DIRS = [
|
|||
|
||||
### Optional
|
||||
|
||||
To avoid loading the app in each template using ``` {% load component_tags %} ```, you can add the tag as a 'builtin' in settings.py
|
||||
To avoid loading the app in each template using `{% load component_tags %}`, you can add the tag as a 'builtin' in settings.py
|
||||
|
||||
```python
|
||||
TEMPLATES = [
|
||||
|
@ -174,8 +179,13 @@ First you need a CSS file. Be sure to prefix all rules with a unique class so th
|
|||
|
||||
```css
|
||||
/* In a file called [project root]/components/calendar/style.css */
|
||||
.calendar-component { width: 200px; background: pink; }
|
||||
.calendar-component span { font-weight: bold; }
|
||||
.calendar-component {
|
||||
width: 200px;
|
||||
background: pink;
|
||||
}
|
||||
.calendar-component span {
|
||||
font-weight: bold;
|
||||
}
|
||||
```
|
||||
|
||||
Then you need a javascript file that specifies how you interact with this component. You are free to use any javascript framework you want. A good way to make sure this component doesn't clash with other components is to define all code inside an anonymous function that calls itself. This makes all variables defined only be defined inside this component and not affect other components.
|
||||
|
@ -184,9 +194,11 @@ Then you need a javascript file that specifies how you interact with this compon
|
|||
/* In a file called [project root]/components/calendar/script.js */
|
||||
(function () {
|
||||
if (document.querySelector(".calendar-component")) {
|
||||
document.querySelector(".calendar-component").onclick = function(){ alert("Clicked calendar!"); };
|
||||
document.querySelector(".calendar-component").onclick = function () {
|
||||
alert("Clicked calendar!");
|
||||
};
|
||||
}
|
||||
})()
|
||||
})();
|
||||
```
|
||||
|
||||
Now you need a Django template for your component. Feel free to define more variables like `date` in this example. When creating an instance of this component we will send in the values for these variables. The template will be rendered with whatever template backend you've specified in your Django settings file.
|
||||
|
@ -230,6 +242,7 @@ By default, the Python files in the `components` app are auto-imported in order
|
|||
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).
|
||||
|
@ -262,13 +275,21 @@ The output from the above template will be:
|
|||
<html>
|
||||
<head>
|
||||
<title>My example calendar</title>
|
||||
<link href="/static/calendar/style.css" type="text/css" media="all" rel="stylesheet">
|
||||
<link
|
||||
href="/static/calendar/style.css"
|
||||
type="text/css"
|
||||
media="all"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="calendar-component">Today's date is <span>2015-06-19</span></div>
|
||||
<div class="calendar-component">
|
||||
Today's date is <span>2015-06-19</span>
|
||||
</div>
|
||||
<script src="/static/calendar/script.js"></script>
|
||||
</body>
|
||||
<html>
|
||||
<html></html>
|
||||
</html>
|
||||
```
|
||||
|
||||
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.
|
||||
|
@ -529,11 +550,12 @@ Produces:
|
|||
</div>
|
||||
```
|
||||
|
||||
|
||||
#### Conditional slots
|
||||
|
||||
_Added in version 0.26._
|
||||
|
||||
> NOTE: In version 0.70, `{% if_filled %}` tags were replaced with `{{ component_vars.is_filled }}` variables. If your slot name contained special characters, see the section "Accessing slot names with special characters".
|
||||
|
||||
In certain circumstances, you may want the behavior of slot filling to depend on
|
||||
whether or not a particular slot is filled.
|
||||
|
||||
|
@ -566,61 +588,62 @@ explicit fills, the div containing the slot is still rendered, as shown below:
|
|||
This may not be what you want. What if instead the outer 'subtitle' div should only
|
||||
be included when the inner slot is in fact filled?
|
||||
|
||||
The answer is to use the `{% if_filled <name> %}` tag. Together with `{% endif_filled %}`,
|
||||
these define a block whose contents will be rendered only if the component slot with
|
||||
The answer is to use the `{{ component_vars.is_filled.<name> }}` variable. You can use this together with Django's `{% if/elif/else/endif %}` tags to define a block whose contents will be rendered only if the component slot with
|
||||
the corresponding 'name' is filled.
|
||||
|
||||
This is what our example looks like with an 'if_filled' tag.
|
||||
This is what our example looks like with `component_vars.is_filled`.
|
||||
|
||||
```htmldjango
|
||||
<div class="frontmatter-component">
|
||||
<div class="title">
|
||||
{% slot "title" %}Title{% endslot %}
|
||||
</div>
|
||||
{% if_filled "subtitle" %}
|
||||
{% if component_vars.is_filled.subtitle %}
|
||||
<div class="subtitle">
|
||||
{% slot "subtitle" %}{# Optional subtitle #}{% endslot %}
|
||||
</div>
|
||||
{% endif_filled %}
|
||||
{% endif %}
|
||||
</div>
|
||||
```
|
||||
|
||||
Just as Django's builtin 'if' tag has 'elif' and 'else' counterparts, so does 'if_filled'
|
||||
include additional tags for more complex branching. These tags are 'elif_filled' and
|
||||
'else_filled'. Here's what our example looks like with them.
|
||||
Here's our example with more complex branching.
|
||||
|
||||
```htmldjango
|
||||
<div class="frontmatter-component">
|
||||
<div class="title">
|
||||
{% slot "title" %}Title{% endslot %}
|
||||
</div>
|
||||
{% if_filled "subtitle" %}
|
||||
{% if component_vars.is_filled.subtitle %}
|
||||
<div class="subtitle">
|
||||
{% slot "subtitle" %}{# Optional subtitle #}{% endslot %}
|
||||
</div>
|
||||
{% elif_filled "title" %}
|
||||
{% elif component_vars.is_filled.title %}
|
||||
...
|
||||
{% else_filled %}
|
||||
{% elif component_vars.is_filled.<name> %}
|
||||
...
|
||||
{% endif_filled %}
|
||||
{% endif %}
|
||||
</div>
|
||||
```
|
||||
|
||||
Sometimes you're not interested in whether a slot is filled, but rather that it _isn't_.
|
||||
To negate the meaning of 'if_filled' in this way, an optional boolean can be passed to
|
||||
the 'if_filled' and 'elif_filled' tags.
|
||||
|
||||
In the example below we use `False` to indicate that the content should be rendered
|
||||
only if the slot 'subtitle' is _not_ filled.
|
||||
To negate the meaning of `component_vars.is_filled`, simply treat it as boolean and negate it with `not`:
|
||||
|
||||
```htmldjango
|
||||
{% if_filled subtitle False %}
|
||||
{% if not component_vars.is_filled.subtitle %}
|
||||
<div class="subtitle">
|
||||
{% slot "subtitle" %}{% endslot %}
|
||||
</div>
|
||||
{% endif_filled %}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
**Accessing 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`).
|
||||
|
||||
However, you can still define slots with other special characters. In such case, the slot name in `component_vars.is_filled` is modified to replace all invalid characters into `_`.
|
||||
|
||||
So a slot named `"my super-slot :)"` will be available as `component_vars.is_filled.my_super_slot___`.
|
||||
|
||||
### 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.
|
||||
|
@ -646,7 +669,9 @@ COMPONENTS = {
|
|||
|
||||
## Component context and scope
|
||||
|
||||
By default, components can access context variables from the parent template, just like templates that are included with the `{% include %}` tag. Just like with `{% include %}`, if you don't want the component template to have access to the parent context, add `only` to the end of the `{% component %}` tag):
|
||||
By default, components are ISOLATED and CANNOT access context variables from the parent template. This is useful if you want to make sure that components don't accidentally access the outer context.
|
||||
|
||||
You can set the [context_behavior](#context-behavior) option to `"django"`, to make components behave just like templates that are included with the `{% include %}` tag. 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:
|
||||
|
||||
```htmldjango
|
||||
{% component "calendar" date="2015-06-19" only %}{% endcomponent %}
|
||||
|
@ -656,15 +681,13 @@ 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`.
|
||||
|
||||
You can also set `context_behavior` to `isolated` to make all components isolated by default. This is useful if you want to make sure that components don't accidentally access the outer context.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
### Configure the module where components are loaded from
|
||||
|
||||
Configure the location where components are loaded. To do this, add a COMPONENTS variable to you settings.py with a list of python paths to load. This allows you to build a structure of components that are independent from your apps.
|
||||
Configure the location where components are loaded. To do this, add a `COMPONENTS` variable to you `settings.py` with a list of python paths to load. This allows you to build a structure of components that are independent from your apps.
|
||||
|
||||
```python
|
||||
COMPONENTS = {
|
||||
|
@ -696,9 +719,19 @@ COMPONENTS = {
|
|||
}
|
||||
```
|
||||
|
||||
### Isolate components' context by default
|
||||
### Context behavior
|
||||
|
||||
If you'd like to prevent components from accessing the outer context by default, you can set the `context_behavior` setting to `isolated`. This is useful if you want to make sure that components don't accidentally access the outer context.
|
||||
> NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70.
|
||||
>
|
||||
> If you are migrating from BEFORE v0.67, set `context_behavior` to `"django"`. From v0.67 and later use the default value `"isolated"`.
|
||||
|
||||
You can configure what variables are available inside the `{% fill %}` tags. See [Component context and scope](#component-context-and-scope).
|
||||
|
||||
This has two modes:
|
||||
|
||||
- `"django"` - Context variables are taken from its surroundings (default before v0.67)
|
||||
|
||||
- `"isolated"` - Default - Similar behavior to [Vue](https://vuejs.org/guide/components/slots.html#render-scope) or React - variables are taken ONLY from `get_context_data()` method of the component which defines the `{% fill %}` tag. This is useful if you want to make sure that components don't accidentally access the outer context.
|
||||
|
||||
```python
|
||||
COMPONENTS = {
|
||||
|
@ -706,26 +739,77 @@ COMPONENTS = {
|
|||
}
|
||||
```
|
||||
|
||||
### Isolate components' slots
|
||||
#### Example "django"
|
||||
|
||||
What variables should be available from inside a component slot?
|
||||
Given this template
|
||||
|
||||
By default, variables inside component slots are preferentially taken from the root context.
|
||||
This is similar to [how Vue renders slots](https://vuejs.org/guide/components/slots.html#render-scope),
|
||||
except that, if variable is not found in the root, then the surrounding context is searched too.
|
||||
|
||||
You can change this with the `slot_contet_behavior` setting. Options are:
|
||||
- `"prefer_root"` - Default - as described above
|
||||
- `"isolated"` - Same behavior as Vue - variables are taken ONLY from the root context
|
||||
- `"allow_override"` - slot context variables are taken from its surroundings (default before v0.67)
|
||||
|
||||
```python
|
||||
COMPONENTS = {
|
||||
"slot_context_behavior": "isolated",
|
||||
}
|
||||
```django
|
||||
{% with cheese="feta" %}
|
||||
{% component 'my_comp' %}
|
||||
{{ my_var }} # my_var
|
||||
{{ cheese }} # cheese
|
||||
{% endcomponent %}
|
||||
{% endwith %}
|
||||
```
|
||||
|
||||
For further details and examples, see [SlotContextBehavior](https://github.com/EmilStenstrom/django-components/blob/master/src/django_components/app_settings.py#L12).
|
||||
and this context returned from the `get_context_data()` method
|
||||
|
||||
```py
|
||||
{ "my_var": 123 }
|
||||
```
|
||||
|
||||
Then if component "my_comp" defines context
|
||||
|
||||
```py
|
||||
{ "my_var": 456 }
|
||||
```
|
||||
|
||||
Then this will render:
|
||||
|
||||
```django
|
||||
456 # my_var
|
||||
feta # cheese
|
||||
```
|
||||
|
||||
Because "my_comp" overrides the variable "my_var",
|
||||
so `{{ my_var }}` equals `456`.
|
||||
And variable "cheese" equals `feta`, because the fill CAN access
|
||||
the current context.
|
||||
|
||||
#### Example "isolated"
|
||||
|
||||
Given this template
|
||||
|
||||
```django
|
||||
{% with cheese="feta" %}
|
||||
{% component 'my_comp' %}
|
||||
{{ my_var }} # my_var
|
||||
{{ cheese }} # cheese
|
||||
{% endcomponent %}
|
||||
{% endwith %}
|
||||
```
|
||||
|
||||
and this context returned from the `get_context_data()` method
|
||||
|
||||
```py
|
||||
{ "my_var": 123 }
|
||||
```
|
||||
|
||||
Then if component "my_comp" defines context
|
||||
|
||||
```py
|
||||
{ "my_var": 456 }
|
||||
```
|
||||
|
||||
Then this will render:
|
||||
|
||||
```django
|
||||
123 # my_var
|
||||
# cheese
|
||||
```
|
||||
|
||||
Because both variables "my_var" and "cheese" are taken from the parent's `get_context_data()`.
|
||||
But since "cheese" is not defined there, it's empty.
|
||||
|
||||
## Logging and debugging
|
||||
|
||||
|
@ -837,7 +921,6 @@ One of our goals with `django-components` is to make it easy to share components
|
|||
|
||||
- [django-htmx-components](https://github.com/iwanalabs/django-htmx-components): A set of components for use with [htmx](https://htmx.org/). Try out the [live demo](https://dhc.iwanalabs.com/).
|
||||
|
||||
|
||||
## Running django-components project locally
|
||||
|
||||
### Install locally and run the tests
|
||||
|
@ -879,19 +962,23 @@ How do you check that your changes to django-components project will work in an
|
|||
Use the [sampleproject](./sampleproject/) demo project to validate the changes:
|
||||
|
||||
1. Navigate to [sampleproject](./sampleproject/) directory:
|
||||
|
||||
```sh
|
||||
cd sampleproject
|
||||
```
|
||||
|
||||
2. Install dependencies from the [requirements.txt](./sampleproject/requirements.txt) file:
|
||||
|
||||
```sh
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Link to your local version of django-components:
|
||||
|
||||
```sh
|
||||
pip install -e ..
|
||||
```
|
||||
|
||||
NOTE: The path (in this case `..`) must point to the directory that has the `setup.py` file.
|
||||
|
||||
4. Start Django server
|
||||
|
@ -905,15 +992,4 @@ To display individual components, add them to the `urls.py`, like in the case of
|
|||
|
||||
## Development guides
|
||||
|
||||
### Slot rendering flow
|
||||
|
||||
1. Flow starts when a template string is being parsed into Django Template instance.
|
||||
|
||||
2. When a `{% component %}` template tag is encountered, its body is searched for all `{% fill %}` nodes (explicit or implicit). and this is attached to the created `ComponentNode`.
|
||||
|
||||
See the implementation of `component` template tag for details.
|
||||
|
||||
3. Template rendering is a separate action from template parsing. When the template is being rendered, the `ComponentNode` creates an instance of the `Component` class and passes it the slot fills.
|
||||
|
||||
It's at this point when `Component.render` is called, and the slots are
|
||||
rendered.
|
||||
- [Slot rendering flot](./docs/slot_rendering.md)
|
||||
|
|
238
docs/slot_rendering.md
Normal file
238
docs/slot_rendering.md
Normal file
|
@ -0,0 +1,238 @@
|
|||
# Slot rendering
|
||||
|
||||
This doc serves as a primer on how component slots and fills are resolved.
|
||||
|
||||
## Flow
|
||||
|
||||
1. Imagine you have a template. Some kind of text, maybe HTML:
|
||||
```django
|
||||
| ------
|
||||
| ---------
|
||||
| ----
|
||||
| -------
|
||||
```
|
||||
|
||||
2. The template may contain some vars, tags, etc
|
||||
```django
|
||||
| -- {{ my_var }} --
|
||||
| ---------
|
||||
| ----
|
||||
| -------
|
||||
```
|
||||
|
||||
3. The template also contains some slots, etc
|
||||
```django
|
||||
| -- {{ my_var }} --
|
||||
| ---------
|
||||
| -- {% slot "myslot" %} ---
|
||||
| -- {% endslot %} ---
|
||||
| ----
|
||||
| -- {% slot "myslot2" %} ---
|
||||
| -- {% endslot %} ---
|
||||
| -------
|
||||
```
|
||||
|
||||
4. Slots may be nested
|
||||
```django
|
||||
| -- {{ my_var }} --
|
||||
| -- ABC
|
||||
| -- {% slot "myslot" %} ---
|
||||
| ----- DEF {{ my_var }}
|
||||
| ----- {% slot "myslot_inner" %}
|
||||
| -------- GHI {{ my_var }}
|
||||
| ----- {% endslot %}
|
||||
| -- {% endslot %} ---
|
||||
| ----
|
||||
| -- {% slot "myslot2" %} ---
|
||||
| ---- JKL {{ my_var }}
|
||||
| -- {% endslot %} ---
|
||||
| -------
|
||||
```
|
||||
|
||||
5. Some slots may be inside fills for other components
|
||||
```django
|
||||
| -- {{ my_var }} --
|
||||
| -- ABC
|
||||
| -- {% slot "myslot" %}---
|
||||
| ----- DEF {{ my_var }}
|
||||
| ----- {% slot "myslot_inner" %}
|
||||
| -------- GHI {{ my_var }}
|
||||
| ----- {% endslot %}
|
||||
| -- {% endslot %} ---
|
||||
| ------
|
||||
| -- {% component "mycomp" %} ---
|
||||
| ---- {% slot "myslot" %} ---
|
||||
| ------- JKL {{ my_var }}
|
||||
| ------- {% slot "myslot_inner" %}
|
||||
| ---------- MNO {{ my_var }}
|
||||
| ------- {% endslot %}
|
||||
| ---- {% endslot %} ---
|
||||
| -- {% endcomponent %} ---
|
||||
| ----
|
||||
| -- {% slot "myslot2" %} ---
|
||||
| ---- PQR {{ my_var }}
|
||||
| -- {% endslot %} ---
|
||||
| -------
|
||||
```
|
||||
|
||||
5. I want to render the slots with `{% fill %}` tag that were defined OUTSIDE of this template. How do I do that?
|
||||
|
||||
1. Traverse the template to collect ALL slots
|
||||
- NOTE: I will also look inside `{% slot %}` and `{% fill %}` tags, since they are all still
|
||||
defined within the same TEMPLATE.
|
||||
|
||||
I should end up with a list like this:
|
||||
```txt
|
||||
- Name: "myslot"
|
||||
ID 0001
|
||||
Content:
|
||||
| ----- DEF {{ my_var }}
|
||||
| ----- {% slot "myslot_inner" %}
|
||||
| -------- GHI {{ my_var }}
|
||||
| ----- {% endslot %}
|
||||
- Name: "myslot_inner"
|
||||
ID 0002
|
||||
Content:
|
||||
| -------- GHI {{ my_var }}
|
||||
- Name: "myslot"
|
||||
ID 0003
|
||||
Content:
|
||||
| ------- JKL {{ my_var }}
|
||||
| ------- {% slot "myslot_inner" %}
|
||||
| ---------- MNO {{ my_var }}
|
||||
| ------- {% endslot %}
|
||||
- Name: "myslot_inner"
|
||||
ID 0004
|
||||
Content:
|
||||
| ---------- MNO {{ my_var }}
|
||||
- Name: "myslot2"
|
||||
ID 0005
|
||||
Content:
|
||||
| ---- PQR {{ my_var }}
|
||||
```
|
||||
|
||||
2. Note the relationships - which slot is nested in which one
|
||||
|
||||
I should end up with a graph-like data like:
|
||||
```txt
|
||||
- 0001: [0002]
|
||||
- 0002: []
|
||||
- 0003: [0004]
|
||||
- 0004: []
|
||||
- 0005: []
|
||||
```
|
||||
|
||||
In other words, the data tells us that slot ID `0001` is PARENT of slot `0002`.
|
||||
|
||||
This is important, because, IF parent template provides slot fill for slot 0001,
|
||||
then we DON'T NEED TO render it's children, AKA slot 0002.
|
||||
|
||||
3. Find roots of the slot relationships
|
||||
|
||||
The data from previous step can be understood also as a collection of
|
||||
directled acyclig graphs (DAG), e.g.:
|
||||
|
||||
```txt
|
||||
0001 --> 0002
|
||||
0003 --> 0004
|
||||
0005
|
||||
```
|
||||
|
||||
So we find the roots (`0001`, `0003`, `0005`), AKA slots that are NOT nested in other slots.
|
||||
We do so by going over ALL entries from previous step. Those IDs which are NOT
|
||||
mentioned in ANY of the lists are the roots.
|
||||
|
||||
Because of the nature of nested structures, there cannot be any cycles.
|
||||
|
||||
4. Recursively render slots, starting from roots.
|
||||
1. First we take each of the roots.
|
||||
|
||||
2. Then we check if there is a slot fill for given slot name.
|
||||
|
||||
3. If YES we replace the slot node with the fill node.
|
||||
- Note: We assume slot fills are ALREADY RENDERED!
|
||||
```django
|
||||
| ----- {% slot "myslot_inner" %}
|
||||
| -------- GHI {{ my_var }}
|
||||
| ----- {% endslot %}
|
||||
```
|
||||
becomes
|
||||
```django
|
||||
| ----- Bla bla
|
||||
| -------- Some Other Content
|
||||
| ----- ...
|
||||
```
|
||||
We don't continue further, because inner slots have been overriden!
|
||||
|
||||
4. If NO, then we will replace slot nodes with their children, e.g.:
|
||||
```django
|
||||
| ---- {% slot "myslot" %} ---
|
||||
| ------- JKL {{ my_var }}
|
||||
| ------- {% slot "myslot_inner" %}
|
||||
| ---------- MNO {{ my_var }}
|
||||
| ------- {% endslot %}
|
||||
| ---- {% endslot %} ---
|
||||
```
|
||||
Becomes
|
||||
```django
|
||||
| ------- JKL {{ my_var }}
|
||||
| ------- {% slot "myslot_inner" %}
|
||||
| ---------- MNO {{ my_var }}
|
||||
| ------- {% endslot %}
|
||||
```
|
||||
|
||||
5. We check if the slot includes any children `{% slot %}` tags. If YES, then continue with step 4. for them, and wait until they finish.
|
||||
|
||||
5. At this point, ALL slots should be rendered and we should have something like this:
|
||||
```django
|
||||
| -- {{ my_var }} --
|
||||
| -- ABC
|
||||
| ----- DEF {{ my_var }}
|
||||
| -------- GHI {{ my_var }}
|
||||
| ------
|
||||
| -- {% component "mycomp" %} ---
|
||||
| ------- JKL {{ my_var }}
|
||||
| ---- {% component "mycomp" %} ---
|
||||
| ---------- MNO {{ my_var }}
|
||||
| ---- {% endcomponent %} ---
|
||||
| -- {% endcomponent %} ---
|
||||
| ----
|
||||
| -- {% component "mycomp2" %} ---
|
||||
| ---- PQR {{ my_var }}
|
||||
| -- {% endcomponent %} ---
|
||||
| ----
|
||||
```
|
||||
- NOTE: Inserting fills into {% slots %} should NOT introduce new {% slots %}, as the fills should be already rendered!
|
||||
|
||||
## Using the correct context in {% slot/fill %} tags
|
||||
|
||||
In previous section, we said that the `{% fill %}` tags should be already rendered by the time they are inserted into the `{% slot %}` tags.
|
||||
|
||||
This is not quite true. To help you understand, consider this complex case:
|
||||
|
||||
```django
|
||||
| -- {% for var in [1, 2, 3] %} ---
|
||||
| ---- {% component "mycomp2" %} ---
|
||||
| ------ {% fill "first" %}
|
||||
| ------- STU {{ my_var }}
|
||||
| ------- {{ var }}
|
||||
| ------ {% endfill %}
|
||||
| ------ {% fill "second" %}
|
||||
| -------- {% component var=var my_var=my_var %}
|
||||
| ---------- VWX {{ my_var }}
|
||||
| -------- {% endcomponent %}
|
||||
| ------ {% endfill %}
|
||||
| ---- {% endcomponent %} ---
|
||||
| -- {% endfor %} ---
|
||||
| -------
|
||||
```
|
||||
|
||||
We want the forloop variables to be available inside the `{% fill %}` tags. Because of that, however, we CANNOT render the fills/slots in advance.
|
||||
|
||||
Instead, our solution is closer to [how Vue handles slots](https://vuejs.org/guide/components/slots.html#scoped-slots). In Vue, slots are effectively functions that accept a context variables and render some content.
|
||||
|
||||
While we do not wrap the logic in a function, we do PREPARE IN ADVANCE:
|
||||
1. The content that should be rendered for each slot
|
||||
2. The context variables from `get_context_data()`
|
||||
|
||||
Thus, once we reach the `{% slot %}` node, in it's `render()` method, we access the data above, and, depending on the `context_behavior` setting, include the current context or not. For more info, see `SlotNode.render()`.
|
|
@ -84,8 +84,7 @@ WSGI_APPLICATION = "sampleproject.wsgi.application"
|
|||
# "autodiscover": True,
|
||||
# "libraries": [],
|
||||
# "template_cache_size": 128,
|
||||
# "context_behavior": "isolated", # "global" | "isolated"
|
||||
# "slot_context_behavior": "prefer_root", # "allow_override" | "prefer_root" | "isolated"
|
||||
# "context_behavior": "isolated", # "django" | "isolated"
|
||||
# }
|
||||
|
||||
|
||||
|
|
|
@ -5,27 +5,27 @@ from django.conf import settings
|
|||
|
||||
|
||||
class ContextBehavior(str, Enum):
|
||||
GLOBAL = "global"
|
||||
ISOLATED = "isolated"
|
||||
|
||||
|
||||
class SlotContextBehavior(str, Enum):
|
||||
ALLOW_OVERRIDE = "allow_override"
|
||||
DJANGO = "django"
|
||||
"""
|
||||
Components CAN override the slot context variables passed from the outer scopes.
|
||||
Contexts of deeper components take precedence over shallower ones.
|
||||
With this setting, component fills behave as usual Django tags.
|
||||
That is, they enrich the context, and pass it along.
|
||||
|
||||
1. Component fills use the context of the component they are within.
|
||||
2. Variables from `get_context_data` are available to the component fill.
|
||||
|
||||
Example:
|
||||
|
||||
Given this template
|
||||
|
||||
```txt
|
||||
```django
|
||||
{% with cheese="feta" %}
|
||||
{% component 'my_comp' %}
|
||||
{{ my_var }}
|
||||
{{ my_var }} # my_var
|
||||
{{ cheese }} # cheese
|
||||
{% endcomponent %}
|
||||
{% endwith %}
|
||||
```
|
||||
|
||||
and this context passed to the render function (AKA root context)
|
||||
and this context returned from the `get_context_data()` method
|
||||
```py
|
||||
{ "my_var": 123 }
|
||||
```
|
||||
|
@ -35,59 +35,37 @@ class SlotContextBehavior(str, Enum):
|
|||
{ "my_var": 456 }
|
||||
```
|
||||
|
||||
Then since "my_comp" overrides the varialbe "my_var", so `{{ my_var }}` will equal `456`.
|
||||
"""
|
||||
|
||||
PREFER_ROOT = "prefer_root"
|
||||
"""
|
||||
This is the same as "allow_override", except any variables defined in the root context
|
||||
take precedence over anything else.
|
||||
|
||||
So if a variable is found in the root context, then root context is used.
|
||||
Otherwise, the context of the component where the slot fill is located is used.
|
||||
|
||||
Example:
|
||||
|
||||
Given this template
|
||||
|
||||
```txt
|
||||
{% component 'my_comp' %}
|
||||
{{ my_var_one }}
|
||||
{{ my_var_two }}
|
||||
{% endcomponent %}
|
||||
Then this will render:
|
||||
```django
|
||||
456 # my_var
|
||||
feta # cheese
|
||||
```
|
||||
|
||||
and this context passed to the render function (AKA root context)
|
||||
```py
|
||||
{ "my_var_one": 123 }
|
||||
```
|
||||
Because "my_comp" overrides the variable "my_var",
|
||||
so `{{ my_var }}` equals `456`.
|
||||
|
||||
Then if component "my_comp" defines context
|
||||
```py
|
||||
{ "my_var": 456, "my_var_two": "abc" }
|
||||
```
|
||||
|
||||
Then the rendered `{{ my_var_one }}` will equal to `123`, and `{{ my_var_two }}`
|
||||
will equal to "abc".
|
||||
And variable "cheese" will equal `feta`, because the fill CAN access
|
||||
the current context.
|
||||
"""
|
||||
|
||||
ISOLATED = "isolated"
|
||||
"""
|
||||
This setting makes the slots behave similar to Vue or React, where
|
||||
the slot uses EXCLUSIVELY the root context, and nested components CANNOT
|
||||
override context variables inside the slots.
|
||||
This setting makes the component fills behave similar to Vue or React, where
|
||||
the fills use EXCLUSIVELY the context variables defined in `get_context_data`.
|
||||
|
||||
Example:
|
||||
|
||||
Given this template
|
||||
|
||||
```txt
|
||||
```django
|
||||
{% with cheese="feta" %}
|
||||
{% component 'my_comp' %}
|
||||
{{ my_var }}
|
||||
{{ my_var }} # my_var
|
||||
{{ cheese }} # cheese
|
||||
{% endcomponent %}
|
||||
{% endwith %}
|
||||
```
|
||||
|
||||
and this context passed to the render function (AKA root context)
|
||||
and this context returned from the `get_context_data()` method
|
||||
```py
|
||||
{ "my_var": 123 }
|
||||
```
|
||||
|
@ -97,7 +75,14 @@ class SlotContextBehavior(str, Enum):
|
|||
{ "my_var": 456 }
|
||||
```
|
||||
|
||||
Then the rendered `{{ my_var }}` will equal `123`.
|
||||
Then this will render:
|
||||
```django
|
||||
123 # my_var
|
||||
# cheese
|
||||
```
|
||||
|
||||
Because both variables "my_var" and "cheese" are taken from the root context.
|
||||
Since "cheese" is not defined in root context, it's empty.
|
||||
"""
|
||||
|
||||
|
||||
|
@ -120,7 +105,7 @@ class AppSettings:
|
|||
|
||||
@property
|
||||
def CONTEXT_BEHAVIOR(self) -> ContextBehavior:
|
||||
raw_value = self.settings.get("context_behavior", ContextBehavior.GLOBAL.value)
|
||||
raw_value = self.settings.get("context_behavior", ContextBehavior.ISOLATED.value)
|
||||
return self._validate_context_behavior(raw_value)
|
||||
|
||||
def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehavior:
|
||||
|
@ -130,17 +115,5 @@ class AppSettings:
|
|||
valid_values = [behavior.value for behavior in ContextBehavior]
|
||||
raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}")
|
||||
|
||||
@property
|
||||
def SLOT_CONTEXT_BEHAVIOR(self) -> SlotContextBehavior:
|
||||
raw_value = self.settings.get("slot_context_behavior", SlotContextBehavior.PREFER_ROOT.value)
|
||||
return self._validate_slot_context_behavior(raw_value)
|
||||
|
||||
def _validate_slot_context_behavior(self, raw_value: SlotContextBehavior) -> SlotContextBehavior:
|
||||
try:
|
||||
return SlotContextBehavior(raw_value)
|
||||
except ValueError:
|
||||
valid_values = [behavior.value for behavior in SlotContextBehavior]
|
||||
raise ValueError(f"Invalid slot context behavior: {raw_value}. Valid options are {valid_values}")
|
||||
|
||||
|
||||
app_settings = AppSettings()
|
||||
|
|
|
@ -20,18 +20,16 @@ from django.views import View
|
|||
# way the two modules depend on one another.
|
||||
from django_components.component_registry import registry # NOQA
|
||||
from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA
|
||||
from django_components.context import make_isolated_context_copy, prepare_context, set_slot_component_association
|
||||
from django_components.context import (
|
||||
_FILLED_SLOTS_CONTENT_CONTEXT_KEY,
|
||||
_PARENT_COMP_CONTEXT_KEY,
|
||||
_ROOT_CTX_CONTEXT_KEY,
|
||||
make_isolated_context_copy,
|
||||
prepare_context,
|
||||
)
|
||||
from django_components.logger import logger, trace_msg
|
||||
from django_components.middleware import is_dependency_middleware_active
|
||||
from django_components.node import walk_nodelist
|
||||
from django_components.slots import (
|
||||
DEFAULT_SLOT_KEY,
|
||||
FillContent,
|
||||
FillNode,
|
||||
SlotName,
|
||||
SlotNode,
|
||||
render_component_template_with_slots,
|
||||
)
|
||||
from django_components.slots import DEFAULT_SLOT_KEY, FillContent, FillNode, SlotName, resolve_slots
|
||||
from django_components.utils import gen_id, search
|
||||
|
||||
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
|
||||
|
@ -189,11 +187,11 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
|||
registered_name: Optional[str] = None,
|
||||
component_id: Optional[str] = None,
|
||||
outer_context: Optional[Context] = None,
|
||||
fill_content: Dict[str, FillContent] = {},
|
||||
fill_content: Optional[Dict[str, FillContent]] = None,
|
||||
):
|
||||
self.registered_name: Optional[str] = registered_name
|
||||
self.outer_context: Context = outer_context or Context()
|
||||
self.fill_content = fill_content
|
||||
self.fill_content = fill_content or {}
|
||||
self.component_id = component_id or gen_id()
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
|
@ -254,34 +252,75 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
|||
f"Note: this attribute is not required if you are overriding the class's `get_template*()` methods."
|
||||
)
|
||||
|
||||
def render_from_input(self, context: Context, args: Union[List, Tuple], kwargs: Dict) -> str:
|
||||
component_context: dict = self.get_context_data(*args, **kwargs)
|
||||
|
||||
with context.update(component_context):
|
||||
rendered_component = self.render(context, context_data=component_context)
|
||||
|
||||
if is_dependency_middleware_active():
|
||||
output = RENDERED_COMMENT_TEMPLATE.format(name=self.registered_name) + rendered_component
|
||||
else:
|
||||
output = rendered_component
|
||||
|
||||
return output
|
||||
|
||||
def render(
|
||||
self,
|
||||
context_data: Union[Dict[str, Any], Context],
|
||||
context: Union[Dict[str, Any], Context],
|
||||
slots_data: Optional[Dict[SlotName, str]] = None,
|
||||
escape_slots_content: bool = True,
|
||||
context_data: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
# NOTE: This if/else is important to avoid nested Contexts,
|
||||
# See https://github.com/EmilStenstrom/django-components/issues/414
|
||||
context = context_data if isinstance(context_data, Context) else Context(context_data)
|
||||
prepare_context(context, component_id=self.component_id, outer_context=self.outer_context or Context())
|
||||
context = context if isinstance(context, Context) else Context(context)
|
||||
prepare_context(context, self.component_id)
|
||||
template = self.get_template(context)
|
||||
|
||||
# Associate the slots with this component for this context
|
||||
# This allows us to look up component-specific slot fills.
|
||||
def on_node(node: Node) -> None:
|
||||
if isinstance(node, SlotNode):
|
||||
trace_msg("ASSOC", "SLOT", node.name, node.node_id, component_id=self.component_id)
|
||||
set_slot_component_association(context, node.node_id, self.component_id)
|
||||
|
||||
walk_nodelist(template.nodelist, on_node)
|
||||
|
||||
# Support passing slots explicitly to `render` method
|
||||
if slots_data:
|
||||
self._fill_slots(slots_data, escape_slots_content)
|
||||
fill_content = self._fills_from_slots_data(slots_data, escape_slots_content)
|
||||
else:
|
||||
fill_content = self.fill_content
|
||||
|
||||
return render_component_template_with_slots(
|
||||
self.component_id, template, context, self.fill_content, self.registered_name
|
||||
# If this is top-level component and it has no parent, use outer context instead
|
||||
if not context[_PARENT_COMP_CONTEXT_KEY]:
|
||||
context_data = self.outer_context.flatten()
|
||||
if context_data is None:
|
||||
context_data = {}
|
||||
|
||||
slots, resolved_fills = resolve_slots(
|
||||
template,
|
||||
component_name=self.registered_name,
|
||||
context_data=context_data,
|
||||
fill_content=fill_content,
|
||||
)
|
||||
|
||||
# Available slot fills - this is internal to us
|
||||
updated_slots = {
|
||||
**context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {}),
|
||||
**resolved_fills,
|
||||
}
|
||||
|
||||
# For users, we expose boolean variables that they may check
|
||||
# to see if given slot was filled, e.g.:
|
||||
# `{% if variable > 8 and component_vars.is_filled.header %}`
|
||||
slot_bools = {slot_fill.escaped_name: slot_fill.is_filled for slot_fill in resolved_fills.values()}
|
||||
|
||||
with context.update(
|
||||
{
|
||||
_ROOT_CTX_CONTEXT_KEY: self.outer_context,
|
||||
_FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_slots,
|
||||
# NOTE: Public API for variables accessible from within a component's template
|
||||
# See https://github.com/EmilStenstrom/django-components/issues/280#issuecomment-2081180940
|
||||
"component_vars": {
|
||||
"is_filled": slot_bools,
|
||||
},
|
||||
}
|
||||
):
|
||||
return template.render(context)
|
||||
|
||||
def render_to_response(
|
||||
self,
|
||||
context_data: Union[Dict[str, Any], Context],
|
||||
|
@ -290,25 +329,30 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
|||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> HttpResponse:
|
||||
"""
|
||||
This is the interface for the `django.views.View` class which allows us to
|
||||
use components as Django views with `component.as_view()`.
|
||||
"""
|
||||
return HttpResponse(
|
||||
self.render(context_data, slots_data, escape_slots_content),
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _fill_slots(
|
||||
def _fills_from_slots_data(
|
||||
self,
|
||||
slots_data: Dict[SlotName, str],
|
||||
escape_content: bool = True,
|
||||
) -> None:
|
||||
) -> Dict[SlotName, FillContent]:
|
||||
"""Fill component slots outside of template rendering."""
|
||||
self.fill_content = {
|
||||
slot_fills = {
|
||||
slot_name: FillContent(
|
||||
nodes=NodeList([TextNode(escape(content) if escape_content else content)]),
|
||||
alias=None,
|
||||
)
|
||||
for (slot_name, content) in slots_data.items()
|
||||
}
|
||||
return slot_fills
|
||||
|
||||
|
||||
class ComponentNode(Node):
|
||||
|
@ -346,8 +390,8 @@ class ComponentNode(Node):
|
|||
# Resolve FilterExpressions and Variables that were passed as args to the
|
||||
# component, then call component's context method
|
||||
# to get values to insert into the context
|
||||
resolved_context_args = [safe_resolve(arg, context) for arg in self.context_args]
|
||||
resolved_context_kwargs = {key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()}
|
||||
resolved_context_args = safe_resolve_list(self.context_args, context)
|
||||
resolved_context_kwargs = safe_resolve_dict(self.context_kwargs, context)
|
||||
|
||||
is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
|
||||
if is_default_slot:
|
||||
|
@ -368,24 +412,27 @@ class ComponentNode(Node):
|
|||
component_id=self.component_id,
|
||||
)
|
||||
|
||||
component_context: dict = component.get_context_data(*resolved_context_args, **resolved_context_kwargs)
|
||||
|
||||
# Prevent outer context from leaking into the template of the component
|
||||
if self.isolated_context:
|
||||
context = make_isolated_context_copy(context)
|
||||
|
||||
with context.update(component_context):
|
||||
rendered_component = component.render(context)
|
||||
|
||||
if is_dependency_middleware_active():
|
||||
output = RENDERED_COMMENT_TEMPLATE.format(name=resolved_component_name) + rendered_component
|
||||
else:
|
||||
output = rendered_component
|
||||
output = component.render_from_input(context, resolved_context_args, resolved_context_kwargs)
|
||||
|
||||
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."""
|
||||
|
||||
|
|
|
@ -5,73 +5,37 @@ pass data across components, nodes, slots, and contexts.
|
|||
You can think of the Context as our storage system.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from django.template import Context
|
||||
|
||||
from django_components.app_settings import SlotContextBehavior, app_settings
|
||||
from django_components.logger import trace_msg
|
||||
from django_components.utils import find_last_index
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components.slots import FillContent
|
||||
|
||||
|
||||
_FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
|
||||
_OUTER_ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_OUTER_ROOT_CTX"
|
||||
_SLOT_COMPONENT_ASSOC_KEY = "_DJANGO_COMPONENTS_SLOT_COMP_ASSOC"
|
||||
_PARENT_COMP_KEY = "_DJANGO_COMPONENTS_PARENT_COMP"
|
||||
_CURRENT_COMP_KEY = "_DJANGO_COMPONENTS_CURRENT_COMP"
|
||||
_ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_ROOT_CTX"
|
||||
_PARENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_PARENT_COMP"
|
||||
_CURRENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_CURRENT_COMP"
|
||||
|
||||
|
||||
def prepare_context(
|
||||
context: Context,
|
||||
outer_context: Optional[Context],
|
||||
component_id: str,
|
||||
) -> None:
|
||||
"""Initialize the internal context state."""
|
||||
# This is supposed to run ALWAYS at `Component.render()`
|
||||
if outer_context is not None:
|
||||
set_outer_root_context(context, outer_context)
|
||||
|
||||
# Initialize mapping dicts within this rendering run.
|
||||
# This is shared across the whole render chain, thus we set it only once.
|
||||
if _SLOT_COMPONENT_ASSOC_KEY not in context:
|
||||
context[_SLOT_COMPONENT_ASSOC_KEY] = {}
|
||||
if _FILLED_SLOTS_CONTENT_CONTEXT_KEY not in context:
|
||||
context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = {}
|
||||
|
||||
# If we're inside a forloop, we need to make a disposable copy of slot -> comp
|
||||
# mapping, which can be modified in the loop. We do so by copying it onto the latest
|
||||
# context layer.
|
||||
#
|
||||
# This is necessary, because otherwise if we have a nested loop with a same
|
||||
# component used recursively, the inner slot -> comp mapping would leak into the outer.
|
||||
#
|
||||
# NOTE: If you ever need to debug this, insert a print/debug statement into
|
||||
# `django.template.defaulttags.ForNode.render` to inspect the context object
|
||||
# inside the for loop.
|
||||
if "forloop" in context:
|
||||
context.dicts[-1][_SLOT_COMPONENT_ASSOC_KEY] = context[_SLOT_COMPONENT_ASSOC_KEY].copy()
|
||||
|
||||
set_component_id(context, component_id)
|
||||
|
||||
|
||||
def make_isolated_context_copy(context: Context) -> Context:
|
||||
# Even if contexts are isolated, we still need to pass down the
|
||||
# metadata so variables in slots can be rendered using the correct context.
|
||||
root_ctx = get_outer_root_context(context)
|
||||
slot_assoc = context.get(_SLOT_COMPONENT_ASSOC_KEY, {})
|
||||
slot_fills = context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {})
|
||||
|
||||
context_copy = context.new()
|
||||
context_copy[_SLOT_COMPONENT_ASSOC_KEY] = slot_assoc
|
||||
context_copy[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = slot_fills
|
||||
set_outer_root_context(context_copy, root_ctx)
|
||||
copy_forloop_context(context, context_copy)
|
||||
|
||||
context_copy[_CURRENT_COMP_KEY] = context.get(_CURRENT_COMP_KEY, None)
|
||||
context_copy[_PARENT_COMP_KEY] = context.get(_PARENT_COMP_KEY, None)
|
||||
# Pass through our internal keys
|
||||
context_copy[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {})
|
||||
if _ROOT_CTX_CONTEXT_KEY in context:
|
||||
context_copy[_ROOT_CTX_CONTEXT_KEY] = context.get(_ROOT_CTX_CONTEXT_KEY, {})
|
||||
|
||||
return context_copy
|
||||
|
||||
|
@ -83,136 +47,8 @@ def set_component_id(context: Context, component_id: str) -> None:
|
|||
"""
|
||||
# Store the previous component so we can detect if the current component
|
||||
# is the top-most or not. If it is, then "_parent_component_id" is None
|
||||
context[_PARENT_COMP_KEY] = context.get(_CURRENT_COMP_KEY, None)
|
||||
context[_CURRENT_COMP_KEY] = component_id
|
||||
|
||||
|
||||
def get_slot_fill(context: Context, component_id: str, slot_name: str) -> Optional["FillContent"]:
|
||||
"""
|
||||
Use this function to obtain a slot fill from the current context.
|
||||
|
||||
See `set_slot_fill` for more details.
|
||||
"""
|
||||
trace_msg("GET", "FILL", slot_name, component_id)
|
||||
slot_key = f"{component_id}__{slot_name}"
|
||||
return context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY].get(slot_key, None)
|
||||
|
||||
|
||||
def set_slot_fill(context: Context, component_id: str, slot_name: str, value: "FillContent") -> None:
|
||||
"""
|
||||
Use this function to set a slot fill for the current context.
|
||||
|
||||
Note that we make use of the fact that Django's Context is a stack - we can push and pop
|
||||
extra contexts on top others.
|
||||
"""
|
||||
trace_msg("SET", "FILL", slot_name, component_id)
|
||||
slot_key = f"{component_id}__{slot_name}"
|
||||
context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY][slot_key] = value
|
||||
|
||||
|
||||
def get_outer_root_context(context: Context) -> Optional[Context]:
|
||||
"""
|
||||
Use this function to get the outer root context.
|
||||
|
||||
See `set_outer_root_context` for more details.
|
||||
"""
|
||||
return context.get(_OUTER_ROOT_CTX_CONTEXT_KEY)
|
||||
|
||||
|
||||
def set_outer_root_context(context: Context, outer_ctx: Optional[Context]) -> None:
|
||||
"""
|
||||
Use this function to set the outer root context.
|
||||
|
||||
When we consider a component's template, then outer context is the context
|
||||
that was available just outside of the component's template (AKA it was in
|
||||
the PARENT template).
|
||||
|
||||
Once we have the outer context, next we get the outer ROOT context. This is
|
||||
the context that was available at the top level of the PARENT template.
|
||||
|
||||
We pass through this context to allow to configure how slot fills should be
|
||||
rendered using the `SLOT_CONTEXT_BEHAVIOR` setting.
|
||||
"""
|
||||
# Special case for handling outer context of top-level components when
|
||||
# slots are isolated. In such case, the entire outer context is to be the
|
||||
# outer root ctx.
|
||||
if (
|
||||
outer_ctx
|
||||
and not context.get(_PARENT_COMP_KEY)
|
||||
and app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ISOLATED
|
||||
and _OUTER_ROOT_CTX_CONTEXT_KEY in context # <-- Added to avoid breaking tests
|
||||
):
|
||||
outer_root_context = outer_ctx.new()
|
||||
outer_root_context.push(outer_ctx.flatten())
|
||||
|
||||
# In nested components, the context generated from `get_context_data`
|
||||
# is found at index 1.
|
||||
# NOTE:
|
||||
# - Index 0 are the defaults set in BaseContext
|
||||
# - Index 1 is the context generated by `Component.get_context_data`
|
||||
# of the parent's component
|
||||
# - All later indices (2, 3, ...) are extra layers added by the rendering
|
||||
# logic (each Node usually adds it's own context layer)
|
||||
elif outer_ctx and len(outer_ctx.dicts) > 1:
|
||||
outer_root_context = outer_ctx.new()
|
||||
outer_root_context.push(outer_ctx.dicts[1])
|
||||
|
||||
# Fallback
|
||||
else:
|
||||
outer_root_context = Context()
|
||||
|
||||
# Include the mappings.
|
||||
if _SLOT_COMPONENT_ASSOC_KEY in context:
|
||||
outer_root_context[_SLOT_COMPONENT_ASSOC_KEY] = context[_SLOT_COMPONENT_ASSOC_KEY]
|
||||
if _FILLED_SLOTS_CONTENT_CONTEXT_KEY in context:
|
||||
outer_root_context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY]
|
||||
|
||||
context[_OUTER_ROOT_CTX_CONTEXT_KEY] = outer_root_context
|
||||
|
||||
|
||||
def set_slot_component_association(
|
||||
context: Context,
|
||||
slot_id: str,
|
||||
component_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Set association between a Slot and a Component in the current context.
|
||||
|
||||
We use SlotNodes to render slot fills. SlotNodes are created only at Template
|
||||
parse time.
|
||||
However, when we refer to components with slots in (another) template (using
|
||||
`{% component %}`), we can render the same component multiple time. So we can
|
||||
have multiple FillNodes intended to be used with the same SlotNode.
|
||||
|
||||
So how do we tell the SlotNode which FillNode to render? We do so by tagging
|
||||
the ComponentNode and FillNodes with a unique component_id, which ties them
|
||||
together. And then we tell SlotNode which component_id to use to be able to
|
||||
find the correct Component/Fill.
|
||||
|
||||
We don't want to store this info on the Nodes themselves, as we need to treat
|
||||
them as immutable due to caching of Templates by Django.
|
||||
|
||||
Hence, we use the Context to store the associations of SlotNode <-> Component
|
||||
for the current context stack.
|
||||
"""
|
||||
# Store associations on the latest context layer so that we can nest components
|
||||
# onto themselves (component A is rendered in slot fill of component A).
|
||||
# Otherwise, they would overwrite each other as the ComponentNode and SlotNodes
|
||||
# are re-used, so their IDs don't change across these two occurences.
|
||||
latest_dict = context.dicts[-1]
|
||||
if _SLOT_COMPONENT_ASSOC_KEY not in latest_dict:
|
||||
latest_dict[_SLOT_COMPONENT_ASSOC_KEY] = context[_SLOT_COMPONENT_ASSOC_KEY].copy()
|
||||
context[_SLOT_COMPONENT_ASSOC_KEY][slot_id] = component_id
|
||||
|
||||
|
||||
def get_slot_component_association(context: Context, slot_id: str) -> str:
|
||||
"""
|
||||
Given a slot ID, get the component ID that this slot is associated with
|
||||
in this context.
|
||||
|
||||
See `set_slot_component_association` for more details.
|
||||
"""
|
||||
return context[_SLOT_COMPONENT_ASSOC_KEY][slot_id]
|
||||
context[_PARENT_COMP_CONTEXT_KEY] = context.get(_CURRENT_COMP_CONTEXT_KEY, None)
|
||||
context[_CURRENT_COMP_CONTEXT_KEY] = component_id
|
||||
|
||||
|
||||
def copy_forloop_context(from_context: Context, to_context: Context) -> None:
|
||||
|
|
|
@ -63,7 +63,7 @@ def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> No
|
|||
|
||||
def trace_msg(
|
||||
action: Literal["PARSE", "ASSOC", "RENDR", "GET", "SET"],
|
||||
node_type: Literal["COMP", "FILL", "SLOT", "IFSB", "N/A"],
|
||||
node_type: Literal["COMP", "FILL", "SLOT", "N/A"],
|
||||
node_name: str,
|
||||
node_id: str,
|
||||
msg: str = "",
|
||||
|
@ -80,7 +80,7 @@ def trace_msg(
|
|||
if not component_id:
|
||||
raise ValueError("component_id must be set for the ASSOC action")
|
||||
msg_prefix = f"TO COMP {component_id}"
|
||||
elif action == "RENDR" and node_type != "COMP":
|
||||
elif action == "RENDR" and node_type == "FILL":
|
||||
if not component_id:
|
||||
raise ValueError("component_id must be set for the RENDER action")
|
||||
msg_prefix = f"FOR COMP {component_id}"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Callable
|
||||
from typing import Callable, List, NamedTuple, Optional
|
||||
|
||||
from django.template.base import Node, NodeList, TextNode
|
||||
from django.template.defaulttags import CommentNode
|
||||
|
@ -15,13 +15,20 @@ def nodelist_has_content(nodelist: NodeList) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def walk_nodelist(nodes: NodeList, callback: Callable[[Node], None]) -> None:
|
||||
class NodeTraverse(NamedTuple):
|
||||
node: Node
|
||||
parent: Optional["NodeTraverse"]
|
||||
|
||||
|
||||
def walk_nodelist(nodes: NodeList, callback: Callable[[Node], Optional[str]]) -> None:
|
||||
"""Recursively walk a NodeList, calling `callback` for each Node."""
|
||||
node_queue = [*nodes]
|
||||
node_queue: List[NodeTraverse] = [NodeTraverse(node=node, parent=None) for node in nodes]
|
||||
while len(node_queue):
|
||||
node: Node = node_queue.pop()
|
||||
callback(node)
|
||||
node_queue.extend(get_node_children(node))
|
||||
traverse = node_queue.pop()
|
||||
callback(traverse)
|
||||
child_nodes = get_node_children(traverse.node)
|
||||
child_traverses = [NodeTraverse(node=child_node, parent=traverse) for child_node in child_nodes]
|
||||
node_queue.extend(child_traverses)
|
||||
|
||||
|
||||
def get_node_children(node: Node) -> NodeList:
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import difflib
|
||||
import json
|
||||
from copy import copy
|
||||
from typing import Dict, List, NamedTuple, Optional, Set, Type, Union
|
||||
import re
|
||||
from collections import deque
|
||||
from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple, Type
|
||||
|
||||
from django.template import Context, Template
|
||||
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode
|
||||
|
@ -9,33 +10,75 @@ from django.template.defaulttags import CommentNode
|
|||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components.app_settings import SlotContextBehavior, app_settings
|
||||
from django_components.context import (
|
||||
copy_forloop_context,
|
||||
get_outer_root_context,
|
||||
get_slot_component_association,
|
||||
get_slot_fill,
|
||||
set_slot_fill,
|
||||
)
|
||||
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.logger import trace_msg
|
||||
from django_components.node import nodelist_has_content
|
||||
from django_components.node import NodeTraverse, nodelist_has_content, walk_nodelist
|
||||
from django_components.utils import gen_id
|
||||
|
||||
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
|
||||
|
||||
# Type aliases
|
||||
|
||||
SlotId = str
|
||||
SlotName = str
|
||||
AliasName = str
|
||||
|
||||
|
||||
class FillContent(NamedTuple):
|
||||
"""Data passed from component to slot to render that slot"""
|
||||
"""
|
||||
This represents content set with the `{% fill %}` tag, e.g.:
|
||||
|
||||
```django
|
||||
{% component "my_comp" %}
|
||||
{% fill "first_slot" %} <--- This
|
||||
hi
|
||||
{{ my_var }}
|
||||
hello
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
"""
|
||||
|
||||
nodes: NodeList
|
||||
alias: Optional[AliasName]
|
||||
|
||||
|
||||
class Slot(NamedTuple):
|
||||
"""
|
||||
This represents content set with the `{% slot %}` tag, e.g.:
|
||||
|
||||
```django
|
||||
{% slot "my_comp" default %} <--- This
|
||||
hi
|
||||
{{ my_var }}
|
||||
hello
|
||||
{% endslot %}
|
||||
```
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
is_default: bool
|
||||
is_required: bool
|
||||
nodelist: NodeList
|
||||
|
||||
|
||||
class SlotFill(NamedTuple):
|
||||
"""
|
||||
SlotFill describes what WILL be rendered.
|
||||
|
||||
It is a Slot that has been resolved against FillContents passed to a Component.
|
||||
"""
|
||||
|
||||
name: str
|
||||
escaped_name: str
|
||||
is_filled: bool
|
||||
nodelist: NodeList
|
||||
context_data: Dict
|
||||
alias: Optional[AliasName]
|
||||
|
||||
|
||||
class UserSlotVar:
|
||||
"""
|
||||
Extensible mechanism for offering 'fill' blocks in template access to properties
|
||||
|
@ -56,32 +99,6 @@ class UserSlotVar:
|
|||
return mark_safe(self._slot.nodelist.render(self._context))
|
||||
|
||||
|
||||
class ComponentIdMixin:
|
||||
"""
|
||||
Mixin for classes use or pass through component ID.
|
||||
|
||||
We use component IDs to identify which slots should be
|
||||
rendered with which fills for which components.
|
||||
"""
|
||||
|
||||
_component_id: str
|
||||
|
||||
@property
|
||||
def component_id(self) -> str:
|
||||
try:
|
||||
return self._component_id
|
||||
except AttributeError:
|
||||
raise RuntimeError(
|
||||
f"Internal error: Instance of {type(self).__name__} was not "
|
||||
"linked to Component before use in render() context. "
|
||||
"Make sure that the 'component_id' field is set."
|
||||
)
|
||||
|
||||
@component_id.setter
|
||||
def component_id(self, value: Template) -> None:
|
||||
self._component_id = value
|
||||
|
||||
|
||||
class SlotNode(Node):
|
||||
def __init__(
|
||||
self,
|
||||
|
@ -99,70 +116,56 @@ class SlotNode(Node):
|
|||
|
||||
@property
|
||||
def active_flags(self) -> List[str]:
|
||||
m = []
|
||||
flags = []
|
||||
if self.is_required:
|
||||
m.append("required")
|
||||
flags.append("required")
|
||||
if self.is_default:
|
||||
m.append("default")
|
||||
return m
|
||||
flags.append("default")
|
||||
return flags
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
|
||||
|
||||
def render(self, context: Context) -> SafeString:
|
||||
component_id = get_slot_component_association(context, self.node_id)
|
||||
trace_msg("RENDR", "SLOT", self.name, self.node_id, component_id=component_id)
|
||||
trace_msg("RENDR", "SLOT", self.name, self.node_id)
|
||||
slots: dict[SlotId, "SlotFill"] = context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY]
|
||||
# NOTE: Slot entry MUST be present. If it's missing, there was an issue upstream.
|
||||
slot_fill = slots[self.node_id]
|
||||
|
||||
slot_fill_content = get_slot_fill(context, component_id, self.name)
|
||||
# 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)
|
||||
|
||||
# Slot fill was NOT found. Will render the default fill
|
||||
if slot_fill_content is None:
|
||||
if self.is_required:
|
||||
raise TemplateSyntaxError(
|
||||
f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), " f"yet no fill is provided. "
|
||||
)
|
||||
nodelist = self.nodelist
|
||||
|
||||
# Slot fill WAS found
|
||||
else:
|
||||
nodelist, alias = slot_fill_content
|
||||
if alias:
|
||||
if not alias.isidentifier():
|
||||
raise TemplateSyntaxError()
|
||||
extra_context[alias] = UserSlotVar(self, context)
|
||||
|
||||
used_ctx = self.resolve_slot_context(context)
|
||||
# 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)
|
||||
with used_ctx.update(extra_context):
|
||||
output = nodelist.render(used_ctx)
|
||||
output = slot_fill.nodelist.render(used_ctx)
|
||||
|
||||
trace_msg("RENDR", "SLOT", self.name, self.node_id, component_id=component_id, msg="...Done!")
|
||||
trace_msg("RENDR", "SLOT", self.name, self.node_id, msg="...Done!")
|
||||
return output
|
||||
|
||||
def resolve_slot_context(self, context: Context) -> Context:
|
||||
"""
|
||||
Prepare the context used in a slot fill based on the settings.
|
||||
|
||||
See SlotContextBehavior for the description of each option.
|
||||
"""
|
||||
root_ctx = get_outer_root_context(context) or Context()
|
||||
|
||||
if app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ALLOW_OVERRIDE:
|
||||
def _resolve_slot_context(self, context: Context, slot_fill: "SlotFill") -> Context:
|
||||
"""Prepare the context used in a slot fill based on the settings."""
|
||||
# If slot is NOT filled, we use the slot's default AKA content between
|
||||
# the `{% slot %}` tags. These should be evaluated as if the `{% slot %}`
|
||||
# tags weren't even there, which means that we use the current context.
|
||||
if not slot_fill.is_filled:
|
||||
return context
|
||||
elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ISOLATED:
|
||||
new_context: Context = copy(root_ctx)
|
||||
copy_forloop_context(context, new_context)
|
||||
return new_context
|
||||
elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.PREFER_ROOT:
|
||||
new_context = copy(context)
|
||||
new_context.update(root_ctx.flatten())
|
||||
return new_context
|
||||
|
||||
if app_settings.CONTEXT_BEHAVIOR == ContextBehavior.DJANGO:
|
||||
return context
|
||||
elif app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED:
|
||||
return context[_ROOT_CTX_CONTEXT_KEY]
|
||||
else:
|
||||
raise ValueError(f"Unknown value for SLOT_CONTEXT_BEHAVIOR: '{app_settings.SLOT_CONTEXT_BEHAVIOR}'")
|
||||
raise ValueError(f"Unknown value for CONTEXT_BEHAVIOR: '{app_settings.CONTEXT_BEHAVIOR}'")
|
||||
|
||||
|
||||
class FillNode(Node, ComponentIdMixin):
|
||||
is_implicit: bool
|
||||
class FillNode(Node):
|
||||
"""
|
||||
Set when a `component` tag pair is passed template content that
|
||||
excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked
|
||||
|
@ -182,6 +185,7 @@ class FillNode(Node, ComponentIdMixin):
|
|||
self.name_fexp = name_fexp
|
||||
self.alias_fexp = alias_fexp
|
||||
self.is_implicit = is_implicit
|
||||
self.component_id: Optional[str] = None
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
raise TemplateSyntaxError(
|
||||
|
@ -207,68 +211,6 @@ class FillNode(Node, ComponentIdMixin):
|
|||
return resolved_alias
|
||||
|
||||
|
||||
class _IfSlotFilledBranchNode(Node):
|
||||
def __init__(self, nodelist: NodeList) -> None:
|
||||
self.nodelist = nodelist
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
return self.nodelist.render(context)
|
||||
|
||||
def evaluate(self, context: Context) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, ComponentIdMixin):
|
||||
def __init__(
|
||||
self,
|
||||
slot_name: str,
|
||||
nodelist: NodeList,
|
||||
is_positive: Union[bool, None] = True,
|
||||
node_id: Optional[str] = None,
|
||||
) -> None:
|
||||
self.slot_name = slot_name
|
||||
self.is_positive: Optional[bool] = is_positive
|
||||
self.node_id = node_id or gen_id()
|
||||
super().__init__(nodelist)
|
||||
|
||||
def evaluate(self, context: Context) -> bool:
|
||||
slot_fill = get_slot_fill(context, component_id=self.component_id, slot_name=self.slot_name)
|
||||
is_filled = slot_fill is not None
|
||||
# Make polarity switchable.
|
||||
# i.e. if slot name is NOT filled and is_positive=False,
|
||||
# then False == False -> True
|
||||
return is_filled == self.is_positive
|
||||
|
||||
|
||||
class IfSlotFilledElseBranchNode(_IfSlotFilledBranchNode):
|
||||
def evaluate(self, context: Context) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class IfSlotFilledNode(Node):
|
||||
def __init__(
|
||||
self,
|
||||
branches: List[_IfSlotFilledBranchNode],
|
||||
):
|
||||
self.branches = branches
|
||||
self.nodelist = self._create_nodelist(branches)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}>"
|
||||
|
||||
def _create_nodelist(self, branches: List[_IfSlotFilledBranchNode]) -> NodeList:
|
||||
return NodeList(branches)
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
for node in self.branches:
|
||||
if isinstance(node, IfSlotFilledElseBranchNode):
|
||||
return node.render(context)
|
||||
elif isinstance(node, IfSlotFilledConditionBranchNode):
|
||||
if node.evaluate(context):
|
||||
return node.render(context)
|
||||
return ""
|
||||
|
||||
|
||||
def parse_slot_fill_nodes_from_component_nodelist(
|
||||
component_nodelist: NodeList,
|
||||
ComponentNodeCls: Type[Node],
|
||||
|
@ -360,115 +302,187 @@ def _try_parse_as_default_fill(
|
|||
]
|
||||
|
||||
|
||||
def render_component_template_with_slots(
|
||||
component_id: str,
|
||||
####################
|
||||
# SLOT RESOLUTION
|
||||
####################
|
||||
|
||||
|
||||
def resolve_slots(
|
||||
template: Template,
|
||||
context: Context,
|
||||
fill_content: Dict[str, FillContent],
|
||||
registered_name: Optional[str],
|
||||
) -> str:
|
||||
component_name: Optional[str],
|
||||
context_data: Dict[str, Any],
|
||||
fill_content: Dict[SlotName, FillContent],
|
||||
) -> Tuple[Dict[SlotId, Slot], Dict[SlotId, SlotFill]]:
|
||||
"""
|
||||
This function first prepares the template to be able to render the fills
|
||||
in the place of slots, and then renders the template with given context.
|
||||
Search the template for all SlotNodes, and associate the slots
|
||||
with the given fills.
|
||||
|
||||
NOTE: The nodes in the template are mutated in the process!
|
||||
Returns tuple of:
|
||||
- Slots defined in the component's Template with `{% slot %}` tag
|
||||
- SlotFills (AKA slots matched with fills) describing what will be rendered for each slot.
|
||||
"""
|
||||
# ---- Prepare slot fills ----
|
||||
slot_name2fill_content = _collect_slot_fills_from_component_template(template, fill_content, registered_name)
|
||||
slot_fills = {
|
||||
name: SlotFill(
|
||||
name=name,
|
||||
escaped_name=_escape_slot_name(name),
|
||||
is_filled=True,
|
||||
nodelist=fill.nodes,
|
||||
context_data=context_data,
|
||||
alias=fill.alias,
|
||||
)
|
||||
for name, fill in fill_content.items()
|
||||
}
|
||||
|
||||
# Give slot nodes knowledge of their parent component.
|
||||
for node in template.nodelist.get_nodes_by_type(IfSlotFilledConditionBranchNode):
|
||||
if isinstance(node, IfSlotFilledConditionBranchNode):
|
||||
trace_msg("ASSOC", "IFSB", node.slot_name, node.node_id, component_id=component_id)
|
||||
node.component_id = component_id
|
||||
slots: Dict[SlotId, Slot] = {}
|
||||
# This holds info on which slot (key) has which slots nested in it (value list)
|
||||
slot_children: Dict[SlotId, List[SlotId]] = {}
|
||||
|
||||
with context.update({}):
|
||||
for slot_name, content_data in slot_name2fill_content.items():
|
||||
# Slots whose content is None (i.e. unfilled) are dropped.
|
||||
if not content_data:
|
||||
continue
|
||||
set_slot_fill(context, component_id, slot_name, content_data)
|
||||
|
||||
# ---- Render ----
|
||||
return template.render(context)
|
||||
|
||||
|
||||
def _collect_slot_fills_from_component_template(
|
||||
template: Template,
|
||||
fill_content: Dict[str, FillContent],
|
||||
registered_name: Optional[str],
|
||||
) -> Dict[SlotName, Optional[FillContent]]:
|
||||
if DEFAULT_SLOT_KEY in fill_content:
|
||||
named_fills_content = fill_content.copy()
|
||||
default_fill_content = named_fills_content.pop(DEFAULT_SLOT_KEY)
|
||||
else:
|
||||
named_fills_content = fill_content
|
||||
default_fill_content = None
|
||||
|
||||
# If value is `None`, then slot is unfilled.
|
||||
slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {}
|
||||
default_slot_encountered: bool = False
|
||||
required_slot_names: Set[str] = set()
|
||||
|
||||
# Collect fills and check for errors
|
||||
for node in template.nodelist.get_nodes_by_type(SlotNode):
|
||||
# Type check so the rest of the logic has type of `node` is inferred
|
||||
def on_node(entry: NodeTraverse) -> None:
|
||||
node = entry.node
|
||||
if not isinstance(node, SlotNode):
|
||||
return
|
||||
|
||||
# 1. Collect slots
|
||||
# Basically we take all the important info form the SlotNode, so the logic is
|
||||
# less coupled to Django's Template/Node. Plain tuples should also help with
|
||||
# troubleshooting.
|
||||
slot = Slot(
|
||||
id=node.node_id,
|
||||
name=node.name,
|
||||
nodelist=node.nodelist,
|
||||
is_default=node.is_default,
|
||||
is_required=node.is_required,
|
||||
)
|
||||
slots[node.node_id] = slot
|
||||
|
||||
# 2. Figure out which Slots are nested in other Slots, so we can render
|
||||
# them from outside-inwards, so we can skip inner Slots if fills are provided.
|
||||
# We should end up with a graph-like data like:
|
||||
# - 0001: [0002]
|
||||
# - 0002: []
|
||||
# - 0003: [0004]
|
||||
# In other words, the data tells us that slot ID 0001 is PARENT of slot 0002.
|
||||
curr_entry = entry.parent
|
||||
while curr_entry and curr_entry.parent is not None:
|
||||
if not isinstance(curr_entry.node, SlotNode):
|
||||
curr_entry = curr_entry.parent
|
||||
continue
|
||||
|
||||
slot_name = node.name
|
||||
parent_slot_id = curr_entry.node.node_id
|
||||
if parent_slot_id not in slot_children:
|
||||
slot_children[parent_slot_id] = []
|
||||
slot_children[parent_slot_id].append(node.node_id)
|
||||
break
|
||||
|
||||
# If true then the template contains multiple slot of the same name.
|
||||
# No action needed, since even tho there's mutliple slots, we will
|
||||
# still apply only a single fill to all of them. And each slot handles
|
||||
# their own fallback content.
|
||||
if slot_name in slot_name2fill_content:
|
||||
walk_nodelist(template.nodelist, on_node)
|
||||
|
||||
# 3. Figure out which slot the default/implicit fill belongs to
|
||||
slot_fills = _resolve_default_slot(
|
||||
template_name=template.name,
|
||||
component_name=component_name,
|
||||
slots=slots,
|
||||
slot_fills=slot_fills,
|
||||
)
|
||||
|
||||
# 4. Detect any errors with slots/fills
|
||||
_report_slot_errors(slots, slot_fills, component_name)
|
||||
|
||||
# 5. Find roots of the slot relationships
|
||||
top_level_slot_ids: List[SlotId] = []
|
||||
for node_id, slot in slots.items():
|
||||
if node_id not in slot_children or not slot_children[node_id]:
|
||||
top_level_slot_ids.append(node_id)
|
||||
|
||||
# 6. Walk from out-most slots inwards, and decide whether and how
|
||||
# we will render each slot.
|
||||
resolved_slots: Dict[SlotId, SlotFill] = {}
|
||||
slot_ids_queue = deque([*top_level_slot_ids])
|
||||
while len(slot_ids_queue):
|
||||
slot_id = slot_ids_queue.pop()
|
||||
slot = slots[slot_id]
|
||||
|
||||
# Check if there is a slot fill for given slot name
|
||||
if slot.name in slot_fills:
|
||||
# If yes, we remember which slot we want to replace with already-rendered fills
|
||||
resolved_slots[slot_id] = slot_fills[slot.name]
|
||||
# Since the fill cannot include other slots, we can leave this path
|
||||
continue
|
||||
else:
|
||||
# If no, then the slot is NOT filled, and we will render the slot's default (what's
|
||||
# between the slot tags)
|
||||
resolved_slots[slot_id] = SlotFill(
|
||||
name=slot.name,
|
||||
escaped_name=_escape_slot_name(slot.name),
|
||||
is_filled=False,
|
||||
nodelist=slot.nodelist,
|
||||
context_data=context_data,
|
||||
alias=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
|
||||
if slot_id in slot_children and slot_children[slot_id]:
|
||||
slot_ids_queue.extend(slot_children[slot_id])
|
||||
|
||||
if node.is_required:
|
||||
required_slot_names.add(node.name)
|
||||
# By the time we get here, we should know, for each slot, how it will be rendered
|
||||
# -> Whether it will be replaced with a fill, or whether we render slot's defaults.
|
||||
return slots, resolved_slots
|
||||
|
||||
content_data: Optional[FillContent] = None # `None` -> unfilled
|
||||
if node.is_default:
|
||||
|
||||
def _resolve_default_slot(
|
||||
template_name: str,
|
||||
component_name: Optional[str],
|
||||
slots: Dict[SlotId, Slot],
|
||||
slot_fills: Dict[SlotName, SlotFill],
|
||||
) -> Dict[SlotName, SlotFill]:
|
||||
"""Figure out which slot the default fill refers to, and perform checks."""
|
||||
named_fills = slot_fills.copy()
|
||||
|
||||
if DEFAULT_SLOT_KEY in named_fills:
|
||||
default_fill = named_fills.pop(DEFAULT_SLOT_KEY)
|
||||
else:
|
||||
default_fill = None
|
||||
|
||||
default_slot_encountered: bool = False
|
||||
|
||||
# Check for errors
|
||||
for slot in slots.values():
|
||||
if slot.is_default:
|
||||
if default_slot_encountered:
|
||||
raise TemplateSyntaxError(
|
||||
"Only one component slot may be marked as 'default'. "
|
||||
f"To fix, check template '{template.name}' "
|
||||
f"of component '{registered_name}'."
|
||||
f"To fix, check template '{template_name}' "
|
||||
f"of component '{component_name}'."
|
||||
)
|
||||
content_data = default_fill_content
|
||||
default_slot_encountered = True
|
||||
|
||||
# If default fill was not found, try to fill it with named slot
|
||||
# Effectively, this allows to fill in default slot as named ones.
|
||||
if not content_data:
|
||||
content_data = named_fills_content.get(node.name)
|
||||
|
||||
slot_name2fill_content[slot_name] = content_data
|
||||
# Here we've identified which slot the default/implicit fill belongs to
|
||||
if default_fill:
|
||||
named_fills[slot.name] = default_fill._replace(name=slot.name)
|
||||
|
||||
# Check: Only component templates that include a 'default' slot
|
||||
# can be invoked with implicit filling.
|
||||
if default_fill_content and not default_slot_encountered:
|
||||
if default_fill and not default_slot_encountered:
|
||||
raise TemplateSyntaxError(
|
||||
f"Component '{registered_name}' passed default fill content '{default_fill_content}'"
|
||||
f"Component '{component_name}' passed default fill content '{default_fill.name}'"
|
||||
f"(i.e. without explicit 'fill' tag), "
|
||||
f"even though none of its slots is marked as 'default'."
|
||||
)
|
||||
|
||||
unfilled_slots: Set[str] = {k for k, v in slot_name2fill_content.items() if v is None}
|
||||
unmatched_fills: Set[str] = named_fills_content.keys() - slot_name2fill_content.keys()
|
||||
|
||||
_report_slot_errors(unfilled_slots, unmatched_fills, registered_name, required_slot_names)
|
||||
|
||||
return slot_name2fill_content
|
||||
return named_fills
|
||||
|
||||
|
||||
def _report_slot_errors(
|
||||
unfilled_slots: Set[str],
|
||||
unmatched_fills: Set[str],
|
||||
slots: Dict[SlotId, Slot],
|
||||
slot_fills: Dict[SlotName, SlotFill],
|
||||
registered_name: Optional[str],
|
||||
required_slot_names: Set[str],
|
||||
) -> None:
|
||||
slots_by_name = {slot.name: slot for slot in slots.values()}
|
||||
unfilled_slots: Set[str] = {slot.name for slot in slots.values() if slot.name not in slot_fills}
|
||||
unmatched_fills: Set[str] = {
|
||||
slot_fill.name for slot_fill in slot_fills.values() if slot_fill.name not in slots_by_name
|
||||
}
|
||||
required_slot_names: Set[str] = set([slot.name for slot in slots.values() if slot.is_required])
|
||||
|
||||
# Check that 'required' slots are filled.
|
||||
for slot_name in unfilled_slots:
|
||||
if slot_name in required_slot_names:
|
||||
|
@ -499,3 +513,22 @@ def _report_slot_errors(
|
|||
if fuzzy_slot_name_matches:
|
||||
msg += f"\nDid you mean '{fuzzy_slot_name_matches[0]}'?"
|
||||
raise TemplateSyntaxError(msg)
|
||||
|
||||
|
||||
name_escape_re = re.compile(r"[^\w]")
|
||||
|
||||
|
||||
def _escape_slot_name(name: str) -> str:
|
||||
"""
|
||||
Users may define slots with names which are invalid identifiers like 'my slot'.
|
||||
But these cannot be used as keys in the template context, e.g. `{{ component_vars.is_filled.'my slot' }}`.
|
||||
So as workaround, we instead use these escaped names which are valid identifiers.
|
||||
|
||||
So e.g. `my slot` should be escaped as `my_slot`.
|
||||
"""
|
||||
# NOTE: Do a simple substitution where we replace all non-identifier characters with `_`.
|
||||
# Identifiers consist of alphanum (a-zA-Z0-9) and underscores.
|
||||
# We don't check if these escaped names conflict with other existing slots in the template,
|
||||
# we leave this obligation to the user.
|
||||
escaped_name = name_escape_re.sub("_", name)
|
||||
return escaped_name
|
||||
|
|
|
@ -6,7 +6,7 @@ from django.template.exceptions import TemplateSyntaxError
|
|||
from django.template.library import parse_bits
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components.app_settings import app_settings
|
||||
from django_components.app_settings import ContextBehavior, app_settings
|
||||
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
|
||||
|
@ -16,15 +16,7 @@ from django_components.middleware import (
|
|||
JS_DEPENDENCY_PLACEHOLDER,
|
||||
is_dependency_middleware_active,
|
||||
)
|
||||
from django_components.slots import (
|
||||
FillNode,
|
||||
IfSlotFilledConditionBranchNode,
|
||||
IfSlotFilledElseBranchNode,
|
||||
IfSlotFilledNode,
|
||||
SlotNode,
|
||||
_IfSlotFilledBranchNode,
|
||||
parse_slot_fill_nodes_from_component_nodelist,
|
||||
)
|
||||
from django_components.slots import FillNode, SlotNode, parse_slot_fill_nodes_from_component_nodelist
|
||||
from django_components.utils import gen_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
@ -268,109 +260,13 @@ def is_block_tag_token(token: Token, name: str) -> bool:
|
|||
return token.token_type == TokenType.BLOCK and token.split_contents()[0] == name
|
||||
|
||||
|
||||
@register.tag(name="if_filled")
|
||||
def do_if_filled_block(parser: Parser, token: Token) -> "IfSlotFilledNode":
|
||||
"""
|
||||
### Usage
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
{% if_filled <slot> (<bool>) %}
|
||||
...
|
||||
{% elif_filled <slot> (<bool>) %}
|
||||
...
|
||||
{% else_filled %}
|
||||
...
|
||||
{% endif_filled %}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
Optional arg `<bool>` is True by default.
|
||||
If a False is provided instead, the effect is a negation of the `if_filled` check:
|
||||
The behavior is analogous to `if not is_filled <slot>`.
|
||||
This design prevents us having to define a separate `if_unfilled` tag.
|
||||
"""
|
||||
bits = token.split_contents()
|
||||
starting_tag = bits[0]
|
||||
slot_name, is_positive = parse_if_filled_bits(bits)
|
||||
nodelist: NodeList = parser.parse(("elif_filled", "else_filled", "endif_filled"))
|
||||
branches: List[_IfSlotFilledBranchNode] = [
|
||||
IfSlotFilledConditionBranchNode(
|
||||
slot_name=slot_name, # type: ignore
|
||||
nodelist=nodelist,
|
||||
is_positive=is_positive,
|
||||
)
|
||||
]
|
||||
|
||||
token = parser.next_token()
|
||||
|
||||
# {% elif_filled <slot> (<is_positive>) %} (repeatable)
|
||||
while token.contents.startswith("elif_filled"):
|
||||
bits = token.split_contents()
|
||||
slot_name, is_positive = parse_if_filled_bits(bits)
|
||||
nodelist = parser.parse(("elif_filled", "else_filled", "endif_filled"))
|
||||
branches.append(
|
||||
IfSlotFilledConditionBranchNode(
|
||||
slot_name=slot_name, # type: ignore
|
||||
nodelist=nodelist,
|
||||
is_positive=is_positive,
|
||||
)
|
||||
)
|
||||
|
||||
token = parser.next_token()
|
||||
|
||||
# {% else_filled %} (optional)
|
||||
if token.contents.startswith("else_filled"):
|
||||
bits = token.split_contents()
|
||||
_, _ = parse_if_filled_bits(bits)
|
||||
nodelist = parser.parse(("endif_filled",))
|
||||
branches.append(IfSlotFilledElseBranchNode(nodelist))
|
||||
token = parser.next_token()
|
||||
|
||||
# {% endif_filled %}
|
||||
if token.contents != "endif_filled":
|
||||
raise TemplateSyntaxError(
|
||||
f"{{% {starting_tag} %}} missing closing {{% endif_filled %}} tag"
|
||||
f" at line {token.lineno}: '{token.contents}'"
|
||||
)
|
||||
|
||||
return IfSlotFilledNode(branches)
|
||||
|
||||
|
||||
def parse_if_filled_bits(
|
||||
bits: List[str],
|
||||
) -> Tuple[Optional[str], Optional[bool]]:
|
||||
tag, args = bits[0], bits[1:]
|
||||
if tag in ("else_filled", "endif_filled"):
|
||||
if len(args) != 0:
|
||||
raise TemplateSyntaxError(f"Tag '{tag}' takes no arguments. Received '{' '.join(args)}'")
|
||||
else:
|
||||
return None, None
|
||||
if len(args) == 1:
|
||||
slot_name = args[0]
|
||||
is_positive = True
|
||||
elif len(args) == 2:
|
||||
slot_name = args[0]
|
||||
is_positive = bool_from_string(args[1])
|
||||
else:
|
||||
raise TemplateSyntaxError(
|
||||
f"{bits[0]} tag arguments '{' '.join(args)}' do not match pattern " f"'<slotname> (<is_positive>)'"
|
||||
)
|
||||
if not is_wrapped_in_quotes(slot_name):
|
||||
raise TemplateSyntaxError(f"First argument of '{bits[0]}' must be a quoted string 'literal'.")
|
||||
slot_name = strip_quotes(slot_name)
|
||||
return slot_name, is_positive
|
||||
|
||||
|
||||
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":
|
||||
return bits[:-1], True
|
||||
|
||||
if app_settings.CONTEXT_BEHAVIOR == "isolated":
|
||||
if app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED:
|
||||
return bits, True
|
||||
|
||||
return bits, False
|
||||
|
@ -416,13 +312,3 @@ def is_wrapped_in_quotes(s: str) -> bool:
|
|||
|
||||
def strip_quotes(s: str) -> str:
|
||||
return s.strip("\"'")
|
||||
|
||||
|
||||
def bool_from_string(s: str) -> bool:
|
||||
s = strip_quotes(s.lower())
|
||||
if s == "true":
|
||||
return True
|
||||
elif s == "false":
|
||||
return False
|
||||
else:
|
||||
raise TemplateSyntaxError(f"Expected a bool value. Received: '{s}'")
|
||||
|
|
10
tests/templates/template_is_filled.html
Normal file
10
tests/templates/template_is_filled.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
{% load component_tags %}
|
||||
<div class="frontmatter-component">
|
||||
{% slot "title" %}{% endslot %}
|
||||
{% slot "my_title" %}{% endslot %}
|
||||
{% slot "my title 1" %}{% endslot %}
|
||||
{% slot "my-title-2" %}{% endslot %}
|
||||
{% slot "escape this: #$%^*()" %}{% endslot %}
|
||||
|
||||
{{ component_vars.is_filled }}
|
||||
</div>
|
|
@ -2,7 +2,7 @@
|
|||
{% load component_tags %}
|
||||
<div class="frontmatter-component">
|
||||
<div class="title">{% slot "title" %}Title{% endslot %}</div>
|
||||
{% if_filled "subtitle" %}
|
||||
{% if component_vars.is_filled.subtitle %}
|
||||
<div class="subtitle">{% slot "subtitle" %}Optional subtitle{% endslot %}</div>
|
||||
{% endif_filled %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
{% load component_tags %}
|
||||
<div class="frontmatter-component">
|
||||
<div class="title">{% slot "title" %}Title{% endslot %}</div>
|
||||
{% if_filled 'subtitle'%}
|
||||
{% if component_vars.is_filled.subtitle %}
|
||||
<div class="subtitle">{% slot "subtitle" %}Optional subtitle{% endslot %}</div>
|
||||
{% elif_filled "alt_subtitle" %}
|
||||
{% elif component_vars.is_filled.alt_subtitle %}
|
||||
<div class="subtitle">{% slot "alt_subtitle" %}Why would you want this?{% endslot %}</div>
|
||||
{% else_filled %}
|
||||
{% else %}
|
||||
<div class="warning">Nothing filled!</div>
|
||||
{% endif_filled %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
{% load component_tags %}
|
||||
<div class="frontmatter-component">
|
||||
<div class="title">{% slot "title" %}Title{% endslot %}</div>
|
||||
{% if_filled "subtitle" False %}
|
||||
{% if not component_vars.is_filled.subtitle %}
|
||||
<div class="warning">Subtitle not filled!</div>
|
||||
{% else_filled %}
|
||||
{% else %}
|
||||
<div class="subtitle">{% slot "alt_subtitle" %}Why would you want this?{% endslot %}</div>
|
||||
{% endif_filled %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
@ -368,7 +368,6 @@ class ComponentTest(BaseTestCase):
|
|||
@override_settings(
|
||||
COMPONENTS={
|
||||
"context_behavior": "isolated",
|
||||
"slot_context_behavior": "isolated",
|
||||
},
|
||||
)
|
||||
def test_slots_of_top_level_comps_can_access_full_outer_ctx(self):
|
||||
|
@ -387,15 +386,16 @@ class ComponentTest(BaseTestCase):
|
|||
{% load component_tags %}
|
||||
<body>
|
||||
{% component "test" %}
|
||||
ABC: {{ name }}
|
||||
ABC: {{ name }} {{ some }}
|
||||
{% endcomponent %}
|
||||
</body>
|
||||
"""
|
||||
)
|
||||
|
||||
nested_ctx = Context()
|
||||
nested_ctx.push({"some": "var"}) # <-- Nested comp's take data only from this layer
|
||||
nested_ctx.push({"name": "carl"}) # <-- But for top-level comp, it should access this layer too
|
||||
# Check that the component can access vars across different context layers
|
||||
nested_ctx.push({"some": "var"})
|
||||
nested_ctx.push({"name": "carl"})
|
||||
rendered = self.template.render(nested_ctx)
|
||||
|
||||
self.assertHTMLEqual(
|
||||
|
@ -403,7 +403,7 @@ class ComponentTest(BaseTestCase):
|
|||
"""
|
||||
<body>
|
||||
<div>
|
||||
<main> ABC: carl </main>
|
||||
<main> ABC: carl var </main>
|
||||
</div>
|
||||
</body>
|
||||
""",
|
||||
|
@ -805,7 +805,9 @@ class ComponentIsolationTests(BaseTestCase):
|
|||
|
||||
|
||||
class SlotBehaviorTests(BaseTestCase):
|
||||
def setUp(self):
|
||||
# NOTE: This is standalone function instead of setUp, so we can configure
|
||||
# Django settings per test with `@override_settings`
|
||||
def make_template(self) -> Template:
|
||||
class SlottedComponent(component.Component):
|
||||
template_name = "slotted_template.html"
|
||||
|
||||
|
@ -816,7 +818,7 @@ class SlotBehaviorTests(BaseTestCase):
|
|||
|
||||
component.registry.register("test", SlottedComponent)
|
||||
|
||||
self.template = Template(
|
||||
return Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "test" name='Igor' %}
|
||||
|
@ -841,11 +843,12 @@ class SlotBehaviorTests(BaseTestCase):
|
|||
)
|
||||
|
||||
@override_settings(
|
||||
COMPONENTS={"slot_context_behavior": "allow_override"},
|
||||
COMPONENTS={"context_behavior": "django"},
|
||||
)
|
||||
def test_slot_context_allow_override(self):
|
||||
def test_slot_context__django(self):
|
||||
template = self.make_template()
|
||||
# {{ name }} should be neither Jannete not empty, because overriden everywhere
|
||||
rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"}))
|
||||
rendered = template.render(Context({"day": "Monday", "name": "Jannete"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
|
@ -864,15 +867,16 @@ class SlotBehaviorTests(BaseTestCase):
|
|||
)
|
||||
|
||||
# {{ name }} should be effectively the same as before, because overriden everywhere
|
||||
rendered2 = self.template.render(Context({"day": "Monday"}))
|
||||
rendered2 = template.render(Context({"day": "Monday"}))
|
||||
self.assertHTMLEqual(rendered2, rendered)
|
||||
|
||||
@override_settings(
|
||||
COMPONENTS={"slot_context_behavior": "isolated"},
|
||||
COMPONENTS={"context_behavior": "isolated"},
|
||||
)
|
||||
def test_slot_context_isolated(self):
|
||||
def test_slot_context__isolated(self):
|
||||
template = self.make_template()
|
||||
# {{ name }} should be "Jannete" everywhere
|
||||
rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"}))
|
||||
rendered = template.render(Context({"day": "Monday", "name": "Jannete"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
|
@ -891,7 +895,7 @@ class SlotBehaviorTests(BaseTestCase):
|
|||
)
|
||||
|
||||
# {{ name }} should be empty everywhere
|
||||
rendered2 = self.template.render(Context({"day": "Monday"}))
|
||||
rendered2 = template.render(Context({"day": "Monday"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered2,
|
||||
"""
|
||||
|
@ -908,47 +912,3 @@ class SlotBehaviorTests(BaseTestCase):
|
|||
</custom-template>
|
||||
""",
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
COMPONENTS={
|
||||
"slot_context_behavior": "prefer_root",
|
||||
},
|
||||
)
|
||||
def test_slot_context_prefer_root(self):
|
||||
# {{ name }} should be "Jannete" everywhere
|
||||
rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<custom-template>
|
||||
<header>Name: Jannete</header>
|
||||
<main>Day: Monday</main>
|
||||
<footer>
|
||||
<custom-template>
|
||||
<header>Name2: Jannete</header>
|
||||
<main>Day2: Monday</main>
|
||||
<footer>Default footer</footer>
|
||||
</custom-template>
|
||||
</footer>
|
||||
</custom-template>
|
||||
""",
|
||||
)
|
||||
|
||||
# {{ name }} should be neither "Jannete" nor empty anywhere
|
||||
rendered = self.template.render(Context({"day": "Monday"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<custom-template>
|
||||
<header>Name: Igor</header>
|
||||
<main>Day: Monday</main>
|
||||
<footer>
|
||||
<custom-template>
|
||||
<header>Name2: Joe2</header>
|
||||
<main>Day2: Monday</main>
|
||||
<footer>Default footer</footer>
|
||||
</custom-template>
|
||||
</footer>
|
||||
</custom-template>
|
||||
""",
|
||||
)
|
||||
|
|
|
@ -213,17 +213,67 @@ class ParentArgsTests(BaseTestCase):
|
|||
component.registry.register(name="parent_with_args", component=ParentComponentWithArgs)
|
||||
component.registry.register(name="variable_display", component=VariableDisplay)
|
||||
|
||||
def test_parent_args_can_be_drawn_from_context(self):
|
||||
@override_settings(
|
||||
COMPONENTS={
|
||||
"context_behavior": "django",
|
||||
}
|
||||
)
|
||||
def test_parent_args_can_be_drawn_from_context__django(self):
|
||||
template = Template(
|
||||
"{% load component_tags %}{% component_dependencies %}"
|
||||
"{% component 'parent_with_args' parent_value=parent_value %}"
|
||||
"{% endcomponent %}"
|
||||
"""
|
||||
{% load component_tags %}{% component_dependencies %}
|
||||
{% component 'parent_with_args' parent_value=parent_value %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context({"parent_value": "passed_in"}))
|
||||
|
||||
self.assertIn("<h1>Shadowing variable = passed_in</h1>", rendered, rendered)
|
||||
self.assertIn("<h1>Uniquely named variable = passed_in</h1>", rendered, rendered)
|
||||
self.assertNotIn("<h1>Shadowing variable = NOT SHADOWED</h1>", rendered, rendered)
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div>
|
||||
<h1>Parent content</h1>
|
||||
<h1>Shadowing variable = passed_in</h1>
|
||||
<h1>Uniquely named variable = unique_val</h1>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Slot content</h2>
|
||||
<h1>Shadowing variable = slot_default_override</h1>
|
||||
<h1>Uniquely named variable = passed_in</h1>
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
COMPONENTS={
|
||||
"context_behavior": "isolated",
|
||||
}
|
||||
)
|
||||
def test_parent_args_can_be_drawn_from_context__isolated(self):
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}{% component_dependencies %}
|
||||
{% component 'parent_with_args' parent_value=parent_value %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context({"parent_value": "passed_in"}))
|
||||
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div>
|
||||
<h1>Parent content</h1>
|
||||
<h1>Shadowing variable = passed_in</h1>
|
||||
<h1>Uniquely named variable = unique_val</h1>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Slot content</h2>
|
||||
<h1>Shadowing variable = slot_default_override</h1>
|
||||
<h1>Uniquely named variable = passed_in</h1>
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
|
||||
def test_parent_args_available_outside_slots(self):
|
||||
template = Template(
|
||||
|
@ -236,7 +286,12 @@ class ParentArgsTests(BaseTestCase):
|
|||
self.assertIn("<h1>Uniquely named variable = passed_in</h1>", rendered, rendered)
|
||||
self.assertNotIn("<h1>Shadowing variable = NOT SHADOWED</h1>", rendered, rendered)
|
||||
|
||||
def test_parent_args_available_in_slots(self):
|
||||
@override_settings(
|
||||
COMPONENTS={
|
||||
"context_behavior": "django",
|
||||
}
|
||||
)
|
||||
def test_parent_args_available_in_slots__django(self):
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}{% component_dependencies %}
|
||||
|
@ -246,13 +301,56 @@ class ParentArgsTests(BaseTestCase):
|
|||
{% endcomponent %}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
""" # NOQA
|
||||
""" # noqa: E501
|
||||
)
|
||||
rendered = template.render(Context())
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div>
|
||||
<h1>Parent content</h1>
|
||||
<h1>Shadowing variable = passed_in</h1>
|
||||
<h1>Uniquely named variable = unique_val</h1>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Shadowing variable = value_from_slot</h1>
|
||||
<h1>Uniquely named variable = passed_in</h1>
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
|
||||
self.assertIn("<h1>Shadowing variable = value_from_slot</h1>", rendered, rendered)
|
||||
self.assertIn("<h1>Uniquely named variable = passed_in</h1>", rendered, rendered)
|
||||
self.assertNotIn("<h1>Shadowing variable = NOT SHADOWED</h1>", rendered, rendered)
|
||||
@override_settings(
|
||||
COMPONENTS={
|
||||
"context_behavior": "isolated",
|
||||
}
|
||||
)
|
||||
def test_parent_args_not_available_in_slots__isolated(self):
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}{% component_dependencies %}
|
||||
{% component 'parent_with_args' parent_value='passed_in' %}
|
||||
{% fill 'content' %}
|
||||
{% component name='variable_display' shadowing_variable='value_from_slot' new_variable=inner_parent_value %}
|
||||
{% endcomponent %}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
""" # noqa: E501
|
||||
)
|
||||
rendered = template.render(Context())
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div>
|
||||
<h1>Parent content</h1>
|
||||
<h1>Shadowing variable = passed_in</h1>
|
||||
<h1>Uniquely named variable = unique_val</h1>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Shadowing variable = value_from_slot</h1>
|
||||
<h1>Uniquely named variable = </h1>
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
class ContextCalledOnceTests(BaseTestCase):
|
||||
|
@ -325,13 +423,37 @@ class ComponentsCanAccessOuterContext(BaseTestCase):
|
|||
super().setUpClass()
|
||||
component.registry.register(name="simple_component", component=SimpleComponent)
|
||||
|
||||
def test_simple_component_can_use_outer_context(self):
|
||||
@override_settings(
|
||||
COMPONENTS={"context_behavior": "django"},
|
||||
)
|
||||
def test_simple_component_can_use_outer_context__django(self):
|
||||
template = Template(
|
||||
"{% load component_tags %}{% component_dependencies %}"
|
||||
"{% component 'simple_component' %}{% endcomponent %}"
|
||||
)
|
||||
rendered = template.render(Context({"variable": "outer_value"})).strip()
|
||||
self.assertIn("outer_value", rendered, rendered)
|
||||
rendered = template.render(Context({"variable": "outer_value"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
Variable: <strong> outer_value </strong>
|
||||
""",
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
COMPONENTS={"context_behavior": "isolated"},
|
||||
)
|
||||
def test_simple_component_cannot_use_outer_context__isolated(self):
|
||||
template = Template(
|
||||
"{% load component_tags %}{% component_dependencies %}"
|
||||
"{% component 'simple_component' %}{% endcomponent %}"
|
||||
)
|
||||
rendered = template.render(Context({"variable": "outer_value"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
Variable: <strong> </strong>
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
class IsolatedContextTests(BaseTestCase):
|
||||
|
@ -424,9 +546,9 @@ class OuterContextPropertyTests(BaseTestCase):
|
|||
component.registry.register(name="outer_context_component", component=OuterContextComponent)
|
||||
|
||||
@override_settings(
|
||||
COMPONENTS={"context_behavior": "global"},
|
||||
COMPONENTS={"context_behavior": "django"},
|
||||
)
|
||||
def test_outer_context_property_with_component_global(self):
|
||||
def test_outer_context_property_with_component__django(self):
|
||||
template = Template(
|
||||
"{% load component_tags %}{% component_dependencies %}"
|
||||
"{% component 'outer_context_component' only %}{% endcomponent %}"
|
||||
|
@ -437,7 +559,7 @@ class OuterContextPropertyTests(BaseTestCase):
|
|||
@override_settings(
|
||||
COMPONENTS={"context_behavior": "isolated"},
|
||||
)
|
||||
def test_outer_context_property_with_component_isolated(self):
|
||||
def test_outer_context_property_with_component__isolated(self):
|
||||
template = Template(
|
||||
"{% load component_tags %}{% component_dependencies %}"
|
||||
"{% component 'outer_context_component' only %}{% endcomponent %}"
|
||||
|
|
|
@ -266,7 +266,44 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
|
|||
""",
|
||||
)
|
||||
|
||||
def test_slotted_template_with_context_var(self):
|
||||
@override_settings(COMPONENTS={"context_behavior": "isolated"})
|
||||
def test_slotted_template_with_context_var__isolated(self):
|
||||
component.registry.register(name="test1", component=SlottedComponentWithContext)
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% with my_first_variable="test123" %}
|
||||
{% component "test1" variable="test456" %}
|
||||
{% fill "main" %}
|
||||
{{ my_first_variable }} - {{ variable }}
|
||||
{% endfill %}
|
||||
{% fill "footer" %}
|
||||
{{ my_second_variable }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
{% endwith %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context({"my_second_variable": "test321"}))
|
||||
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<custom-template>
|
||||
<header>Default header</header>
|
||||
<main>test123 - </main>
|
||||
<footer>test321</footer>
|
||||
</custom-template>
|
||||
""",
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
COMPONENTS={
|
||||
"context_behavior": "django",
|
||||
}
|
||||
)
|
||||
def test_slotted_template_with_context_var__django(self):
|
||||
component.registry.register(name="test1", component=SlottedComponentWithContext)
|
||||
|
||||
template = Template(
|
||||
|
@ -743,7 +780,9 @@ class NestedSlotTests(BaseTestCase):
|
|||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component 'test' %}{% fill 'inner' %}Override{% endfill %}{% endcomponent %}
|
||||
{% component 'test' %}
|
||||
{% fill 'inner' %}Override{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context({}))
|
||||
|
@ -1045,7 +1084,8 @@ class ComponentNestingTests(BaseTestCase):
|
|||
super().tearDownClass()
|
||||
component.registry.clear()
|
||||
|
||||
def test_component_nesting_component_without_fill(self):
|
||||
@override_settings(COMPONENTS={"context_behavior": "django"})
|
||||
def test_component_nesting_component_without_fill__django(self):
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
|
@ -1072,13 +1112,8 @@ class ComponentNestingTests(BaseTestCase):
|
|||
"""
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
@override_settings(
|
||||
COMPONENTS={
|
||||
"context_behavior": "isolated",
|
||||
"slot_context_behavior": "isolated",
|
||||
}
|
||||
)
|
||||
def test_component_nesting_slot_inside_component_fill_isolated(self):
|
||||
@override_settings(COMPONENTS={"context_behavior": "isolated"})
|
||||
def test_component_nesting_component_without_fill__isolated(self):
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
|
@ -1102,13 +1137,33 @@ class ComponentNestingTests(BaseTestCase):
|
|||
"""
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
@override_settings(
|
||||
COMPONENTS={
|
||||
"context_behavior": "isolated",
|
||||
"slot_context_behavior": "isolated",
|
||||
}
|
||||
@override_settings(COMPONENTS={"context_behavior": "isolated"})
|
||||
def test_component_nesting_slot_inside_component_fill__isolated(self):
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "dashboard" %}{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
def test_component_nesting_slot_inside_component_fill_isolated_2(self):
|
||||
rendered = template.render(Context({"items": [1, 2, 3]}))
|
||||
expected = """
|
||||
<div class="dashboard-component">
|
||||
<div class="calendar-component">
|
||||
<h1>
|
||||
Welcome to your dashboard!
|
||||
</h1>
|
||||
<main>
|
||||
Here are your to-do items for today:
|
||||
</main>
|
||||
</div>
|
||||
<ol>
|
||||
</ol>
|
||||
</div>
|
||||
"""
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
@override_settings(COMPONENTS={"context_behavior": "isolated"})
|
||||
def test_component_nesting_slot_inside_component_fill__isolated_2(self):
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
|
@ -1136,13 +1191,8 @@ class ComponentNestingTests(BaseTestCase):
|
|||
"""
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
@override_settings(
|
||||
COMPONENTS={
|
||||
"context_behavior": "isolated",
|
||||
"slot_context_behavior": "isolated",
|
||||
}
|
||||
)
|
||||
def test_component_nesting_deep_slot_inside_component_fill_isolated(self):
|
||||
@override_settings(COMPONENTS={"context_behavior": "isolated"})
|
||||
def test_component_nesting_deep_slot_inside_component_fill__isolated(self):
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
|
@ -1166,7 +1216,8 @@ class ComponentNestingTests(BaseTestCase):
|
|||
"""
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
def test_component_nesting_component_with_fill_and_super(self):
|
||||
@override_settings(COMPONENTS={"context_behavior": "django"})
|
||||
def test_component_nesting_component_with_fill_and_super__django(self):
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
|
@ -1194,6 +1245,33 @@ class ComponentNestingTests(BaseTestCase):
|
|||
"""
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
@override_settings(COMPONENTS={"context_behavior": "isolated"})
|
||||
def test_component_nesting_component_with_fill_and_super__isolated(self):
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "dashboard" %}
|
||||
{% fill "header" as "h" %} Hello! {{ h.default }} {% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context({"items": [1, 2]}))
|
||||
expected = """
|
||||
<div class="dashboard-component">
|
||||
<div class="calendar-component">
|
||||
<h1>
|
||||
Hello! Welcome to your dashboard!
|
||||
</h1>
|
||||
<main>
|
||||
Here are your to-do items for today:
|
||||
</main>
|
||||
</div>
|
||||
<ol>
|
||||
</ol>
|
||||
</div>
|
||||
"""
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
|
||||
class ConditionalIfFilledSlotsTests(BaseTestCase):
|
||||
class ComponentWithConditionalSlots(component.Component):
|
||||
|
@ -1235,7 +1313,8 @@ class ConditionalIfFilledSlotsTests(BaseTestCase):
|
|||
rendered = Template(template).render(Context({}))
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
def test_component_with_filled_conditional_slot(self):
|
||||
@override_settings(COMPONENTS={"context_behavior": "django"})
|
||||
def test_component_with_filled_conditional_slot__django(self):
|
||||
template = """
|
||||
{% load component_tags %}
|
||||
{% component "conditional_slots" %}
|
||||
|
@ -1311,6 +1390,38 @@ class ConditionalIfFilledSlotsTests(BaseTestCase):
|
|||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
|
||||
class ContextVarsTests(BaseTestCase):
|
||||
class IsFilledVarsComponent(component.Component):
|
||||
template_name = "template_is_filled.html"
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
component.registry.register("is_filled_vars", cls.IsFilledVarsComponent)
|
||||
|
||||
def test_is_filled_vars(self):
|
||||
template = """
|
||||
{% load component_tags %}
|
||||
{% component "is_filled_vars" %}
|
||||
{% fill "title" %}{% endfill %}
|
||||
{% fill "my-title-2" %}{% endfill %}
|
||||
{% fill "escape this: #$%^*()" %}{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
rendered = Template(template).render(Context())
|
||||
# NOTE: `'` are escaped quotes
|
||||
expected = """
|
||||
<div class="frontmatter-component">
|
||||
{'title': True,
|
||||
'my_title': False,
|
||||
'my_title_1': False,
|
||||
'my_title_2': True,
|
||||
'escape_this_________': True}
|
||||
</div>
|
||||
"""
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
|
||||
class RegressionTests(BaseTestCase):
|
||||
"""Ensure we don't break the same thing AGAIN."""
|
||||
|
||||
|
@ -1372,7 +1483,8 @@ class IterationFillTest(BaseTestCase):
|
|||
def setUp(self):
|
||||
django_components.component.registry.clear()
|
||||
|
||||
def test_inner_slot_iteration_basic(self):
|
||||
@override_settings(COMPONENTS={"context_behavior": "django"})
|
||||
def test_inner_slot_iteration_basic__django(self):
|
||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||
|
||||
template = Template(
|
||||
|
@ -1396,7 +1508,27 @@ class IterationFillTest(BaseTestCase):
|
|||
""",
|
||||
)
|
||||
|
||||
def test_inner_slot_iteration_with_variable_from_outer_scope(self):
|
||||
@override_settings(COMPONENTS={"context_behavior": "isolated"})
|
||||
def test_inner_slot_iteration_basic__isolated(self):
|
||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "slot_in_a_loop" objects=objects %}
|
||||
{% fill "slot_inner" %}
|
||||
{{ object }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
objects = ["OBJECT1", "OBJECT2"]
|
||||
rendered = template.render(Context({"objects": objects}))
|
||||
|
||||
self.assertHTMLEqual(rendered, "")
|
||||
|
||||
@override_settings(COMPONENTS={"context_behavior": "django"})
|
||||
def test_inner_slot_iteration_with_variable_from_outer_scope__django(self):
|
||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||
|
||||
template = Template(
|
||||
|
@ -1430,7 +1562,41 @@ class IterationFillTest(BaseTestCase):
|
|||
""",
|
||||
)
|
||||
|
||||
def test_inner_slot_iteration_nested(self):
|
||||
@override_settings(COMPONENTS={"context_behavior": "isolated"})
|
||||
def test_inner_slot_iteration_with_variable_from_outer_scope__isolated(self):
|
||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "slot_in_a_loop" objects=objects %}
|
||||
{% fill "slot_inner" %}
|
||||
{{ outer_scope_variable }}
|
||||
{{ object }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
objects = ["OBJECT1", "OBJECT2"]
|
||||
rendered = template.render(
|
||||
Context(
|
||||
{
|
||||
"objects": objects,
|
||||
"outer_scope_variable": "OUTER_SCOPE_VARIABLE",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
OUTER_SCOPE_VARIABLE
|
||||
OUTER_SCOPE_VARIABLE
|
||||
""",
|
||||
)
|
||||
|
||||
@override_settings(COMPONENTS={"context_behavior": "django"})
|
||||
def test_inner_slot_iteration_nested__django(self):
|
||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||
|
||||
objects = [
|
||||
|
@ -1464,7 +1630,35 @@ class IterationFillTest(BaseTestCase):
|
|||
""",
|
||||
)
|
||||
|
||||
def test_inner_slot_iteration_nested_with_outer_scope_variable(self):
|
||||
@override_settings(COMPONENTS={"context_behavior": "isolated"})
|
||||
def test_inner_slot_iteration_nested__isolated(self):
|
||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||
|
||||
objects = [
|
||||
{"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
|
||||
{"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
|
||||
]
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "slot_in_a_loop" objects=objects %}
|
||||
{% fill "slot_inner" %}
|
||||
{% component "slot_in_a_loop" objects=object.inner %}
|
||||
{% fill "slot_inner" %}
|
||||
{{ object }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context({"objects": objects}))
|
||||
|
||||
self.assertHTMLEqual(rendered, "")
|
||||
|
||||
@override_settings(COMPONENTS={"context_behavior": "django"})
|
||||
def test_inner_slot_iteration_nested_with_outer_scope_variable__django(self):
|
||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||
|
||||
objects = [
|
||||
|
@ -1514,7 +1708,51 @@ class IterationFillTest(BaseTestCase):
|
|||
""",
|
||||
)
|
||||
|
||||
def test_inner_slot_iteration_nested_with_slot_default(self):
|
||||
@override_settings(COMPONENTS={"context_behavior": "isolated"})
|
||||
def test_inner_slot_iteration_nested_with_outer_scope_variable__isolated(self):
|
||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||
|
||||
objects = [
|
||||
{"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
|
||||
{"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
|
||||
]
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "slot_in_a_loop" objects=objects %}
|
||||
{% fill "slot_inner" %}
|
||||
{{ outer_scope_variable_1 }}
|
||||
{% component "slot_in_a_loop" objects=object.inner %}
|
||||
{% fill "slot_inner" %}
|
||||
{{ outer_scope_variable_2 }}
|
||||
{{ object }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(
|
||||
Context(
|
||||
{
|
||||
"objects": objects,
|
||||
"outer_scope_variable_1": "OUTER_SCOPE_VARIABLE1",
|
||||
"outer_scope_variable_2": "OUTER_SCOPE_VARIABLE2",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
OUTER_SCOPE_VARIABLE1
|
||||
OUTER_SCOPE_VARIABLE1
|
||||
""",
|
||||
)
|
||||
|
||||
@override_settings(COMPONENTS={"context_behavior": "django"})
|
||||
def test_inner_slot_iteration_nested_with_slot_default__django(self):
|
||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||
|
||||
objects = [
|
||||
|
@ -1548,7 +1786,34 @@ class IterationFillTest(BaseTestCase):
|
|||
""",
|
||||
)
|
||||
|
||||
def test_inner_slot_iteration_nested_with_slot_default_and_outer_scope_variable(
|
||||
@override_settings(COMPONENTS={"context_behavior": "isolated"})
|
||||
def test_inner_slot_iteration_nested_with_slot_default__isolated(self):
|
||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||
|
||||
objects = [
|
||||
{"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
|
||||
{"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
|
||||
]
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "slot_in_a_loop" objects=objects %}
|
||||
{% fill "slot_inner" %}
|
||||
{% component "slot_in_a_loop" objects=object.inner %}
|
||||
{% fill "slot_inner" as "super_slot_inner" %}
|
||||
{{ super_slot_inner.default }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context({"objects": objects}))
|
||||
self.assertHTMLEqual(rendered, "")
|
||||
|
||||
@override_settings(COMPONENTS={"context_behavior": "django"})
|
||||
def test_inner_slot_iteration_nested_with_slot_default_and_outer_scope_variable__django(
|
||||
self,
|
||||
):
|
||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||
|
@ -1599,3 +1864,107 @@ class IterationFillTest(BaseTestCase):
|
|||
ITER2_OBJ2 default
|
||||
""",
|
||||
)
|
||||
|
||||
@override_settings(COMPONENTS={"context_behavior": "isolated"})
|
||||
def test_inner_slot_iteration_nested_with_slot_default_and_outer_scope_variable__isolated_1(
|
||||
self,
|
||||
):
|
||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||
|
||||
objects = [
|
||||
{"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
|
||||
{"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
|
||||
]
|
||||
|
||||
# NOTE: In this case the `object.inner` in the inner "slot_in_a_loop"
|
||||
# should be undefined, so the loop inside the inner `slot_in_a_loop`
|
||||
# shouldn't run. Hence even the inner `slot_inner` fill should NOT run.
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "slot_in_a_loop" objects=objects %}
|
||||
{% fill "slot_inner" %}
|
||||
{{ outer_scope_variable_1 }}
|
||||
{% component "slot_in_a_loop" objects=object.inner %}
|
||||
{% fill "slot_inner" as "super_slot_inner" %}
|
||||
{{ outer_scope_variable_2 }}
|
||||
{{ super_slot_inner.default }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(
|
||||
Context(
|
||||
{
|
||||
"objects": objects,
|
||||
"outer_scope_variable_1": "OUTER_SCOPE_VARIABLE1",
|
||||
"outer_scope_variable_2": "OUTER_SCOPE_VARIABLE2",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
OUTER_SCOPE_VARIABLE1
|
||||
OUTER_SCOPE_VARIABLE1
|
||||
""",
|
||||
)
|
||||
|
||||
@override_settings(COMPONENTS={"context_behavior": "isolated"})
|
||||
def test_inner_slot_iteration_nested_with_slot_default_and_outer_scope_variable__isolated_2(
|
||||
self,
|
||||
):
|
||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||
|
||||
objects = [
|
||||
{"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
|
||||
{"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
|
||||
]
|
||||
|
||||
# NOTE: In this case we use `objects` in the inner "slot_in_a_loop", which
|
||||
# is defined in the root context. So the loop inside the inner `slot_in_a_loop`
|
||||
# should run.
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "slot_in_a_loop" objects=objects %}
|
||||
{% fill "slot_inner" %}
|
||||
{{ outer_scope_variable_1 }}
|
||||
{% component "slot_in_a_loop" objects=objects %}
|
||||
{% fill "slot_inner" as "super_slot_inner" %}
|
||||
{{ outer_scope_variable_2 }}
|
||||
{{ super_slot_inner.default }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(
|
||||
Context(
|
||||
{
|
||||
"objects": objects,
|
||||
"outer_scope_variable_1": "OUTER_SCOPE_VARIABLE1",
|
||||
"outer_scope_variable_2": "OUTER_SCOPE_VARIABLE2",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
OUTER_SCOPE_VARIABLE1
|
||||
OUTER_SCOPE_VARIABLE2
|
||||
{'inner': ['ITER1_OBJ1', 'ITER1_OBJ2']} default
|
||||
OUTER_SCOPE_VARIABLE2
|
||||
{'inner': ['ITER2_OBJ1', 'ITER2_OBJ2']} default
|
||||
OUTER_SCOPE_VARIABLE1
|
||||
OUTER_SCOPE_VARIABLE2
|
||||
{'inner': ['ITER1_OBJ1', 'ITER1_OBJ2']} default
|
||||
OUTER_SCOPE_VARIABLE2
|
||||
{'inner': ['ITER2_OBJ1', 'ITER2_OBJ2']} default
|
||||
""",
|
||||
)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue