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:
Juro Oravec 2024-05-01 20:55:09 +02:00 committed by GitHub
parent 0f3491850b
commit 3fc90e4956
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1394 additions and 838 deletions

250
README.md
View file

@ -20,7 +20,12 @@ Read on to learn about the details!
## Release notes ## 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. 🚨📢 **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: Install the app into your environment:
> ```pip install django_components``` > `pip install django_components`
Then add the app into INSTALLED_APPS in settings.py Then add the app into INSTALLED_APPS in settings.py
@ -118,7 +123,7 @@ STATICFILES_DIRS = [
### Optional ### 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 ```python
TEMPLATES = [ TEMPLATES = [
@ -174,19 +179,26 @@ First you need a CSS file. Be sure to prefix all rules with a unique class so th
```css ```css
/* In a file called [project root]/components/calendar/style.css */ /* In a file called [project root]/components/calendar/style.css */
.calendar-component { width: 200px; background: pink; } .calendar-component {
.calendar-component span { font-weight: bold; } 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. 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.
```js ```js
/* In a file called [project root]/components/calendar/script.js */ /* In a file called [project root]/components/calendar/script.js */
(function(){ (function () {
if (document.querySelector(".calendar-component")) { 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. 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. 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: 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. - 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()` - 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). - 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).
@ -260,15 +273,23 @@ The output from the above template will be:
```html ```html
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>My example calendar</title> <title>My example calendar</title>
<link href="/static/calendar/style.css" type="text/css" media="all" rel="stylesheet"> <link
</head> href="/static/calendar/style.css"
<body> type="text/css"
<div class="calendar-component">Today's date is <span>2015-06-19</span></div> media="all"
rel="stylesheet"
/>
</head>
<body>
<div class="calendar-component">
Today's date is <span>2015-06-19</span>
</div>
<script src="/static/calendar/script.js"></script> <script src="/static/calendar/script.js"></script>
</body> </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. 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> </div>
``` ```
#### Conditional slots #### Conditional slots
_Added in version 0.26._ _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 In certain circumstances, you may want the behavior of slot filling to depend on
whether or not a particular slot is filled. 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 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? be included when the inner slot is in fact filled?
The answer is to use the `{% if_filled <name> %}` tag. Together with `{% endif_filled %}`, 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
these define a block whose contents will be rendered only if the component slot with
the corresponding 'name' is filled. 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 ```htmldjango
<div class="frontmatter-component"> <div class="frontmatter-component">
<div class="title"> <div class="title">
{% slot "title" %}Title{% endslot %} {% slot "title" %}Title{% endslot %}
</div> </div>
{% if_filled "subtitle" %} {% if component_vars.is_filled.subtitle %}
<div class="subtitle"> <div class="subtitle">
{% slot "subtitle" %}{# Optional subtitle #}{% endslot %} {% slot "subtitle" %}{# Optional subtitle #}{% endslot %}
</div> </div>
{% endif_filled %} {% endif %}
</div> </div>
``` ```
Just as Django's builtin 'if' tag has 'elif' and 'else' counterparts, so does 'if_filled' Here's our example with more complex branching.
include additional tags for more complex branching. These tags are 'elif_filled' and
'else_filled'. Here's what our example looks like with them.
```htmldjango ```htmldjango
<div class="frontmatter-component"> <div class="frontmatter-component">
<div class="title"> <div class="title">
{% slot "title" %}Title{% endslot %} {% slot "title" %}Title{% endslot %}
</div> </div>
{% if_filled "subtitle" %} {% if component_vars.is_filled.subtitle %}
<div class="subtitle"> <div class="subtitle">
{% slot "subtitle" %}{# Optional subtitle #}{% endslot %} {% slot "subtitle" %}{# Optional subtitle #}{% endslot %}
</div> </div>
{% elif_filled "title" %} {% elif component_vars.is_filled.title %}
... ...
{% else_filled %} {% elif component_vars.is_filled.<name> %}
... ...
{% endif_filled %} {% endif %}
</div> </div>
``` ```
Sometimes you're not interested in whether a slot is filled, but rather that it _isn't_. 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 To negate the meaning of `component_vars.is_filled`, simply treat it as boolean and negate it with `not`:
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.
```htmldjango ```htmldjango
{% if_filled subtitle False %} {% if not component_vars.is_filled.subtitle %}
<div class="subtitle"> <div class="subtitle">
{% slot "subtitle" %}{% endslot %} {% slot "subtitle" %}{% endslot %}
</div> </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` ### 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. `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,25 +669,25 @@ COMPONENTS = {
## Component context and scope ## 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 ```htmldjango
{% component "calendar" date="2015-06-19" only %}{% endcomponent %} {% component "calendar" date="2015-06-19" only %}{% endcomponent %}
``` ```
NOTE: `{% csrf_token %}` tags need access to the top-level context, and they will not function properly if they are rendered in a component that is called with the `only` modifier. NOTE: `{% csrf_token %}` tags need access to the top-level context, and they will not function properly if they are rendered in a component that is called with the `only` modifier.
Components can also access the outer context in their context methods by accessing the property `outer_context`. 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 ## 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 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 ```python
COMPONENTS = { 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 ```python
COMPONENTS = { 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. ```django
This is similar to [how Vue renders slots](https://vuejs.org/guide/components/slots.html#render-scope), {% with cheese="feta" %}
except that, if variable is not found in the root, then the surrounding context is searched too. {% component 'my_comp' %}
{{ my_var }} # my_var
You can change this with the `slot_contet_behavior` setting. Options are: {{ cheese }} # cheese
- `"prefer_root"` - Default - as described above {% endcomponent %}
- `"isolated"` - Same behavior as Vue - variables are taken ONLY from the root context {% endwith %}
- `"allow_override"` - slot context variables are taken from its surroundings (default before v0.67)
```python
COMPONENTS = {
"slot_context_behavior": "isolated",
}
``` ```
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 ## 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/). - [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 ## Running django-components project locally
### Install locally and run the tests ### Install locally and run the tests
@ -879,25 +962,29 @@ 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: Use the [sampleproject](./sampleproject/) demo project to validate the changes:
1. Navigate to [sampleproject](./sampleproject/) directory: 1. Navigate to [sampleproject](./sampleproject/) directory:
```sh
cd sampleproject ```sh
``` cd sampleproject
```
2. Install dependencies from the [requirements.txt](./sampleproject/requirements.txt) file: 2. Install dependencies from the [requirements.txt](./sampleproject/requirements.txt) file:
```sh
pip install -r requirements.txt ```sh
``` pip install -r requirements.txt
```
3. Link to your local version of django-components: 3. Link to your local version of django-components:
```sh
pip install -e .. ```sh
``` pip install -e ..
NOTE: The path (in this case `..`) must point to the directory that has the `setup.py` file. ```
NOTE: The path (in this case `..`) must point to the directory that has the `setup.py` file.
4. Start Django server 4. Start Django server
```sh ```sh
python manage.py runserver python manage.py runserver
``` ```
Once the server is up, it should be available at <http://127.0.0.1:8000>. Once the server is up, it should be available at <http://127.0.0.1:8000>.
@ -905,15 +992,4 @@ To display individual components, add them to the `urls.py`, like in the case of
## Development guides ## Development guides
### Slot rendering flow - [Slot rendering flot](./docs/slot_rendering.md)
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.

238
docs/slot_rendering.md Normal file
View 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()`.

View file

@ -84,8 +84,7 @@ WSGI_APPLICATION = "sampleproject.wsgi.application"
# "autodiscover": True, # "autodiscover": True,
# "libraries": [], # "libraries": [],
# "template_cache_size": 128, # "template_cache_size": 128,
# "context_behavior": "isolated", # "global" | "isolated" # "context_behavior": "isolated", # "django" | "isolated"
# "slot_context_behavior": "prefer_root", # "allow_override" | "prefer_root" | "isolated"
# } # }

View file

@ -5,27 +5,27 @@ from django.conf import settings
class ContextBehavior(str, Enum): class ContextBehavior(str, Enum):
GLOBAL = "global" DJANGO = "django"
ISOLATED = "isolated"
class SlotContextBehavior(str, Enum):
ALLOW_OVERRIDE = "allow_override"
""" """
Components CAN override the slot context variables passed from the outer scopes. With this setting, component fills behave as usual Django tags.
Contexts of deeper components take precedence over shallower ones. 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: Example:
Given this template Given this template
```django
```txt {% with cheese="feta" %}
{% component 'my_comp' %} {% component 'my_comp' %}
{{ my_var }} {{ my_var }} # my_var
{% endcomponent %} {{ 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 ```py
{ "my_var": 123 } { "my_var": 123 }
``` ```
@ -35,59 +35,37 @@ class SlotContextBehavior(str, Enum):
{ "my_var": 456 } { "my_var": 456 }
``` ```
Then since "my_comp" overrides the varialbe "my_var", so `{{ my_var }}` will equal `456`. Then this will render:
""" ```django
456 # my_var
PREFER_ROOT = "prefer_root" feta # cheese
"""
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 %}
``` ```
and this context passed to the render function (AKA root context) Because "my_comp" overrides the variable "my_var",
```py so `{{ my_var }}` equals `456`.
{ "my_var_one": 123 }
```
Then if component "my_comp" defines context And variable "cheese" will equal `feta`, because the fill CAN access
```py the current context.
{ "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".
""" """
ISOLATED = "isolated" ISOLATED = "isolated"
""" """
This setting makes the slots behave similar to Vue or React, where This setting makes the component fills behave similar to Vue or React, where
the slot uses EXCLUSIVELY the root context, and nested components CANNOT the fills use EXCLUSIVELY the context variables defined in `get_context_data`.
override context variables inside the slots.
Example: Example:
Given this template Given this template
```django
```txt {% with cheese="feta" %}
{% component 'my_comp' %} {% component 'my_comp' %}
{{ my_var }} {{ my_var }} # my_var
{% endcomponent %} {{ 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 ```py
{ "my_var": 123 } { "my_var": 123 }
``` ```
@ -97,7 +75,14 @@ class SlotContextBehavior(str, Enum):
{ "my_var": 456 } { "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 @property
def CONTEXT_BEHAVIOR(self) -> ContextBehavior: 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) return self._validate_context_behavior(raw_value)
def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehavior: def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehavior:
@ -130,17 +115,5 @@ class AppSettings:
valid_values = [behavior.value for behavior in ContextBehavior] valid_values = [behavior.value for behavior in ContextBehavior]
raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}") raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}")
@property
def 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() app_settings = AppSettings()

View file

@ -20,18 +20,16 @@ from django.views import View
# way the two modules depend on one another. # way the two modules depend on one another.
from django_components.component_registry import registry # NOQA from django_components.component_registry import registry # NOQA
from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # 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.logger import logger, trace_msg
from django_components.middleware import is_dependency_middleware_active 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, resolve_slots
from django_components.slots import (
DEFAULT_SLOT_KEY,
FillContent,
FillNode,
SlotName,
SlotNode,
render_component_template_with_slots,
)
from django_components.utils import gen_id, search from django_components.utils import gen_id, search
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->" RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
@ -189,11 +187,11 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
registered_name: Optional[str] = None, registered_name: Optional[str] = None,
component_id: Optional[str] = None, component_id: Optional[str] = None,
outer_context: Optional[Context] = 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.registered_name: Optional[str] = registered_name
self.outer_context: Context = outer_context or Context() 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() self.component_id = component_id or gen_id()
def __init_subclass__(cls, **kwargs: Any) -> None: 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." 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( def render(
self, self,
context_data: Union[Dict[str, Any], Context], context: Union[Dict[str, Any], Context],
slots_data: Optional[Dict[SlotName, str]] = None, slots_data: Optional[Dict[SlotName, str]] = None,
escape_slots_content: bool = True, escape_slots_content: bool = True,
context_data: Optional[Dict[str, Any]] = None,
) -> str: ) -> str:
# NOTE: This if/else is important to avoid nested Contexts, # NOTE: This if/else is important to avoid nested Contexts,
# See https://github.com/EmilStenstrom/django-components/issues/414 # See https://github.com/EmilStenstrom/django-components/issues/414
context = context_data if isinstance(context_data, Context) else Context(context_data) context = context if isinstance(context, Context) else Context(context)
prepare_context(context, component_id=self.component_id, outer_context=self.outer_context or Context()) prepare_context(context, self.component_id)
template = self.get_template(context) template = self.get_template(context)
# Associate the slots with this component for this context # Support passing slots explicitly to `render` method
# 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)
if slots_data: 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( # If this is top-level component and it has no parent, use outer context instead
self.component_id, template, context, self.fill_content, self.registered_name 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( def render_to_response(
self, self,
context_data: Union[Dict[str, Any], Context], context_data: Union[Dict[str, Any], Context],
@ -290,25 +329,30 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
*args: Any, *args: Any,
**kwargs: Any, **kwargs: Any,
) -> HttpResponse: ) -> 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( return HttpResponse(
self.render(context_data, slots_data, escape_slots_content), self.render(context_data, slots_data, escape_slots_content),
*args, *args,
**kwargs, **kwargs,
) )
def _fill_slots( def _fills_from_slots_data(
self, self,
slots_data: Dict[SlotName, str], slots_data: Dict[SlotName, str],
escape_content: bool = True, escape_content: bool = True,
) -> None: ) -> Dict[SlotName, FillContent]:
"""Fill component slots outside of template rendering.""" """Fill component slots outside of template rendering."""
self.fill_content = { slot_fills = {
slot_name: FillContent( slot_name: FillContent(
nodes=NodeList([TextNode(escape(content) if escape_content else content)]), nodes=NodeList([TextNode(escape(content) if escape_content else content)]),
alias=None, alias=None,
) )
for (slot_name, content) in slots_data.items() for (slot_name, content) in slots_data.items()
} }
return slot_fills
class ComponentNode(Node): class ComponentNode(Node):
@ -346,8 +390,8 @@ class ComponentNode(Node):
# Resolve FilterExpressions and Variables that were passed as args to the # Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method # component, then call component's context method
# to get values to insert into the context # to get values to insert into the context
resolved_context_args = [safe_resolve(arg, context) for arg in self.context_args] resolved_context_args = safe_resolve_list(self.context_args, context)
resolved_context_kwargs = {key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()} 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 is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
if is_default_slot: if is_default_slot:
@ -368,24 +412,27 @@ class ComponentNode(Node):
component_id=self.component_id, 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 # Prevent outer context from leaking into the template of the component
if self.isolated_context: if self.isolated_context:
context = make_isolated_context_copy(context) context = make_isolated_context_copy(context)
with context.update(component_context): output = component.render_from_input(context, resolved_context_args, resolved_context_kwargs)
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
trace_msg("RENDR", "COMP", self.name_fexp, self.component_id, "...Done!") trace_msg("RENDR", "COMP", self.name_fexp, self.component_id, "...Done!")
return output 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: def safe_resolve(context_item: FilterExpression, context: Context) -> Any:
"""Resolve FilterExpressions and Variables in context if possible. Return other items unchanged.""" """Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""

View file

@ -5,73 +5,37 @@ pass data across components, nodes, slots, and contexts.
You can think of the Context as our storage system. You can think of the Context as our storage system.
""" """
from typing import TYPE_CHECKING, Optional
from django.template import Context 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 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" _FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
_OUTER_ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_OUTER_ROOT_CTX" _ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_ROOT_CTX"
_SLOT_COMPONENT_ASSOC_KEY = "_DJANGO_COMPONENTS_SLOT_COMP_ASSOC" _PARENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_PARENT_COMP"
_PARENT_COMP_KEY = "_DJANGO_COMPONENTS_PARENT_COMP" _CURRENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_CURRENT_COMP"
_CURRENT_COMP_KEY = "_DJANGO_COMPONENTS_CURRENT_COMP"
def prepare_context( def prepare_context(
context: Context, context: Context,
outer_context: Optional[Context],
component_id: str, component_id: str,
) -> None: ) -> None:
"""Initialize the internal context state.""" """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. # Initialize mapping dicts within this rendering run.
# This is shared across the whole render chain, thus we set it only once. # 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: if _FILLED_SLOTS_CONTENT_CONTEXT_KEY not in context:
context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = {} 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) set_component_id(context, component_id)
def make_isolated_context_copy(context: Context) -> Context: 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 = 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) copy_forloop_context(context, context_copy)
context_copy[_CURRENT_COMP_KEY] = context.get(_CURRENT_COMP_KEY, None) # Pass through our internal keys
context_copy[_PARENT_COMP_KEY] = context.get(_PARENT_COMP_KEY, None) 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 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 # 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 # 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[_PARENT_COMP_CONTEXT_KEY] = context.get(_CURRENT_COMP_CONTEXT_KEY, None)
context[_CURRENT_COMP_KEY] = component_id context[_CURRENT_COMP_CONTEXT_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]
def copy_forloop_context(from_context: Context, to_context: Context) -> None: def copy_forloop_context(from_context: Context, to_context: Context) -> None:

View file

@ -63,7 +63,7 @@ def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> No
def trace_msg( def trace_msg(
action: Literal["PARSE", "ASSOC", "RENDR", "GET", "SET"], 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_name: str,
node_id: str, node_id: str,
msg: str = "", msg: str = "",
@ -80,7 +80,7 @@ def trace_msg(
if not component_id: if not component_id:
raise ValueError("component_id must be set for the ASSOC action") raise ValueError("component_id must be set for the ASSOC action")
msg_prefix = f"TO COMP {component_id}" 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: if not component_id:
raise ValueError("component_id must be set for the RENDER action") raise ValueError("component_id must be set for the RENDER action")
msg_prefix = f"FOR COMP {component_id}" msg_prefix = f"FOR COMP {component_id}"

View file

@ -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.base import Node, NodeList, TextNode
from django.template.defaulttags import CommentNode from django.template.defaulttags import CommentNode
@ -15,13 +15,20 @@ def nodelist_has_content(nodelist: NodeList) -> bool:
return False 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.""" """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): while len(node_queue):
node: Node = node_queue.pop() traverse = node_queue.pop()
callback(node) callback(traverse)
node_queue.extend(get_node_children(node)) 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: def get_node_children(node: Node) -> NodeList:

View file

@ -1,7 +1,8 @@
import difflib import difflib
import json import json
from copy import copy import re
from typing import Dict, List, NamedTuple, Optional, Set, Type, Union from collections import deque
from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple, Type
from django.template import Context, Template from django.template import Context, Template
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode 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.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
from django_components.app_settings import SlotContextBehavior, app_settings from django_components.app_settings import ContextBehavior, app_settings
from django_components.context import ( from django_components.context import _FILLED_SLOTS_CONTENT_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY
copy_forloop_context,
get_outer_root_context,
get_slot_component_association,
get_slot_fill,
set_slot_fill,
)
from django_components.logger import trace_msg 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 from django_components.utils import gen_id
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT" DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
# Type aliases # Type aliases
SlotId = str
SlotName = str SlotName = str
AliasName = str AliasName = str
class FillContent(NamedTuple): 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 nodes: NodeList
alias: Optional[AliasName] 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: class UserSlotVar:
""" """
Extensible mechanism for offering 'fill' blocks in template access to properties 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)) 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): class SlotNode(Node):
def __init__( def __init__(
self, self,
@ -99,70 +116,56 @@ class SlotNode(Node):
@property @property
def active_flags(self) -> List[str]: def active_flags(self) -> List[str]:
m = [] flags = []
if self.is_required: if self.is_required:
m.append("required") flags.append("required")
if self.is_default: if self.is_default:
m.append("default") flags.append("default")
return m return flags
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>" return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
def render(self, context: Context) -> SafeString: 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)
trace_msg("RENDR", "SLOT", self.name, self.node_id, component_id=component_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 = {} 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 # For the user-provided slot fill, we want to use the context of where the slot
if slot_fill_content is None: # came from (or current context if configured so)
if self.is_required: used_ctx = self._resolve_slot_context(context, slot_fill)
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)
with used_ctx.update(extra_context): 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 return output
def resolve_slot_context(self, context: Context) -> Context: def _resolve_slot_context(self, context: Context, slot_fill: "SlotFill") -> Context:
""" """Prepare the context used in a slot fill based on the settings."""
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 %}`
See SlotContextBehavior for the description of each option. # tags weren't even there, which means that we use the current context.
""" if not slot_fill.is_filled:
root_ctx = get_outer_root_context(context) or Context()
if app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ALLOW_OVERRIDE:
return context return context
elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ISOLATED:
new_context: Context = copy(root_ctx) if app_settings.CONTEXT_BEHAVIOR == ContextBehavior.DJANGO:
copy_forloop_context(context, new_context) return context
return new_context elif app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED:
elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.PREFER_ROOT: return context[_ROOT_CTX_CONTEXT_KEY]
new_context = copy(context)
new_context.update(root_ctx.flatten())
return new_context
else: 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): class FillNode(Node):
is_implicit: bool
""" """
Set when a `component` tag pair is passed template content that Set when a `component` tag pair is passed template content that
excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked 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.name_fexp = name_fexp
self.alias_fexp = alias_fexp self.alias_fexp = alias_fexp
self.is_implicit = is_implicit self.is_implicit = is_implicit
self.component_id: Optional[str] = None
def render(self, context: Context) -> str: def render(self, context: Context) -> str:
raise TemplateSyntaxError( raise TemplateSyntaxError(
@ -207,68 +211,6 @@ class FillNode(Node, ComponentIdMixin):
return resolved_alias 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( def parse_slot_fill_nodes_from_component_nodelist(
component_nodelist: NodeList, component_nodelist: NodeList,
ComponentNodeCls: Type[Node], 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, template: Template,
context: Context, component_name: Optional[str],
fill_content: Dict[str, FillContent], context_data: Dict[str, Any],
registered_name: Optional[str], fill_content: Dict[SlotName, FillContent],
) -> str: ) -> Tuple[Dict[SlotId, Slot], Dict[SlotId, SlotFill]]:
""" """
This function first prepares the template to be able to render the fills Search the template for all SlotNodes, and associate the slots
in the place of slots, and then renders the template with given context. 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_fills = {
slot_name2fill_content = _collect_slot_fills_from_component_template(template, fill_content, registered_name) 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. slots: Dict[SlotId, Slot] = {}
for node in template.nodelist.get_nodes_by_type(IfSlotFilledConditionBranchNode): # This holds info on which slot (key) has which slots nested in it (value list)
if isinstance(node, IfSlotFilledConditionBranchNode): slot_children: Dict[SlotId, List[SlotId]] = {}
trace_msg("ASSOC", "IFSB", node.slot_name, node.node_id, component_id=component_id)
node.component_id = component_id
with context.update({}): def on_node(entry: NodeTraverse) -> None:
for slot_name, content_data in slot_name2fill_content.items(): node = entry.node
# 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
if not isinstance(node, SlotNode): 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
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
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 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])
slot_name = 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
# 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:
continue
if node.is_required: def _resolve_default_slot(
required_slot_names.add(node.name) 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()
content_data: Optional[FillContent] = None # `None` -> unfilled if DEFAULT_SLOT_KEY in named_fills:
if node.is_default: 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: if default_slot_encountered:
raise TemplateSyntaxError( raise TemplateSyntaxError(
"Only one component slot may be marked as 'default'. " "Only one component slot may be marked as 'default'. "
f"To fix, check template '{template.name}' " f"To fix, check template '{template_name}' "
f"of component '{registered_name}'." f"of component '{component_name}'."
) )
content_data = default_fill_content
default_slot_encountered = True default_slot_encountered = True
# If default fill was not found, try to fill it with named slot # Here we've identified which slot the default/implicit fill belongs to
# Effectively, this allows to fill in default slot as named ones. if default_fill:
if not content_data: named_fills[slot.name] = default_fill._replace(name=slot.name)
content_data = named_fills_content.get(node.name)
slot_name2fill_content[slot_name] = content_data
# Check: Only component templates that include a 'default' slot # Check: Only component templates that include a 'default' slot
# can be invoked with implicit filling. # 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( 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"(i.e. without explicit 'fill' tag), "
f"even though none of its slots is marked as 'default'." 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} return named_fills
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
def _report_slot_errors( def _report_slot_errors(
unfilled_slots: Set[str], slots: Dict[SlotId, Slot],
unmatched_fills: Set[str], slot_fills: Dict[SlotName, SlotFill],
registered_name: Optional[str], registered_name: Optional[str],
required_slot_names: Set[str],
) -> None: ) -> 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. # Check that 'required' slots are filled.
for slot_name in unfilled_slots: for slot_name in unfilled_slots:
if slot_name in required_slot_names: if slot_name in required_slot_names:
@ -499,3 +513,22 @@ def _report_slot_errors(
if fuzzy_slot_name_matches: if fuzzy_slot_name_matches:
msg += f"\nDid you mean '{fuzzy_slot_name_matches[0]}'?" msg += f"\nDid you mean '{fuzzy_slot_name_matches[0]}'?"
raise TemplateSyntaxError(msg) 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

View file

@ -6,7 +6,7 @@ from django.template.exceptions import TemplateSyntaxError
from django.template.library import parse_bits from django.template.library import parse_bits
from django.utils.safestring import SafeString, mark_safe 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 import RENDERED_COMMENT_TEMPLATE, ComponentNode
from django_components.component_registry import ComponentRegistry from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as component_registry from django_components.component_registry import registry as component_registry
@ -16,15 +16,7 @@ from django_components.middleware import (
JS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER,
is_dependency_middleware_active, is_dependency_middleware_active,
) )
from django_components.slots import ( from django_components.slots import FillNode, SlotNode, parse_slot_fill_nodes_from_component_nodelist
FillNode,
IfSlotFilledConditionBranchNode,
IfSlotFilledElseBranchNode,
IfSlotFilledNode,
SlotNode,
_IfSlotFilledBranchNode,
parse_slot_fill_nodes_from_component_nodelist,
)
from django_components.utils import gen_id from django_components.utils import gen_id
if TYPE_CHECKING: 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 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]: 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'.""" """Return True and strip the last word if token ends with 'only' keyword or if CONTEXT_BEHAVIOR is 'isolated'."""
if bits[-1] == "only": if bits[-1] == "only":
return bits[:-1], True return bits[:-1], True
if app_settings.CONTEXT_BEHAVIOR == "isolated": if app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED:
return bits, True return bits, True
return bits, False return bits, False
@ -416,13 +312,3 @@ def is_wrapped_in_quotes(s: str) -> bool:
def strip_quotes(s: str) -> str: def strip_quotes(s: str) -> str:
return s.strip("\"'") 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}'")

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

View file

@ -2,7 +2,7 @@
{% load component_tags %} {% load component_tags %}
<div class="frontmatter-component"> <div class="frontmatter-component">
<div class="title">{% slot "title" %}Title{% endslot %}</div> <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> <div class="subtitle">{% slot "subtitle" %}Optional subtitle{% endslot %}</div>
{% endif_filled %} {% endif %}
</div> </div>

View file

@ -2,11 +2,11 @@
{% load component_tags %} {% load component_tags %}
<div class="frontmatter-component"> <div class="frontmatter-component">
<div class="title">{% slot "title" %}Title{% endslot %}</div> <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> <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> <div class="subtitle">{% slot "alt_subtitle" %}Why would you want this?{% endslot %}</div>
{% else_filled %} {% else %}
<div class="warning">Nothing filled!</div> <div class="warning">Nothing filled!</div>
{% endif_filled %} {% endif %}
</div> </div>

View file

@ -2,9 +2,9 @@
{% load component_tags %} {% load component_tags %}
<div class="frontmatter-component"> <div class="frontmatter-component">
<div class="title">{% slot "title" %}Title{% endslot %}</div> <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> <div class="warning">Subtitle not filled!</div>
{% else_filled %} {% else %}
<div class="subtitle">{% slot "alt_subtitle" %}Why would you want this?{% endslot %}</div> <div class="subtitle">{% slot "alt_subtitle" %}Why would you want this?{% endslot %}</div>
{% endif_filled %} {% endif %}
</div> </div>

View file

@ -368,7 +368,6 @@ class ComponentTest(BaseTestCase):
@override_settings( @override_settings(
COMPONENTS={ COMPONENTS={
"context_behavior": "isolated", "context_behavior": "isolated",
"slot_context_behavior": "isolated",
}, },
) )
def test_slots_of_top_level_comps_can_access_full_outer_ctx(self): def test_slots_of_top_level_comps_can_access_full_outer_ctx(self):
@ -387,15 +386,16 @@ class ComponentTest(BaseTestCase):
{% load component_tags %} {% load component_tags %}
<body> <body>
{% component "test" %} {% component "test" %}
ABC: {{ name }} ABC: {{ name }} {{ some }}
{% endcomponent %} {% endcomponent %}
</body> </body>
""" """
) )
nested_ctx = Context() nested_ctx = Context()
nested_ctx.push({"some": "var"}) # <-- Nested comp's take data only from this layer # Check that the component can access vars across different context layers
nested_ctx.push({"name": "carl"}) # <-- But for top-level comp, it should access this layer too nested_ctx.push({"some": "var"})
nested_ctx.push({"name": "carl"})
rendered = self.template.render(nested_ctx) rendered = self.template.render(nested_ctx)
self.assertHTMLEqual( self.assertHTMLEqual(
@ -403,7 +403,7 @@ class ComponentTest(BaseTestCase):
""" """
<body> <body>
<div> <div>
<main> ABC: carl </main> <main> ABC: carl var </main>
</div> </div>
</body> </body>
""", """,
@ -805,7 +805,9 @@ class ComponentIsolationTests(BaseTestCase):
class SlotBehaviorTests(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): class SlottedComponent(component.Component):
template_name = "slotted_template.html" template_name = "slotted_template.html"
@ -816,7 +818,7 @@ class SlotBehaviorTests(BaseTestCase):
component.registry.register("test", SlottedComponent) component.registry.register("test", SlottedComponent)
self.template = Template( return Template(
""" """
{% load component_tags %} {% load component_tags %}
{% component "test" name='Igor' %} {% component "test" name='Igor' %}
@ -841,11 +843,12 @@ class SlotBehaviorTests(BaseTestCase):
) )
@override_settings( @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 # {{ 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( self.assertHTMLEqual(
rendered, rendered,
""" """
@ -864,15 +867,16 @@ class SlotBehaviorTests(BaseTestCase):
) )
# {{ name }} should be effectively the same as before, because overriden everywhere # {{ 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) self.assertHTMLEqual(rendered2, rendered)
@override_settings( @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 # {{ name }} should be "Jannete" everywhere
rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"})) rendered = template.render(Context({"day": "Monday", "name": "Jannete"}))
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
@ -891,7 +895,7 @@ class SlotBehaviorTests(BaseTestCase):
) )
# {{ name }} should be empty everywhere # {{ name }} should be empty everywhere
rendered2 = self.template.render(Context({"day": "Monday"})) rendered2 = template.render(Context({"day": "Monday"}))
self.assertHTMLEqual( self.assertHTMLEqual(
rendered2, rendered2,
""" """
@ -908,47 +912,3 @@ class SlotBehaviorTests(BaseTestCase):
</custom-template> </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>
""",
)

View file

@ -213,17 +213,67 @@ class ParentArgsTests(BaseTestCase):
component.registry.register(name="parent_with_args", component=ParentComponentWithArgs) component.registry.register(name="parent_with_args", component=ParentComponentWithArgs)
component.registry.register(name="variable_display", component=VariableDisplay) 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( template = Template(
"{% load component_tags %}{% component_dependencies %}" """
"{% component 'parent_with_args' parent_value=parent_value %}" {% load component_tags %}{% component_dependencies %}
"{% endcomponent %}" {% component 'parent_with_args' parent_value=parent_value %}
{% endcomponent %}
"""
) )
rendered = template.render(Context({"parent_value": "passed_in"})) rendered = template.render(Context({"parent_value": "passed_in"}))
self.assertIn("<h1>Shadowing variable = passed_in</h1>", rendered, rendered) self.assertHTMLEqual(
self.assertIn("<h1>Uniquely named variable = passed_in</h1>", rendered, rendered) rendered,
self.assertNotIn("<h1>Shadowing variable = NOT SHADOWED</h1>", rendered, 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): def test_parent_args_available_outside_slots(self):
template = Template( template = Template(
@ -236,7 +286,12 @@ class ParentArgsTests(BaseTestCase):
self.assertIn("<h1>Uniquely named 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.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( template = Template(
""" """
{% load component_tags %}{% component_dependencies %} {% load component_tags %}{% component_dependencies %}
@ -246,13 +301,56 @@ class ParentArgsTests(BaseTestCase):
{% endcomponent %} {% endcomponent %}
{% endfill %} {% endfill %}
{% endcomponent %} {% endcomponent %}
""" # NOQA """ # noqa: E501
) )
rendered = template.render(Context()) 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) @override_settings(
self.assertIn("<h1>Uniquely named variable = passed_in</h1>", rendered, rendered) COMPONENTS={
self.assertNotIn("<h1>Shadowing variable = NOT SHADOWED</h1>", rendered, rendered) "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): class ContextCalledOnceTests(BaseTestCase):
@ -325,13 +423,37 @@ class ComponentsCanAccessOuterContext(BaseTestCase):
super().setUpClass() super().setUpClass()
component.registry.register(name="simple_component", component=SimpleComponent) 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( template = Template(
"{% load component_tags %}{% component_dependencies %}" "{% load component_tags %}{% component_dependencies %}"
"{% component 'simple_component' %}{% endcomponent %}" "{% component 'simple_component' %}{% endcomponent %}"
) )
rendered = template.render(Context({"variable": "outer_value"})).strip() rendered = template.render(Context({"variable": "outer_value"}))
self.assertIn("outer_value", rendered, rendered) 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): class IsolatedContextTests(BaseTestCase):
@ -424,9 +546,9 @@ class OuterContextPropertyTests(BaseTestCase):
component.registry.register(name="outer_context_component", component=OuterContextComponent) component.registry.register(name="outer_context_component", component=OuterContextComponent)
@override_settings( @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( template = Template(
"{% load component_tags %}{% component_dependencies %}" "{% load component_tags %}{% component_dependencies %}"
"{% component 'outer_context_component' only %}{% endcomponent %}" "{% component 'outer_context_component' only %}{% endcomponent %}"
@ -437,7 +559,7 @@ class OuterContextPropertyTests(BaseTestCase):
@override_settings( @override_settings(
COMPONENTS={"context_behavior": "isolated"}, COMPONENTS={"context_behavior": "isolated"},
) )
def test_outer_context_property_with_component_isolated(self): def test_outer_context_property_with_component__isolated(self):
template = Template( template = Template(
"{% load component_tags %}{% component_dependencies %}" "{% load component_tags %}{% component_dependencies %}"
"{% component 'outer_context_component' only %}{% endcomponent %}" "{% component 'outer_context_component' only %}{% endcomponent %}"

View file

@ -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) component.registry.register(name="test1", component=SlottedComponentWithContext)
template = Template( template = Template(
@ -743,7 +780,9 @@ class NestedSlotTests(BaseTestCase):
template = Template( template = Template(
""" """
{% load component_tags %} {% load component_tags %}
{% component 'test' %}{% fill 'inner' %}Override{% endfill %}{% endcomponent %} {% component 'test' %}
{% fill 'inner' %}Override{% endfill %}
{% endcomponent %}
""" """
) )
rendered = template.render(Context({})) rendered = template.render(Context({}))
@ -1045,7 +1084,8 @@ class ComponentNestingTests(BaseTestCase):
super().tearDownClass() super().tearDownClass()
component.registry.clear() 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( template = Template(
""" """
{% load component_tags %} {% load component_tags %}
@ -1072,13 +1112,8 @@ class ComponentNestingTests(BaseTestCase):
""" """
self.assertHTMLEqual(rendered, expected) self.assertHTMLEqual(rendered, expected)
@override_settings( @override_settings(COMPONENTS={"context_behavior": "isolated"})
COMPONENTS={ def test_component_nesting_component_without_fill__isolated(self):
"context_behavior": "isolated",
"slot_context_behavior": "isolated",
}
)
def test_component_nesting_slot_inside_component_fill_isolated(self):
template = Template( template = Template(
""" """
{% load component_tags %} {% load component_tags %}
@ -1102,13 +1137,33 @@ class ComponentNestingTests(BaseTestCase):
""" """
self.assertHTMLEqual(rendered, expected) self.assertHTMLEqual(rendered, expected)
@override_settings( @override_settings(COMPONENTS={"context_behavior": "isolated"})
COMPONENTS={ def test_component_nesting_slot_inside_component_fill__isolated(self):
"context_behavior": "isolated", template = Template(
"slot_context_behavior": "isolated", """
} {% 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( template = Template(
""" """
{% load component_tags %} {% load component_tags %}
@ -1136,13 +1191,8 @@ class ComponentNestingTests(BaseTestCase):
""" """
self.assertHTMLEqual(rendered, expected) self.assertHTMLEqual(rendered, expected)
@override_settings( @override_settings(COMPONENTS={"context_behavior": "isolated"})
COMPONENTS={ def test_component_nesting_deep_slot_inside_component_fill__isolated(self):
"context_behavior": "isolated",
"slot_context_behavior": "isolated",
}
)
def test_component_nesting_deep_slot_inside_component_fill_isolated(self):
template = Template( template = Template(
""" """
@ -1166,7 +1216,8 @@ class ComponentNestingTests(BaseTestCase):
""" """
self.assertHTMLEqual(rendered, expected) 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( template = Template(
""" """
{% load component_tags %} {% load component_tags %}
@ -1194,6 +1245,33 @@ class ComponentNestingTests(BaseTestCase):
""" """
self.assertHTMLEqual(rendered, expected) 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 ConditionalIfFilledSlotsTests(BaseTestCase):
class ComponentWithConditionalSlots(component.Component): class ComponentWithConditionalSlots(component.Component):
@ -1235,7 +1313,8 @@ class ConditionalIfFilledSlotsTests(BaseTestCase):
rendered = Template(template).render(Context({})) rendered = Template(template).render(Context({}))
self.assertHTMLEqual(rendered, expected) 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 = """ template = """
{% load component_tags %} {% load component_tags %}
{% component "conditional_slots" %} {% component "conditional_slots" %}
@ -1311,6 +1390,38 @@ class ConditionalIfFilledSlotsTests(BaseTestCase):
self.assertHTMLEqual(rendered, expected) 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: `&#x27;` are escaped quotes
expected = """
<div class="frontmatter-component">
{&#x27;title&#x27;: True,
&#x27;my_title&#x27;: False,
&#x27;my_title_1&#x27;: False,
&#x27;my_title_2&#x27;: True,
&#x27;escape_this_________&#x27;: True}
</div>
"""
self.assertHTMLEqual(rendered, expected)
class RegressionTests(BaseTestCase): class RegressionTests(BaseTestCase):
"""Ensure we don't break the same thing AGAIN.""" """Ensure we don't break the same thing AGAIN."""
@ -1372,7 +1483,8 @@ class IterationFillTest(BaseTestCase):
def setUp(self): def setUp(self):
django_components.component.registry.clear() 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) component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
template = Template( 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) component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
template = Template( 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) component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
objects = [ 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) component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
objects = [ 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) component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
objects = [ 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, self,
): ):
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop) component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
@ -1599,3 +1864,107 @@ class IterationFillTest(BaseTestCase):
ITER2_OBJ2 default 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
{&#x27;inner&#x27;: [&#x27;ITER1_OBJ1&#x27;, &#x27;ITER1_OBJ2&#x27;]} default
OUTER_SCOPE_VARIABLE2
{&#x27;inner&#x27;: [&#x27;ITER2_OBJ1&#x27;, &#x27;ITER2_OBJ2&#x27;]} default
OUTER_SCOPE_VARIABLE1
OUTER_SCOPE_VARIABLE2
{&#x27;inner&#x27;: [&#x27;ITER1_OBJ1&#x27;, &#x27;ITER1_OBJ2&#x27;]} default
OUTER_SCOPE_VARIABLE2
{&#x27;inner&#x27;: [&#x27;ITER2_OBJ1&#x27;, &#x27;ITER2_OBJ2&#x27;]} default
""",
)