mirror of
https://github.com/django-components/django-components.git
synced 2025-07-07 17:34:59 +00:00
feat: on_render (#1231)
* feat: on_render * docs: fix typos * refactor: fix linter errors * refactor: make `error` in on_render_after optional to fix benchmarks * refactor: benchmark attempt 2 * refactor: fix linter errors * refactor: fix formatting
This commit is contained in:
parent
46e524e37d
commit
eceebb9696
24 changed files with 1793 additions and 417 deletions
76
CHANGELOG.md
76
CHANGELOG.md
|
@ -586,6 +586,35 @@ Summary:
|
|||
)
|
||||
```
|
||||
|
||||
- Component method `on_render_after` was updated to receive also `error` field.
|
||||
|
||||
For backwards compatibility, the `error` field can be omitted until v1.
|
||||
|
||||
Before:
|
||||
|
||||
```py
|
||||
def on_render_after(
|
||||
self,
|
||||
context: Context,
|
||||
template: Template,
|
||||
html: str,
|
||||
) -> None:
|
||||
pass
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```py
|
||||
def on_render_after(
|
||||
self,
|
||||
context: Context,
|
||||
template: Template,
|
||||
html: Optional[str],
|
||||
error: Optional[Exception],
|
||||
) -> None:
|
||||
pass
|
||||
```
|
||||
|
||||
- If you are using the Components as views, the way to access the component class is now different.
|
||||
|
||||
Instead of `self.component`, use `self.component_cls`. `self.component` will be removed in v1.
|
||||
|
@ -638,7 +667,7 @@ Summary:
|
|||
from django_components import ComponentExtension
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
class ExtensionClass(ComponentExtension.ComponentConfig):
|
||||
class ExtensionClass(ComponentExtension.ExtensionClass):
|
||||
pass
|
||||
```
|
||||
|
||||
|
@ -938,7 +967,7 @@ Summary:
|
|||
class Slots(NamedTuple):
|
||||
content: SlotInput
|
||||
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
assert self.args.page == 123
|
||||
assert self.kwargs.per_page == 10
|
||||
content_html = self.slots.content()
|
||||
|
@ -970,6 +999,19 @@ Summary:
|
|||
Same as with the parameters in `Component.get_template_data()`, they will be instances of the `Args`, `Kwargs`, `Slots` classes
|
||||
if defined, or plain lists / dictionaries otherwise.
|
||||
|
||||
- New component lifecycle hook `Component.on_render()`.
|
||||
|
||||
This hook is called when the component is being rendered.
|
||||
|
||||
You can override this method to:
|
||||
|
||||
- Change what template gets rendered
|
||||
- Modify the context
|
||||
- Modify the rendered output after it has been rendered
|
||||
- Handle errors
|
||||
|
||||
See [on_render](https://django-components.github.io/django-components/0.140/concepts/advanced/hooks/#on_render) for more info.
|
||||
|
||||
- `get_component_url()` now optionally accepts `query` and `fragment` arguments.
|
||||
|
||||
```py
|
||||
|
@ -1164,7 +1206,7 @@ Summary:
|
|||
from django_components import ComponentExtension
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
class ExtensionClass(ComponentExtension.ComponentConfig): # Error!
|
||||
class ExtensionClass(ComponentExtension.ExtensionClass): # Error!
|
||||
pass
|
||||
```
|
||||
|
||||
|
@ -1221,6 +1263,34 @@ Summary:
|
|||
|
||||
- Passing `Slot` instance to `Slot` constructor raises an error.
|
||||
|
||||
- Extension hook `on_component_rendered` now receives `error` field.
|
||||
|
||||
`on_component_rendered` now behaves similar to `Component.on_render_after`:
|
||||
|
||||
- Raising error in this hook overrides what error will be returned from `Component.render()`.
|
||||
- Returning new string overrides what will be returned from `Component.render()`.
|
||||
|
||||
Before:
|
||||
|
||||
```py
|
||||
class OnComponentRenderedContext(NamedTuple):
|
||||
component: "Component"
|
||||
component_cls: Type["Component"]
|
||||
component_id: str
|
||||
result: str
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```py
|
||||
class OnComponentRenderedContext(NamedTuple):
|
||||
component: "Component"
|
||||
component_cls: Type["Component"]
|
||||
component_id: str
|
||||
result: Optional[str]
|
||||
error: Optional[Exception]
|
||||
```
|
||||
|
||||
#### Fix
|
||||
|
||||
- Fix bug: Context processors data was being generated anew for each component. Now the data is correctly created once and reused across components with the same request ([#1165](https://github.com/django-components/django-components/issues/1165)).
|
||||
|
|
|
@ -1,58 +1,333 @@
|
|||
_New in version 0.96_
|
||||
|
||||
Component hooks are functions that allow you to intercept the rendering process at specific positions.
|
||||
Intercept the rendering lifecycle with Component hooks.
|
||||
|
||||
Unlike the [extension hooks](../../../reference/extension_hooks/), these are defined directly
|
||||
on the [`Component`](../../../reference/api#django_components.Component) class.
|
||||
|
||||
## Available hooks
|
||||
|
||||
- `on_render_before`
|
||||
### `on_render_before`
|
||||
|
||||
```py
|
||||
def on_render_before(
|
||||
self: Component,
|
||||
context: Context,
|
||||
template: Template
|
||||
) -> None:
|
||||
```
|
||||
```py
|
||||
def on_render_before(
|
||||
self: Component,
|
||||
context: Context,
|
||||
template: Optional[Template],
|
||||
) -> None:
|
||||
```
|
||||
|
||||
Hook that runs just before the component's template is rendered.
|
||||
[`Component.on_render_before`](../../../reference/api#django_components.Component.on_render_before) runs just before the component's template is rendered.
|
||||
|
||||
You can use this hook to access or modify the context or the template:
|
||||
It is called for every component, including nested ones, as part of
|
||||
the component render lifecycle.
|
||||
|
||||
```py
|
||||
def on_render_before(self, context, template) -> None:
|
||||
# Insert value into the Context
|
||||
context["from_on_before"] = ":)"
|
||||
It receives the [Context](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context)
|
||||
and the [Template](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template)
|
||||
as arguments.
|
||||
|
||||
# Append text into the Template
|
||||
template.nodelist.append(TextNode("FROM_ON_BEFORE"))
|
||||
```
|
||||
The `template` argument is `None` if the component has no template.
|
||||
|
||||
- `on_render_after`
|
||||
**Example:**
|
||||
|
||||
```py
|
||||
def on_render_after(
|
||||
self: Component,
|
||||
context: Context,
|
||||
template: Template,
|
||||
content: str
|
||||
) -> None | str | SafeString:
|
||||
```
|
||||
You can use this hook to access the context or the template:
|
||||
|
||||
Hook that runs just after the component's template was rendered.
|
||||
It receives the rendered output as the last argument.
|
||||
```py
|
||||
from django.template import Context, Template
|
||||
from django_components import Component
|
||||
|
||||
You can use this hook to access the context or the template, but modifying
|
||||
them won't have any effect.
|
||||
class MyTable(Component):
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
# Insert value into the Context
|
||||
context["from_on_before"] = ":)"
|
||||
|
||||
To override the content that gets rendered, you can return a string or SafeString from this hook:
|
||||
assert isinstance(template, Template)
|
||||
```
|
||||
|
||||
```py
|
||||
def on_render_after(self, context, template, content):
|
||||
# Prepend text to the rendered content
|
||||
return "Chocolate cookie recipe: " + content
|
||||
```
|
||||
!!! warning
|
||||
|
||||
## Component hooks example
|
||||
If you want to pass data to the template, prefer using
|
||||
[`get_template_data()`](../../../reference/api#django_components.Component.get_template_data)
|
||||
instead of this hook.
|
||||
|
||||
!!! warning
|
||||
|
||||
Do NOT modify the template in this hook. The template is reused across renders.
|
||||
|
||||
Since this hook is called for every component, this means that the template would be modified
|
||||
every time a component is rendered.
|
||||
|
||||
### `on_render`
|
||||
|
||||
_New in version 0.140_
|
||||
|
||||
```py
|
||||
def on_render(
|
||||
self: Component,
|
||||
context: Context,
|
||||
template: Optional[Template],
|
||||
) -> Union[str, SafeString, OnRenderGenerator, None]:
|
||||
```
|
||||
|
||||
[`Component.on_render`](../../../reference/api#django_components.Component.on_render) does the actual rendering.
|
||||
|
||||
You can override this method to:
|
||||
|
||||
- Change what template gets rendered
|
||||
- Modify the context
|
||||
- Modify the rendered output after it has been rendered
|
||||
- Handle errors
|
||||
|
||||
The default implementation renders the component's
|
||||
[Template](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template)
|
||||
with the given
|
||||
[Context](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context).
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
if template is None:
|
||||
return None
|
||||
else:
|
||||
return template.render(context)
|
||||
```
|
||||
|
||||
The `template` argument is `None` if the component has no template.
|
||||
|
||||
#### Modifying rendered template
|
||||
|
||||
To change what gets rendered, you can:
|
||||
|
||||
- Render a different template
|
||||
- Render a component
|
||||
- Return a different string or SafeString
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
return "Hello"
|
||||
```
|
||||
|
||||
You can also use [`on_render()`](../../../reference/api#django_components.Component.on_render) as a router,
|
||||
rendering other components based on the parent component's arguments:
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
# Select different component based on `feature_new_table` kwarg
|
||||
if self.kwargs.get("feature_new_table"):
|
||||
comp_cls = NewTable
|
||||
else:
|
||||
comp_cls = OldTable
|
||||
|
||||
# Render the selected component
|
||||
return comp_cls.render(
|
||||
args=self.args,
|
||||
kwargs=self.kwargs,
|
||||
slots=self.slots,
|
||||
context=context,
|
||||
)
|
||||
```
|
||||
|
||||
#### Post-processing rendered template
|
||||
|
||||
When you render the original template in [`on_render()`](../../../reference/api#django_components.Component.on_render) as:
|
||||
|
||||
```py
|
||||
template.render(context)
|
||||
```
|
||||
|
||||
The result is NOT the final output, but an intermediate result. Nested components
|
||||
are not rendered yet.
|
||||
|
||||
Instead, django-components needs to take this result and process it
|
||||
to actually render the child components.
|
||||
|
||||
To access the final output, you can `yield` the result instead of returning it.
|
||||
|
||||
This will return a tuple of (rendered HTML, error). The error is `None` if the rendering succeeded.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
html, error = yield template.render(context)
|
||||
|
||||
if error is None:
|
||||
# The rendering succeeded
|
||||
return html
|
||||
else:
|
||||
# The rendering failed
|
||||
print(f"Error: {error}")
|
||||
```
|
||||
|
||||
At this point you can do 3 things:
|
||||
|
||||
1. Return a new HTML
|
||||
|
||||
The new HTML will be used as the final output.
|
||||
|
||||
If the original template raised an error, it will be ignored.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
html, error = yield template.render(context)
|
||||
|
||||
return "NEW HTML"
|
||||
```
|
||||
|
||||
2. Raise a new exception
|
||||
|
||||
The new exception is what will bubble up from the component.
|
||||
|
||||
The original HTML and original error will be ignored.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
html, error = yield template.render(context)
|
||||
|
||||
raise Exception("Error message")
|
||||
```
|
||||
|
||||
3. Return nothing (or `None`) to handle the result as usual
|
||||
|
||||
If you don't raise an exception, and neither return a new HTML,
|
||||
then original HTML / error will be used:
|
||||
|
||||
- If rendering succeeded, the original HTML will be used as the final output.
|
||||
- If rendering failed, the original error will be propagated.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
html, error = yield template.render(context)
|
||||
|
||||
if error is not None:
|
||||
# The rendering failed
|
||||
print(f"Error: {error}")
|
||||
```
|
||||
|
||||
#### Example: ErrorBoundary
|
||||
|
||||
[`on_render()`](../../../reference/api#django_components.Component.on_render) can be used to
|
||||
implement React's [ErrorBoundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary).
|
||||
|
||||
That is, a component that catches errors in nested components and displays a fallback UI instead:
|
||||
|
||||
```django
|
||||
{% component "error_boundary" %}
|
||||
{% fill "content" %}
|
||||
{% component "nested_component" %}
|
||||
{% endfill %}
|
||||
{% fill "fallback" %}
|
||||
Sorry, something went wrong.
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
To implement this, we render the fallback slot in [`on_render()`](../../../reference/api#django_components.Component.on_render)
|
||||
and return it if an error occured:
|
||||
|
||||
```djc_py
|
||||
class ErrorFallback(Component):
|
||||
template = """
|
||||
{% slot "content" default / %}
|
||||
"""
|
||||
|
||||
def on_render(self, context, template):
|
||||
fallback = self.slots.fallback
|
||||
|
||||
if fallback is None:
|
||||
raise ValueError("fallback slot is required")
|
||||
|
||||
html, error = yield template.render(context)
|
||||
|
||||
if error is not None:
|
||||
return fallback()
|
||||
else:
|
||||
return html
|
||||
```
|
||||
|
||||
### `on_render_after`
|
||||
|
||||
```py
|
||||
def on_render_after(
|
||||
self: Component,
|
||||
context: Context,
|
||||
template: Optional[Template],
|
||||
result: Optional[str | SafeString],
|
||||
error: Optional[Exception],
|
||||
) -> Union[str, SafeString, None]:
|
||||
```
|
||||
|
||||
[`on_render_after()`](../../../reference/api#django_components.Component.on_render_after) runs when the component was fully rendered,
|
||||
including all its children.
|
||||
|
||||
It receives the same arguments as [`on_render_before()`](#on_render_before),
|
||||
plus the outcome of the rendering:
|
||||
|
||||
- `result`: The rendered output of the component. `None` if the rendering failed.
|
||||
- `error`: The error that occurred during the rendering, or `None` if the rendering succeeded.
|
||||
|
||||
[`on_render_after()`](../../../reference/api#django_components.Component.on_render_after) behaves the same way
|
||||
as the second part of [`on_render()`](#on_render) (after the `yield`).
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render_after(self, context, template, result, error):
|
||||
if error is None:
|
||||
# The rendering succeeded
|
||||
return result
|
||||
else:
|
||||
# The rendering failed
|
||||
print(f"Error: {error}")
|
||||
```
|
||||
|
||||
Same as [`on_render()`](#on_render),
|
||||
you can return a new HTML, raise a new exception, or return nothing:
|
||||
|
||||
1. Return a new HTML
|
||||
|
||||
The new HTML will be used as the final output.
|
||||
|
||||
If the original template raised an error, it will be ignored.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render_after(self, context, template, result, error):
|
||||
return "NEW HTML"
|
||||
```
|
||||
|
||||
2. Raise a new exception
|
||||
|
||||
The new exception is what will bubble up from the component.
|
||||
|
||||
The original HTML and original error will be ignored.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render_after(self, context, template, result, error):
|
||||
raise Exception("Error message")
|
||||
```
|
||||
|
||||
3. Return nothing (or `None`) to handle the result as usual
|
||||
|
||||
If you don't raise an exception, and neither return a new HTML,
|
||||
then original HTML / error will be used:
|
||||
|
||||
- If rendering succeeded, the original HTML will be used as the final output.
|
||||
- If rendering failed, the original error will be propagated.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render_after(self, context, template, result, error):
|
||||
if error is not None:
|
||||
# The rendering failed
|
||||
print(f"Error: {error}")
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
You can use hooks together with [provide / inject](#how-to-use-provide--inject) to create components
|
||||
that accept a list of items via a slot.
|
||||
|
|
|
@ -118,6 +118,92 @@ class Button(Component):
|
|||
</button>
|
||||
```
|
||||
|
||||
### Dynamic templates
|
||||
|
||||
Each component has only a single template associated with it.
|
||||
|
||||
However, whether it's for A/B testing or for preserving public API
|
||||
when sharing your components, sometimes you may need to render different templates
|
||||
based on the input to your component.
|
||||
|
||||
You can use [`Component.on_render()`](../../reference/api.md#django_components.Component.on_render)
|
||||
to dynamically override what template gets rendered.
|
||||
|
||||
By default, the component's template is rendered as-is.
|
||||
|
||||
```py
|
||||
class Table(Component):
|
||||
def on_render(self, context: Context, template: Optional[Template]):
|
||||
if template is not None:
|
||||
return template.render(context)
|
||||
```
|
||||
|
||||
If you want to render a different template in its place,
|
||||
we recommended you to:
|
||||
|
||||
1. Wrap the substitute templates as new Components
|
||||
2. Then render those Components inside [`Component.on_render()`](../../reference/api.md#django_components.Component.on_render):
|
||||
|
||||
```py
|
||||
class TableNew(Component):
|
||||
template_file = "table_new.html"
|
||||
|
||||
class TableOld(Component):
|
||||
template_file = "table_old.html"
|
||||
|
||||
class Table(Component):
|
||||
def on_render(self, context, template):
|
||||
if self.kwargs.get("feat_table_new_ui"):
|
||||
return TableNew.render(
|
||||
args=self.args,
|
||||
kwargs=self.kwargs,
|
||||
slots=self.slots,
|
||||
)
|
||||
else:
|
||||
return TableOld.render(
|
||||
args=self.args,
|
||||
kwargs=self.kwargs,
|
||||
slots=self.slots,
|
||||
)
|
||||
```
|
||||
|
||||
!!! warning
|
||||
|
||||
If you do not wrap the templates as Components,
|
||||
there is a risk that some [extensions](../../advanced/extensions) will not work as expected.
|
||||
|
||||
```py
|
||||
new_template = Template("""
|
||||
{% load django_components %}
|
||||
<div>
|
||||
{% slot "content" %}
|
||||
Other template
|
||||
{% endslot %}
|
||||
</div>
|
||||
""")
|
||||
|
||||
class Table(Component):
|
||||
def on_render(self, context, template):
|
||||
if self.kwargs.get("feat_table_new_ui"):
|
||||
return new_template.render(context)
|
||||
else:
|
||||
return template.render(context)
|
||||
```
|
||||
|
||||
### Template-less components
|
||||
|
||||
Since you can use [`Component.on_render()`](../../reference/api.md#django_components.Component.on_render)
|
||||
to render *other* components, there is no need to define a template for the component.
|
||||
|
||||
So even an empty component like this is valid:
|
||||
|
||||
```py
|
||||
class MyComponent(Component):
|
||||
pass
|
||||
```
|
||||
|
||||
These "template-less" components can be useful as base classes for other components, or as mixins.
|
||||
|
||||
### HTML processing
|
||||
|
||||
Django Components expects the rendered template to be a valid HTML. This is needed to enable features like [CSS / JS variables](../html_js_css_variables).
|
||||
|
|
|
@ -205,7 +205,7 @@ and [`self.slots`](../../../reference/api/#django_components.Component.slots) pr
|
|||
|
||||
```py
|
||||
class ProfileCard(Component):
|
||||
def on_render_before(self, *args, **kwargs):
|
||||
def on_render_before(self, context: Context, template: Optional[Template]):
|
||||
# Access inputs via self.args, self.kwargs, self.slots
|
||||
self.args[0]
|
||||
self.kwargs.get("show_details", False)
|
||||
|
|
|
@ -10,6 +10,7 @@ Render API is available inside these [`Component`](../../../reference/api#django
|
|||
- [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data)
|
||||
- [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data)
|
||||
- [`on_render_before()`](../../../reference/api#django_components.Component.on_render_before)
|
||||
- [`on_render()`](../../../reference/api#django_components.Component.on_render)
|
||||
- [`on_render_after()`](../../../reference/api#django_components.Component.on_render_after)
|
||||
|
||||
Example:
|
||||
|
@ -89,7 +90,7 @@ class Table(Component):
|
|||
page: int
|
||||
per_page: int
|
||||
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
assert self.args.page == 123
|
||||
assert self.args.per_page == 10
|
||||
|
||||
|
@ -104,7 +105,7 @@ Without `Args` class:
|
|||
from django_components import Component
|
||||
|
||||
class Table(Component):
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
assert self.args[0] == 123
|
||||
assert self.args[1] == 10
|
||||
```
|
||||
|
@ -131,7 +132,7 @@ class Table(Component):
|
|||
page: int
|
||||
per_page: int
|
||||
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
assert self.kwargs.page == 123
|
||||
assert self.kwargs.per_page == 10
|
||||
|
||||
|
@ -146,7 +147,7 @@ Without `Kwargs` class:
|
|||
from django_components import Component
|
||||
|
||||
class Table(Component):
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
assert self.kwargs["page"] == 123
|
||||
assert self.kwargs["per_page"] == 10
|
||||
```
|
||||
|
@ -173,7 +174,7 @@ class Table(Component):
|
|||
header: SlotInput
|
||||
footer: SlotInput
|
||||
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
assert isinstance(self.slots.header, Slot)
|
||||
assert isinstance(self.slots.footer, Slot)
|
||||
|
||||
|
@ -191,7 +192,7 @@ Without `Slots` class:
|
|||
from django_components import Component, Slot, SlotInput
|
||||
|
||||
class Table(Component):
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
assert isinstance(self.slots["header"], Slot)
|
||||
assert isinstance(self.slots["footer"], Slot)
|
||||
```
|
||||
|
|
|
@ -305,8 +305,8 @@ class Calendar(Component):
|
|||
"my_slot": content,
|
||||
}
|
||||
|
||||
# But in other methods you can still access the slots with `Component.slots`
|
||||
def on_render_before(self, *args, **kwargs):
|
||||
# In other methods you can still access the slots with `Component.slots`
|
||||
def on_render_before(self, context, template):
|
||||
if "my_slot" in self.slots:
|
||||
# Do something
|
||||
```
|
||||
|
|
|
@ -135,13 +135,13 @@ Thus, you can check where a slot was filled from by printing it out:
|
|||
|
||||
```python
|
||||
class MyComponent(Component):
|
||||
def on_render_before(self, *args, **kwargs):
|
||||
def on_render_before(self, context: Context, template: Optional[Template]):
|
||||
print(self.slots)
|
||||
```
|
||||
|
||||
might print:
|
||||
|
||||
```txt
|
||||
```python
|
||||
{
|
||||
'content': <Slot component_name='layout' slot_name='content'>,
|
||||
'header': <Slot component_name='my_page' slot_name='header'>,
|
||||
|
|
|
@ -91,6 +91,10 @@
|
|||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.OnRenderGenerator
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.ProvideNode
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
|
|
@ -126,7 +126,8 @@ name | type | description
|
|||
`component` | [`Component`](../api#django_components.Component) | The Component instance that is being rendered
|
||||
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class
|
||||
`component_id` | `str` | The unique identifier for this component instance
|
||||
`result` | `str` | The rendered component
|
||||
`error` | `Optional[Exception]` | The error that occurred during rendering, or `None` if rendering was successful
|
||||
`result` | `Optional[str]` | The rendered component, or `None` if rendering failed
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_component_unregistered
|
||||
options:
|
||||
|
|
|
@ -20,7 +20,7 @@ Import as
|
|||
|
||||
|
||||
|
||||
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1022" target="_blank">See source code</a>
|
||||
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1010" target="_blank">See source code</a>
|
||||
|
||||
|
||||
|
||||
|
@ -43,7 +43,7 @@ If you insert this tag multiple times, ALL CSS links will be duplicately inserte
|
|||
|
||||
|
||||
|
||||
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1044" target="_blank">See source code</a>
|
||||
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1032" target="_blank">See source code</a>
|
||||
|
||||
|
||||
|
||||
|
@ -67,7 +67,7 @@ If you insert this tag multiple times, ALL JS scripts will be duplicately insert
|
|||
|
||||
|
||||
|
||||
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L3350" target="_blank">See source code</a>
|
||||
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L3685" target="_blank">See source code</a>
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -3,8 +3,7 @@
|
|||
# Template variables
|
||||
|
||||
Here is a list of all variables that are automatically available from inside the component's
|
||||
template and in [`on_render_before` / `on_render_after`](../concepts/advanced/hooks.md#available-hooks)
|
||||
hooks.
|
||||
template:
|
||||
|
||||
|
||||
::: django_components.component.ComponentVars.args
|
||||
|
|
3
docs/templates/reference_templatevars.md
vendored
3
docs/templates/reference_templatevars.md
vendored
|
@ -1,5 +1,4 @@
|
|||
# Template variables
|
||||
|
||||
Here is a list of all variables that are automatically available from inside the component's
|
||||
template and in [`on_render_before` / `on_render_after`](../concepts/advanced/hooks.md#available-hooks)
|
||||
hooks.
|
||||
template:
|
||||
|
|
|
@ -20,6 +20,7 @@ from django_components.component import (
|
|||
ComponentInput,
|
||||
ComponentNode,
|
||||
ComponentVars,
|
||||
OnRenderGenerator,
|
||||
all_components,
|
||||
get_component_by_class_id,
|
||||
)
|
||||
|
@ -137,6 +138,7 @@ __all__ = [
|
|||
"OnComponentUnregisteredContext",
|
||||
"OnRegistryCreatedContext",
|
||||
"OnRegistryDeletedContext",
|
||||
"OnRenderGenerator",
|
||||
"ProvideNode",
|
||||
"register",
|
||||
"registry",
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import sys
|
||||
from dataclasses import dataclass
|
||||
from inspect import signature
|
||||
from types import MethodType
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Dict,
|
||||
Generator,
|
||||
List,
|
||||
Mapping,
|
||||
NamedTuple,
|
||||
|
@ -56,7 +58,12 @@ from django_components.extensions.debug_highlight import ComponentDebugHighlight
|
|||
from django_components.extensions.defaults import ComponentDefaults
|
||||
from django_components.extensions.view import ComponentView, ViewFn
|
||||
from django_components.node import BaseNode
|
||||
from django_components.perfutil.component import ComponentRenderer, component_context_cache, component_post_render
|
||||
from django_components.perfutil.component import (
|
||||
ComponentRenderer,
|
||||
OnComponentRenderedResult,
|
||||
component_context_cache,
|
||||
component_post_render,
|
||||
)
|
||||
from django_components.perfutil.provide import register_provide_reference, unregister_provide_reference
|
||||
from django_components.provide import get_injected_context_var
|
||||
from django_components.slots import (
|
||||
|
@ -98,6 +105,55 @@ else:
|
|||
CompHashMapping = WeakValueDictionary
|
||||
|
||||
|
||||
OnRenderGenerator = Generator[
|
||||
Optional[SlotResult],
|
||||
Tuple[Optional[SlotResult], Optional[Exception]],
|
||||
Optional[SlotResult],
|
||||
]
|
||||
"""
|
||||
This is the signature of the [`Component.on_render()`](../api/#django_components.Component.on_render)
|
||||
method if it yields (and thus returns a generator).
|
||||
|
||||
When `on_render()` is a generator then it:
|
||||
|
||||
- Yields a rendered template (string or `None`)
|
||||
|
||||
- Receives back a tuple of `(final_output, error)`.
|
||||
|
||||
The final output is the rendered template that now has all its children rendered too.
|
||||
May be `None` if you yielded `None` earlier.
|
||||
|
||||
The error is `None` if the rendering was successful. Otherwise the error is set
|
||||
and the output is `None`.
|
||||
|
||||
- At the end it may return a new string to override the final rendered output.
|
||||
|
||||
**Example:**
|
||||
|
||||
```py
|
||||
from django_components import Component, OnRenderGenerator
|
||||
|
||||
class MyTable(Component):
|
||||
def on_render(
|
||||
self,
|
||||
context: Context,
|
||||
template: Optional[Template],
|
||||
) -> OnRenderGenerator:
|
||||
# Do something BEFORE rendering template
|
||||
# Same as `Component.on_render_before()`
|
||||
context["hello"] = "world"
|
||||
|
||||
# Yield rendered template to receive fully-rendered template or error
|
||||
html, error = yield template.render(context)
|
||||
|
||||
# Do something AFTER rendering template, or post-process
|
||||
# the rendered template.
|
||||
# Same as `Component.on_render_after()`
|
||||
return html + "<p>Hello</p>"
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
# Keep track of all the Component classes created, so we can clean up after tests
|
||||
ALL_COMPONENTS: AllComponents = []
|
||||
|
||||
|
@ -414,7 +470,7 @@ class ComponentMeta(ComponentMediaMeta):
|
|||
attrs["template_file"] = attrs.pop("template_name")
|
||||
attrs["template_name"] = ComponentTemplateNameDescriptor()
|
||||
|
||||
cls = super().__new__(mcs, name, bases, attrs)
|
||||
cls = cast(Type["Component"], super().__new__(mcs, name, bases, attrs))
|
||||
|
||||
# If the component defined `template_file`, then associate this Component class
|
||||
# with that template file path.
|
||||
|
@ -423,6 +479,23 @@ class ComponentMeta(ComponentMediaMeta):
|
|||
if "template_file" in attrs and attrs["template_file"]:
|
||||
cache_component_template_file(cls)
|
||||
|
||||
# TODO_V1 - Remove. This is only for backwards compatibility with v0.139 and earlier,
|
||||
# where `on_render_after` had 4 parameters.
|
||||
on_render_after_sig = signature(cls.on_render_after)
|
||||
if len(on_render_after_sig.parameters) == 4:
|
||||
orig_on_render_after = cls.on_render_after
|
||||
|
||||
def on_render_after_wrapper(
|
||||
self: Component,
|
||||
context: Context,
|
||||
template: Template,
|
||||
result: str,
|
||||
error: Optional[Exception],
|
||||
) -> Optional[SlotResult]:
|
||||
return orig_on_render_after(self, context, template, result) # type: ignore[call-arg]
|
||||
|
||||
cls.on_render_after = on_render_after_wrapper # type: ignore[assignment]
|
||||
|
||||
return cls
|
||||
|
||||
# This runs when a Component class is being deleted
|
||||
|
@ -446,7 +519,7 @@ class ComponentContext:
|
|||
# When we render a component, the root component, together with all the nested Components,
|
||||
# shares this dictionary for storing callbacks that are called from within `component_post_render`.
|
||||
# This is so that we can pass them all in when the root component is passed to `component_post_render`.
|
||||
post_render_callbacks: Dict[str, Callable[[str], str]]
|
||||
post_render_callbacks: Dict[str, Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult]]
|
||||
|
||||
|
||||
class Component(metaclass=ComponentMeta):
|
||||
|
@ -1791,22 +1864,237 @@ class Component(metaclass=ComponentMeta):
|
|||
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
"""
|
||||
Hook that runs just before the component's template is rendered.
|
||||
Runs just before the component's template is rendered.
|
||||
|
||||
You can use this hook to access or modify the context or the template.
|
||||
It is called for every component, including nested ones, as part of
|
||||
the component render lifecycle.
|
||||
|
||||
Args:
|
||||
context (Context): The Django
|
||||
[Context](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context)
|
||||
that will be used to render the component's template.
|
||||
template (Optional[Template]): The Django
|
||||
[Template](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template)
|
||||
instance that will be rendered, or `None` if no template.
|
||||
|
||||
Returns:
|
||||
None. This hook is for side effects only.
|
||||
|
||||
**Example:**
|
||||
|
||||
You can use this hook to access the context or the template:
|
||||
|
||||
```py
|
||||
from django.template import Context, Template
|
||||
from django_components import Component
|
||||
|
||||
class MyTable(Component):
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
# Insert value into the Context
|
||||
context["from_on_before"] = ":)"
|
||||
|
||||
assert isinstance(template, Template)
|
||||
```
|
||||
|
||||
!!! warning
|
||||
|
||||
If you want to pass data to the template, prefer using
|
||||
[`get_template_data()`](../api#django_components.Component.get_template_data)
|
||||
instead of this hook.
|
||||
|
||||
!!! warning
|
||||
|
||||
Do NOT modify the template in this hook. The template is reused across renders.
|
||||
|
||||
Since this hook is called for every component, this means that the template would be modified
|
||||
every time a component is rendered.
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_render_after(self, context: Context, template: Optional[Template], content: str) -> Optional[SlotResult]:
|
||||
def on_render(self, context: Context, template: Optional[Template]) -> Union[SlotResult, OnRenderGenerator, None]:
|
||||
"""
|
||||
Hook that runs just after the component's template was rendered.
|
||||
It receives the rendered output as the last argument.
|
||||
This method does the actual rendering.
|
||||
|
||||
You can use this hook to access the context or the template, but modifying
|
||||
them won't have any effect.
|
||||
Read more about this hook in [Component hooks](../../concepts/advanced/hooks/#on_render).
|
||||
|
||||
To override the content that gets rendered, you can return a string or SafeString
|
||||
from this hook.
|
||||
You can override this method to:
|
||||
|
||||
- Change what template gets rendered
|
||||
- Modify the context
|
||||
- Modify the rendered output after it has been rendered
|
||||
- Handle errors
|
||||
|
||||
The default implementation renders the component's
|
||||
[Template](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template)
|
||||
with the given
|
||||
[Context](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context).
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
if template is None:
|
||||
return None
|
||||
else:
|
||||
return template.render(context)
|
||||
```
|
||||
|
||||
The `template` argument is `None` if the component has no template.
|
||||
|
||||
**Modifying rendered template**
|
||||
|
||||
To change what gets rendered, you can:
|
||||
|
||||
- Render a different template
|
||||
- Render a component
|
||||
- Return a different string or SafeString
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
return "Hello"
|
||||
```
|
||||
|
||||
**Post-processing rendered template**
|
||||
|
||||
To access the final output, you can `yield` the result instead of returning it.
|
||||
|
||||
This will return a tuple of (rendered HTML, error). The error is `None` if the rendering succeeded.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
html, error = yield template.render(context)
|
||||
|
||||
if error is None:
|
||||
# The rendering succeeded
|
||||
return html
|
||||
else:
|
||||
# The rendering failed
|
||||
print(f"Error: {error}")
|
||||
```
|
||||
|
||||
At this point you can do 3 things:
|
||||
|
||||
1. Return a new HTML
|
||||
|
||||
The new HTML will be used as the final output.
|
||||
|
||||
If the original template raised an error, it will be ignored.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
html, error = yield template.render(context)
|
||||
|
||||
return "NEW HTML"
|
||||
```
|
||||
|
||||
2. Raise a new exception
|
||||
|
||||
The new exception is what will bubble up from the component.
|
||||
|
||||
The original HTML and original error will be ignored.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
html, error = yield template.render(context)
|
||||
|
||||
raise Exception("Error message")
|
||||
```
|
||||
|
||||
3. Return nothing (or `None`) to handle the result as usual
|
||||
|
||||
If you don't raise an exception, and neither return a new HTML,
|
||||
then original HTML / error will be used:
|
||||
|
||||
- If rendering succeeded, the original HTML will be used as the final output.
|
||||
- If rendering failed, the original error will be propagated.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render(self, context, template):
|
||||
html, error = yield template.render(context)
|
||||
|
||||
if error is not None:
|
||||
# The rendering failed
|
||||
print(f"Error: {error}")
|
||||
```
|
||||
"""
|
||||
if template is None:
|
||||
return None
|
||||
else:
|
||||
return template.render(context)
|
||||
|
||||
def on_render_after(
|
||||
self, context: Context, template: Optional[Template], result: Optional[str], error: Optional[Exception]
|
||||
) -> Optional[SlotResult]:
|
||||
"""
|
||||
Hook that runs when the component was fully rendered,
|
||||
including all its children.
|
||||
|
||||
It receives the same arguments as [`on_render_before()`](../api#django_components.Component.on_render_before),
|
||||
plus the outcome of the rendering:
|
||||
|
||||
- `result`: The rendered output of the component. `None` if the rendering failed.
|
||||
- `error`: The error that occurred during the rendering, or `None` if the rendering succeeded.
|
||||
|
||||
[`on_render_after()`](../api#django_components.Component.on_render_after) behaves the same way
|
||||
as the second part of [`on_render()`](../api#django_components.Component.on_render) (after the `yield`).
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render_after(self, context, template, result, error):
|
||||
if error is None:
|
||||
# The rendering succeeded
|
||||
return result
|
||||
else:
|
||||
# The rendering failed
|
||||
print(f"Error: {error}")
|
||||
```
|
||||
|
||||
Same as [`on_render()`](../api#django_components.Component.on_render),
|
||||
you can return a new HTML, raise a new exception, or return nothing:
|
||||
|
||||
1. Return a new HTML
|
||||
|
||||
The new HTML will be used as the final output.
|
||||
|
||||
If the original template raised an error, it will be ignored.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render_after(self, context, template, result, error):
|
||||
return "NEW HTML"
|
||||
```
|
||||
|
||||
2. Raise a new exception
|
||||
|
||||
The new exception is what will bubble up from the component.
|
||||
|
||||
The original HTML and original error will be ignored.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render_after(self, context, template, result, error):
|
||||
raise Exception("Error message")
|
||||
```
|
||||
|
||||
3. Return nothing (or `None`) to handle the result as usual
|
||||
|
||||
If you don't raise an exception, and neither return a new HTML,
|
||||
then original HTML / error will be used:
|
||||
|
||||
- If rendering succeeded, the original HTML will be used as the final output.
|
||||
- If rendering failed, the original error will be propagated.
|
||||
|
||||
```py
|
||||
class MyTable(Component):
|
||||
def on_render_after(self, context, template, result, error):
|
||||
if error is not None:
|
||||
# The rendering failed
|
||||
print(f"Error: {error}")
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
|
@ -2183,7 +2471,7 @@ class Component(metaclass=ComponentMeta):
|
|||
page: int
|
||||
per_page: int
|
||||
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
assert self.args.page == 123
|
||||
assert self.args.per_page == 10
|
||||
|
||||
|
@ -2198,7 +2486,7 @@ class Component(metaclass=ComponentMeta):
|
|||
from django_components import Component
|
||||
|
||||
class Table(Component):
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
assert self.args[0] == 123
|
||||
assert self.args[1] == 10
|
||||
```
|
||||
|
@ -2228,7 +2516,7 @@ class Component(metaclass=ComponentMeta):
|
|||
page: int
|
||||
per_page: int
|
||||
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
assert self.kwargs.page == 123
|
||||
assert self.kwargs.per_page == 10
|
||||
|
||||
|
@ -2246,7 +2534,7 @@ class Component(metaclass=ComponentMeta):
|
|||
from django_components import Component
|
||||
|
||||
class Table(Component):
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
assert self.kwargs["page"] == 123
|
||||
assert self.kwargs["per_page"] == 10
|
||||
```
|
||||
|
@ -2276,7 +2564,7 @@ class Component(metaclass=ComponentMeta):
|
|||
header: SlotInput
|
||||
footer: SlotInput
|
||||
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
assert isinstance(self.slots.header, Slot)
|
||||
assert isinstance(self.slots.footer, Slot)
|
||||
|
||||
|
@ -2294,7 +2582,7 @@ class Component(metaclass=ComponentMeta):
|
|||
from django_components import Component, Slot, SlotInput
|
||||
|
||||
class Table(Component):
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
assert isinstance(self.slots["header"], Slot)
|
||||
assert isinstance(self.slots["footer"], Slot)
|
||||
```
|
||||
|
@ -3183,30 +3471,49 @@ class Component(metaclass=ComponentMeta):
|
|||
component_path=component_path,
|
||||
css_input_hash=css_input_hash,
|
||||
js_input_hash=js_input_hash,
|
||||
css_scope_id=None, # TODO - Implement CSS scoping
|
||||
)
|
||||
|
||||
# This is triggered when a component is rendered, but the component's parents
|
||||
# may not have been rendered yet.
|
||||
def on_component_rendered(html: str) -> str:
|
||||
# Allow to optionally override/modify the rendered content
|
||||
new_output = component.on_render_after(context_snapshot, template, html)
|
||||
html = default(new_output, html)
|
||||
def on_component_rendered(
|
||||
html: Optional[str],
|
||||
error: Optional[Exception],
|
||||
) -> OnComponentRenderedResult:
|
||||
# Allow the user to either:
|
||||
# - Override/modify the rendered HTML by returning new value
|
||||
# - Raise an exception to discard the HTML and bubble up error
|
||||
# - Or don't return anything (or return `None`) to use the original HTML / error
|
||||
try:
|
||||
maybe_output = component.on_render_after(context_snapshot, template, html, error)
|
||||
if maybe_output is not None:
|
||||
html = maybe_output
|
||||
error = None
|
||||
except Exception as new_error:
|
||||
error = new_error
|
||||
html = None
|
||||
|
||||
# Remove component from caches
|
||||
del component_context_cache[render_id] # type: ignore[arg-type]
|
||||
unregister_provide_reference(render_id) # type: ignore[arg-type]
|
||||
|
||||
html = extensions.on_component_rendered(
|
||||
# Allow extensions to either:
|
||||
# - Override/modify the rendered HTML by returning new value
|
||||
# - Raise an exception to discard the HTML and bubble up error
|
||||
# - Or don't return anything (or return `None`) to use the original HTML / error
|
||||
result = extensions.on_component_rendered(
|
||||
OnComponentRenderedContext(
|
||||
component=component,
|
||||
component_cls=comp_cls,
|
||||
component_id=render_id,
|
||||
result=html,
|
||||
error=error,
|
||||
)
|
||||
)
|
||||
|
||||
return html
|
||||
if result is not None:
|
||||
html, error = result
|
||||
|
||||
return html, error
|
||||
|
||||
post_render_callbacks[render_id] = on_component_rendered
|
||||
|
||||
|
@ -3259,14 +3566,15 @@ class Component(metaclass=ComponentMeta):
|
|||
component_path: List[str],
|
||||
css_input_hash: Optional[str],
|
||||
js_input_hash: Optional[str],
|
||||
css_scope_id: Optional[str],
|
||||
) -> ComponentRenderer:
|
||||
component = self
|
||||
render_id = component.id
|
||||
component_name = component.name
|
||||
component_cls = component.__class__
|
||||
|
||||
def renderer(root_attributes: Optional[List[str]] = None) -> Tuple[str, Dict[str, List[str]]]:
|
||||
def renderer(
|
||||
root_attributes: Optional[List[str]] = None,
|
||||
) -> Tuple[str, Dict[str, List[str]], Optional[OnRenderGenerator]]:
|
||||
trace_component_msg(
|
||||
"COMP_RENDER_START",
|
||||
component_name=component_name,
|
||||
|
@ -3280,16 +3588,31 @@ class Component(metaclass=ComponentMeta):
|
|||
# Emit signal that the template is about to be rendered
|
||||
template_rendered.send(sender=template, template=template, context=context)
|
||||
|
||||
if template is not None:
|
||||
# Get the component's HTML
|
||||
html_content = template.render(context)
|
||||
# Get the component's HTML
|
||||
# To access the *final* output (with all its children rendered) from within `Component.on_render()`,
|
||||
# users may convert it to a generator by including a `yield` keyword. If they do so, the part of code
|
||||
# AFTER the yield will be called once, when the component's HTML is fully rendered.
|
||||
#
|
||||
# Hence we have to distinguish between the two, and pass the generator with the HTML content
|
||||
html_content_or_generator = component.on_render(context, template)
|
||||
|
||||
if html_content_or_generator is None:
|
||||
html_content: Optional[str] = None
|
||||
on_render_generator: Optional[OnRenderGenerator] = None
|
||||
elif isinstance(html_content_or_generator, str):
|
||||
html_content = html_content_or_generator
|
||||
on_render_generator = None
|
||||
else:
|
||||
# Move generator to the first yield
|
||||
html_content = next(html_content_or_generator)
|
||||
on_render_generator = html_content_or_generator
|
||||
|
||||
if html_content is not None:
|
||||
# Add necessary HTML attributes to work with JS and CSS variables
|
||||
updated_html, child_components = set_component_attrs_for_js_and_css(
|
||||
html_content=html_content,
|
||||
component_id=render_id,
|
||||
css_input_hash=css_input_hash,
|
||||
css_scope_id=css_scope_id,
|
||||
root_attributes=root_attributes,
|
||||
)
|
||||
|
||||
|
@ -3313,7 +3636,7 @@ class Component(metaclass=ComponentMeta):
|
|||
component_path=component_path,
|
||||
)
|
||||
|
||||
return updated_html, child_components
|
||||
return updated_html, child_components, on_render_generator
|
||||
|
||||
return renderer
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ from typing import Any, Optional, Type, Union, cast
|
|||
|
||||
from django.template import Context, Template
|
||||
|
||||
from django_components import Component, ComponentRegistry, NotRegistered, types
|
||||
from django_components import Component, ComponentRegistry, NotRegistered
|
||||
from django_components.component_registry import ALL_REGISTRIES
|
||||
|
||||
|
||||
|
@ -99,23 +99,25 @@ class DynamicComponent(Component):
|
|||
|
||||
_is_dynamic_component = True
|
||||
|
||||
# TODO: Replace combination of `on_render_before()` + `template` with single `on_render()`
|
||||
#
|
||||
# NOTE: The inner component is rendered in `on_render_before`, so that the `Context` object
|
||||
# NOTE: The inner component is rendered in `on_render`, so that the `Context` object
|
||||
# is already configured as if the inner component was rendered inside the template.
|
||||
# E.g. the `_COMPONENT_CONTEXT_KEY` is set, which means that the child component
|
||||
# will know that it's a child of this component.
|
||||
def on_render_before(self, context: Context, template: Template) -> Context:
|
||||
def on_render(
|
||||
self,
|
||||
context: Context,
|
||||
template: Optional[Template],
|
||||
) -> str:
|
||||
# Make a copy of kwargs so we pass to the child only the kwargs that are
|
||||
# actually used by the child component.
|
||||
cleared_kwargs = self.input.kwargs.copy()
|
||||
|
||||
# Resolve the component class
|
||||
registry: Optional[ComponentRegistry] = cleared_kwargs.pop("registry", None)
|
||||
comp_name_or_class: Union[str, Type[Component]] = cleared_kwargs.pop("is", None)
|
||||
if not comp_name_or_class:
|
||||
raise TypeError(f"Component '{self.name}' is missing a required argument 'is'")
|
||||
|
||||
# Resolve the component class
|
||||
comp_class = self._resolve_component(comp_name_or_class, registry)
|
||||
|
||||
output = comp_class.render(
|
||||
|
@ -128,12 +130,7 @@ class DynamicComponent(Component):
|
|||
outer_context=self.outer_context,
|
||||
registry=self.registry,
|
||||
)
|
||||
|
||||
# Set the output to the context so it can be accessed from within the template.
|
||||
context["output"] = output
|
||||
return context
|
||||
|
||||
template: types.django_html = """{{ output|safe }}"""
|
||||
return output
|
||||
|
||||
def _resolve_component(
|
||||
self,
|
||||
|
|
|
@ -229,7 +229,6 @@ def set_component_attrs_for_js_and_css(
|
|||
html_content: Union[str, SafeString],
|
||||
component_id: Optional[str],
|
||||
css_input_hash: Optional[str],
|
||||
css_scope_id: Optional[str],
|
||||
root_attributes: Optional[List[str]] = None,
|
||||
) -> Tuple[Union[str, SafeString], Dict[str, List[str]]]:
|
||||
# These are the attributes that we want to set on the root element.
|
||||
|
@ -249,22 +248,11 @@ def set_component_attrs_for_js_and_css(
|
|||
if css_input_hash:
|
||||
all_root_attributes.append(f"data-djc-css-{css_input_hash}")
|
||||
|
||||
# These attributes are set on all tags
|
||||
all_attributes = []
|
||||
|
||||
# We apply the CSS scoping attribute to both root and non-root tags.
|
||||
#
|
||||
# This is the HTML part of Vue-like CSS scoping.
|
||||
# That is, for each HTML element that the component renders, we add a `data-djc-scope-a1b2c3` attribute.
|
||||
# And we stop when we come across a nested components.
|
||||
if css_scope_id:
|
||||
all_attributes.append(f"data-djc-scope-{css_scope_id}")
|
||||
|
||||
is_safestring = isinstance(html_content, SafeString)
|
||||
updated_html, child_components = set_html_attributes(
|
||||
html_content,
|
||||
root_attributes=all_root_attributes,
|
||||
all_attributes=all_attributes,
|
||||
all_attributes=[],
|
||||
# Setting this means that set_html_attributes will check for HTML elemetnts with this
|
||||
# attribute, and return a dictionary of {attribute_value: [attributes_set_on_this_tag]}.
|
||||
#
|
||||
|
|
|
@ -28,6 +28,7 @@ from django_components.util.routing import URLRoute
|
|||
if TYPE_CHECKING:
|
||||
from django_components import Component
|
||||
from django_components.component_registry import ComponentRegistry
|
||||
from django_components.perfutil.component import OnComponentRenderedResult
|
||||
from django_components.slots import Slot, SlotNode, SlotResult
|
||||
|
||||
|
||||
|
@ -139,8 +140,10 @@ class OnComponentRenderedContext(NamedTuple):
|
|||
"""The Component class"""
|
||||
component_id: str
|
||||
"""The unique identifier for this component instance"""
|
||||
result: str
|
||||
"""The rendered component"""
|
||||
result: Optional[str]
|
||||
"""The rendered component, or `None` if rendering failed"""
|
||||
error: Optional[Exception]
|
||||
"""The error that occurred during rendering, or `None` if rendering was successful"""
|
||||
|
||||
|
||||
@mark_extension_hook_api
|
||||
|
@ -709,9 +712,19 @@ class ComponentExtension(metaclass=ExtensionMeta):
|
|||
|
||||
Use this hook to access or post-process the component's rendered output.
|
||||
|
||||
To modify the output, return a new string from this hook.
|
||||
This hook works similarly to
|
||||
[`Component.on_render_after()`](../api#django_components.Component.on_render_after):
|
||||
|
||||
**Example:**
|
||||
1. To modify the output, return a new string from this hook. The original output or error will be ignored.
|
||||
|
||||
2. To cause this component to return a new error, raise that error. The original output and error
|
||||
will be ignored.
|
||||
|
||||
3. If you neither raise nor return string, the original output or error will be used.
|
||||
|
||||
**Examples:**
|
||||
|
||||
Change the final output of a component:
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnComponentRenderedContext
|
||||
|
@ -721,6 +734,32 @@ class ComponentExtension(metaclass=ExtensionMeta):
|
|||
# Append a comment to the component's rendered output
|
||||
return ctx.result + "<!-- MyExtension comment -->"
|
||||
```
|
||||
|
||||
Cause the component to raise a new exception:
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnComponentRenderedContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
|
||||
# Raise a new exception
|
||||
raise Exception("Error message")
|
||||
```
|
||||
|
||||
Return nothing (or `None`) to handle the result as usual:
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnComponentRenderedContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
|
||||
if ctx.error is not None:
|
||||
# The component raised an exception
|
||||
print(f"Error: {ctx.error}")
|
||||
else:
|
||||
# The component rendered successfully
|
||||
print(f"Result: {ctx.result}")
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
|
@ -1113,12 +1152,21 @@ class ExtensionManager:
|
|||
for extension in self.extensions:
|
||||
extension.on_component_data(ctx)
|
||||
|
||||
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> str:
|
||||
def on_component_rendered(
|
||||
self,
|
||||
ctx: OnComponentRenderedContext,
|
||||
) -> Optional["OnComponentRenderedResult"]:
|
||||
for extension in self.extensions:
|
||||
result = extension.on_component_rendered(ctx)
|
||||
if result is not None:
|
||||
ctx = ctx._replace(result=result)
|
||||
return ctx.result
|
||||
try:
|
||||
result = extension.on_component_rendered(ctx)
|
||||
except Exception as error:
|
||||
# Error from `on_component_rendered()` - clear HTML and set error
|
||||
ctx = ctx._replace(result=None, error=error)
|
||||
else:
|
||||
# No error from `on_component_rendered()` - set HTML and clear error
|
||||
if result is not None:
|
||||
ctx = ctx._replace(result=result, error=None)
|
||||
return ctx.result, ctx.error
|
||||
|
||||
##########################
|
||||
# Tags lifecycle hooks
|
||||
|
|
|
@ -198,5 +198,8 @@ class CacheExtension(ComponentExtension):
|
|||
if not cache_instance.enabled:
|
||||
return None
|
||||
|
||||
if ctx.error is not None:
|
||||
return
|
||||
|
||||
cache_key = self.render_id_to_cache_key[ctx.component_id]
|
||||
cache_instance.set_entry(cache_key, ctx.result)
|
||||
|
|
|
@ -134,7 +134,7 @@ class DebugHighlightExtension(ComponentExtension):
|
|||
# Apply highlight to the rendered component
|
||||
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
|
||||
debug_cls: Optional[ComponentDebugHighlight] = getattr(ctx.component_cls, "DebugHighlight", None)
|
||||
if not debug_cls or not debug_cls.highlight_components:
|
||||
if not debug_cls or not debug_cls.highlight_components or ctx.result is None:
|
||||
return None
|
||||
|
||||
return apply_component_highlight("component", ctx.result, f"{ctx.component.name} ({ctx.component_id})")
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import re
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, Callable, Deque, Dict, List, NamedTuple, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Callable, Deque, Dict, List, NamedTuple, Optional, Set, Tuple, Union
|
||||
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
|
@ -8,7 +8,9 @@ from django_components.constants import COMP_ID_LENGTH
|
|||
from django_components.util.exception import component_error_message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components.component import ComponentContext
|
||||
from django_components.component import ComponentContext, OnRenderGenerator
|
||||
|
||||
OnComponentRenderedResult = Tuple[Optional[str], Optional[Exception]]
|
||||
|
||||
# When we're inside a component's template, we need to acccess some component data,
|
||||
# as defined by `ComponentContext`. If we have nested components, then
|
||||
|
@ -29,28 +31,45 @@ if TYPE_CHECKING:
|
|||
component_context_cache: Dict[str, "ComponentContext"] = {}
|
||||
|
||||
|
||||
class PostRenderQueueItem(NamedTuple):
|
||||
content_before_component: str
|
||||
child_id: Optional[str]
|
||||
class ComponentPart(NamedTuple):
|
||||
"""Queue item where a component is nested in another component."""
|
||||
|
||||
child_id: str
|
||||
parent_id: Optional[str]
|
||||
grandparent_id: Optional[str]
|
||||
component_name_path: List[str]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"PostRenderQueueItem(child_id={self.child_id!r}, parent_id={self.parent_id!r}, "
|
||||
f"grandparent_id={self.grandparent_id!r}, component_name_path={self.component_name_path!r}, "
|
||||
f"content_before_component={self.content_before_component[:10]!r})"
|
||||
f"ComponentPart(child_id={self.child_id!r}, parent_id={self.parent_id!r}, "
|
||||
f"component_name_path={self.component_name_path!r})"
|
||||
)
|
||||
|
||||
|
||||
class TextPart(NamedTuple):
|
||||
"""Queue item where a text is between two components."""
|
||||
|
||||
text: str
|
||||
is_last: bool
|
||||
parent_id: str
|
||||
|
||||
|
||||
class ErrorPart(NamedTuple):
|
||||
"""Queue item where a component has thrown an error."""
|
||||
|
||||
child_id: str
|
||||
error: Exception
|
||||
|
||||
|
||||
# Function that accepts a list of extra HTML attributes to be set on the component's root elements
|
||||
# and returns the component's HTML content and a dictionary of child components' IDs
|
||||
# and their root elements' HTML attributes.
|
||||
#
|
||||
# In other words, we use this to "delay" the actual rendering of the component's HTML content,
|
||||
# until we know what HTML attributes to apply to the root elements.
|
||||
ComponentRenderer = Callable[[Optional[List[str]]], Tuple[str, Dict[str, List[str]]]]
|
||||
ComponentRenderer = Callable[
|
||||
[Optional[List[str]]],
|
||||
Tuple[str, Dict[str, List[str]], Optional["OnRenderGenerator"]],
|
||||
]
|
||||
|
||||
# Render-time cache for component rendering
|
||||
# See component_post_render()
|
||||
|
@ -115,7 +134,9 @@ def component_post_render(
|
|||
render_id: str,
|
||||
component_name: str,
|
||||
parent_id: Optional[str],
|
||||
on_component_rendered_callbacks: Dict[str, Callable[[str], str]],
|
||||
on_component_rendered_callbacks: Dict[
|
||||
str, Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult]
|
||||
],
|
||||
on_html_rendered: Callable[[str], str],
|
||||
) -> str:
|
||||
# Instead of rendering the component's HTML content immediately, we store it,
|
||||
|
@ -123,9 +144,34 @@ def component_post_render(
|
|||
# to be applied to the resulting HTML.
|
||||
component_renderer_cache[render_id] = (renderer, component_name)
|
||||
|
||||
# Case: Nested component
|
||||
# If component is nested, return a placeholder
|
||||
#
|
||||
# How this works is that we have nested components:
|
||||
# ```
|
||||
# ComponentA
|
||||
# ComponentB
|
||||
# ComponentC
|
||||
# ```
|
||||
#
|
||||
# And these components are embedded one in another using the `{% component %}` tag.
|
||||
# ```django
|
||||
# <!-- ComponentA -->
|
||||
# <div>
|
||||
# {% component "ComponentB" / %}
|
||||
# </div>
|
||||
# ```
|
||||
#
|
||||
# Then the order in which components call `component_post_render()` is:
|
||||
# 1. ComponentB - Triggered by `{% component "ComponentB" / %}` while A's template is being rendered,
|
||||
# returns only a placeholder.
|
||||
# 2. ComponentA - Triggered by the end of A's template. A isn't nested, so it starts full component
|
||||
# tree render. This replaces B's placeholder with actual HTML and introduces C's placeholder.
|
||||
# And so on...
|
||||
# 3. ComponentC - Triggered by `{% component "ComponentC" / %}` while B's template is being rendered
|
||||
# as part of full component tree render. Returns only a placeholder, to be replaced in next
|
||||
# step.
|
||||
if parent_id is not None:
|
||||
# Case: Nested component
|
||||
# If component is nested, return a placeholder
|
||||
return mark_safe(f'<template djc-render-id="{render_id}"></template>')
|
||||
|
||||
# Case: Root component - Construct the final HTML by recursively replacing placeholders
|
||||
|
@ -133,13 +179,25 @@ def component_post_render(
|
|||
# We first generate the component's HTML content, by calling the renderer.
|
||||
#
|
||||
# Then we process the component's HTML from root-downwards, going depth-first.
|
||||
# So if we have a structure:
|
||||
# So if we have a template:
|
||||
# ```django
|
||||
# <div>
|
||||
# <h2>...</h2>
|
||||
# {% component "ComponentB" / %}
|
||||
# <span>...</span>
|
||||
# {% component "ComponentD" / %}
|
||||
# </div>
|
||||
# ```
|
||||
#
|
||||
# Then component's template is rendered, replacing nested components with placeholders:
|
||||
# ```html
|
||||
# <div>
|
||||
# <h2>...</h2>
|
||||
# <template djc-render-id="a1b3cf"></template>
|
||||
# <span>...</span>
|
||||
# <template djc-render-id="f3d3cf"></template>
|
||||
# </div>
|
||||
# ```
|
||||
#
|
||||
# Then we first split up the current HTML into parts, splitting at placeholders:
|
||||
# - <div><h2>...</h2>
|
||||
|
@ -161,14 +219,12 @@ def component_post_render(
|
|||
# repeating this whole process until we've processed all nested components.
|
||||
# 5. If the placeholder ID is None, then we've reached the end of the component's HTML content,
|
||||
# and we can go one level up to continue the process with component's parent.
|
||||
process_queue: Deque[PostRenderQueueItem] = deque()
|
||||
process_queue: Deque[Union[ErrorPart, TextPart, ComponentPart]] = deque()
|
||||
|
||||
process_queue.append(
|
||||
PostRenderQueueItem(
|
||||
content_before_component="",
|
||||
ComponentPart(
|
||||
child_id=render_id,
|
||||
parent_id=None,
|
||||
grandparent_id=None,
|
||||
component_name_path=[],
|
||||
)
|
||||
)
|
||||
|
@ -187,61 +243,135 @@ def component_post_render(
|
|||
#
|
||||
# Then we end up with 3 bits - 1. text before, 2. component, and 3. text after
|
||||
#
|
||||
# We know when we've arrived at component's end, because `child_id` will be set to `None`.
|
||||
# So we can collect the HTML parts by the component ID, and when we hit the end, we join
|
||||
# all the bits that belong to the same component.
|
||||
# We know when we've arrived at component's end. We then collect the HTML parts by the component ID,
|
||||
# and when we hit the end, we join all the bits that belong to the same component.
|
||||
#
|
||||
# Once the component's HTML is joined, we can call the callback for the component, and
|
||||
# then add the joined HTML to the cache for the parent component to continue the cycle.
|
||||
html_parts_by_component_id: Dict[str, List[str]] = {}
|
||||
content_parts: List[str] = []
|
||||
|
||||
# Remember which component ID had which parent ID, so we can bubble up errors
|
||||
# to the parent component.
|
||||
child_id_to_parent_id: Dict[str, Optional[str]] = {}
|
||||
|
||||
def get_html_parts(component_id: str) -> List[str]:
|
||||
if component_id not in html_parts_by_component_id:
|
||||
html_parts_by_component_id[component_id] = []
|
||||
return html_parts_by_component_id[component_id]
|
||||
|
||||
def handle_error(component_id: str, error: Exception) -> None:
|
||||
# Cleanup
|
||||
# Remove any HTML parts that were already rendered for this component
|
||||
html_parts_by_component_id.pop(component_id, None)
|
||||
# Mark any remaining parts of this component (that may be still in the queue) as errored
|
||||
ignored_ids.add(component_id)
|
||||
# Also mark as ignored any remaining parts of the PARENT component.
|
||||
# The reason is because due to the error, parent's rendering flow was disrupted.
|
||||
# Even if parent recovers from the error by returning a new HTML, this new HTML
|
||||
# may have nothing in common with the original HTML.
|
||||
parent_id = child_id_to_parent_id[component_id]
|
||||
if parent_id is not None:
|
||||
ignored_ids.add(parent_id)
|
||||
|
||||
# Add error item to the queue so we handle it in next iteration
|
||||
process_queue.appendleft(
|
||||
ErrorPart(
|
||||
child_id=component_id,
|
||||
error=error,
|
||||
)
|
||||
)
|
||||
|
||||
def finalize_component(component_id: str, error: Optional[Exception]) -> None:
|
||||
parent_id = child_id_to_parent_id[component_id]
|
||||
|
||||
component_parts = html_parts_by_component_id.pop(component_id, [])
|
||||
if error is None:
|
||||
component_html = "".join(component_parts)
|
||||
else:
|
||||
component_html = None
|
||||
|
||||
# Allow to optionally override/modify the rendered content from `Component.on_render()`
|
||||
# and by extensions' `on_component_rendered` hooks.
|
||||
on_component_rendered = on_component_rendered_callbacks[component_id]
|
||||
component_html, error = on_component_rendered(component_html, error)
|
||||
|
||||
# If this component had an error, then we ignore this component's HTML, and instead
|
||||
# bubble the error up to the parent component.
|
||||
if error is not None:
|
||||
handle_error(component_id=component_id, error=error)
|
||||
return
|
||||
|
||||
if component_html is None:
|
||||
raise RuntimeError("Unexpected `None` from `Component.on_render()`")
|
||||
|
||||
# At this point we have a component, and we've resolved all its children into strings.
|
||||
# So the component's full HTML is now only strings.
|
||||
#
|
||||
# Hence we can transfer the child component's HTML to parent, treating it as if
|
||||
# the parent component had the rendered HTML in child's place.
|
||||
if parent_id is not None:
|
||||
target_list = get_html_parts(parent_id)
|
||||
target_list.append(component_html)
|
||||
# If there is no parent, then we're at the root component, and we can add the
|
||||
# component's HTML to the final output.
|
||||
else:
|
||||
content_parts.append(component_html)
|
||||
|
||||
# To avoid having to iterate over the queue multiple times to remove from it those
|
||||
# entries that belong to components that have thrown error, we instead keep track of which
|
||||
# components have thrown error, and skip any remaining parts of the component.
|
||||
ignored_ids: Set[str] = set()
|
||||
|
||||
while len(process_queue):
|
||||
curr_item = process_queue.popleft()
|
||||
|
||||
# In this case we've reached the end of the component's HTML content, and there's
|
||||
# no more subcomponents to process.
|
||||
if curr_item.child_id is None:
|
||||
# Parent ID must NOT be None in this branch
|
||||
if curr_item.parent_id is None:
|
||||
raise RuntimeError("Parent ID is None")
|
||||
# NOTE: When an error is bubbling up, then the flow goes between `handle_error()`, `finalize_component()`,
|
||||
# and this branch, until we reach the root component, where the error is finally raised.
|
||||
#
|
||||
# Any ancestor component of the one that raised can intercept the error and instead return a new string
|
||||
# (or a new error).
|
||||
if isinstance(curr_item, ErrorPart):
|
||||
parent_id = child_id_to_parent_id[curr_item.child_id]
|
||||
|
||||
parent_parts = html_parts_by_component_id.pop(curr_item.parent_id, [])
|
||||
# If there is no parent, then we're at the root component, so we simply propagate the error.
|
||||
# This ends the error bubbling.
|
||||
if parent_id is None:
|
||||
raise curr_item.error from None # Re-raise
|
||||
|
||||
# Add the left-over content
|
||||
parent_parts.append(curr_item.content_before_component)
|
||||
# This will make the parent component either handle the error and return a new string instead,
|
||||
# or propagate the error to its parent.
|
||||
finalize_component(component_id=parent_id, error=curr_item.error)
|
||||
continue
|
||||
|
||||
# Allow to optionally override/modify the rendered content from outside
|
||||
component_html = "".join(parent_parts)
|
||||
on_component_rendered = on_component_rendered_callbacks[curr_item.parent_id]
|
||||
component_html = on_component_rendered(component_html) # type: ignore[arg-type]
|
||||
# Skip parts of errored components
|
||||
elif curr_item.parent_id in ignored_ids:
|
||||
continue
|
||||
|
||||
# Add the component's HTML to parent's parent's HTML parts
|
||||
if curr_item.grandparent_id is not None:
|
||||
target_list = get_html_parts(curr_item.grandparent_id)
|
||||
target_list.append(component_html)
|
||||
else:
|
||||
content_parts.append(component_html)
|
||||
# Process text parts
|
||||
elif isinstance(curr_item, TextPart):
|
||||
parent_html_parts = get_html_parts(curr_item.parent_id)
|
||||
parent_html_parts.append(curr_item.text)
|
||||
|
||||
# In this case we've reached the end of the component's HTML content, and there's
|
||||
# no more subcomponents to process. We can call `finalize_component()` to process
|
||||
# the component's HTML and eventually trigger `on_component_rendered` hook.
|
||||
if curr_item.is_last:
|
||||
finalize_component(component_id=curr_item.parent_id, error=None)
|
||||
|
||||
continue
|
||||
|
||||
# Process content before the component
|
||||
if curr_item.content_before_component:
|
||||
if curr_item.parent_id is None:
|
||||
raise RuntimeError("Parent ID is None")
|
||||
parent_html_parts = get_html_parts(curr_item.parent_id)
|
||||
parent_html_parts.append(curr_item.content_before_component)
|
||||
# The rest of this branch assumes `curr_item` is a `ComponentPart`
|
||||
component_id = curr_item.child_id
|
||||
|
||||
# Remember which component ID had which parent ID, so we can bubble up errors
|
||||
# to the parent component.
|
||||
child_id_to_parent_id[component_id] = curr_item.parent_id
|
||||
|
||||
# Generate component's content, applying the extra HTML attributes set by the parent component
|
||||
curr_comp_renderer, curr_comp_name = component_renderer_cache.pop(curr_item.child_id)
|
||||
# NOTE: This may be undefined, because this is set only for components that
|
||||
# are also root elements in their parent's HTML
|
||||
curr_comp_attrs = child_component_attrs.pop(curr_item.child_id, None)
|
||||
curr_comp_renderer, curr_comp_name = component_renderer_cache.pop(component_id)
|
||||
# NOTE: Attributes passed from parent to current component are `None` for the root component.
|
||||
curr_comp_attrs = child_component_attrs.pop(component_id, None)
|
||||
|
||||
full_path = [*curr_item.component_name_path, curr_comp_name]
|
||||
|
||||
|
@ -249,23 +379,44 @@ def component_post_render(
|
|||
#
|
||||
# NOTE: [1:] because the root component will be yet again added to the error's
|
||||
# `components` list in `_render_with_error_trace` so we remove the first element from the path.
|
||||
with component_error_message(full_path[1:]):
|
||||
curr_comp_content, grandchild_component_attrs = curr_comp_renderer(curr_comp_attrs)
|
||||
try:
|
||||
with component_error_message(full_path[1:]):
|
||||
comp_content, grandchild_component_attrs, on_render_generator = curr_comp_renderer(curr_comp_attrs)
|
||||
# This error may be triggered when any of following raises:
|
||||
# - `Component.on_render()` (first part - before yielding)
|
||||
# - `Component.on_render_before()`
|
||||
# - Rendering of component's template
|
||||
#
|
||||
# In all cases, we want to mark the component as errored, and let the parent handle it.
|
||||
except Exception as err:
|
||||
handle_error(component_id=component_id, error=err)
|
||||
continue
|
||||
|
||||
# Exclude the `data-djc-scope-...` attribute from being applied to the child component's HTML
|
||||
for key in list(grandchild_component_attrs.keys()):
|
||||
if key.startswith("data-djc-scope-"):
|
||||
grandchild_component_attrs.pop(key, None)
|
||||
# To access the *final* output (with all its children rendered) from within `Component.on_render()`,
|
||||
# users may convert it to a generator by including a `yield` keyword. If they do so, the part of code
|
||||
# AFTER the yield will be called once, when the component's HTML is fully rendered.
|
||||
#
|
||||
# We want to make sure we call the second part of `Component.on_render()` BEFORE
|
||||
# we call `Component.on_render_after()`. The latter will be triggered by calling
|
||||
# corresponding `on_component_rendered`.
|
||||
#
|
||||
# So we want to wrap the `on_component_rendered` callback, so we get to call the generator first.
|
||||
if on_render_generator is not None:
|
||||
unwrapped_on_component_rendered = on_component_rendered_callbacks[component_id]
|
||||
on_component_rendered_callbacks[component_id] = _call_generator_before_callback(
|
||||
on_render_generator,
|
||||
unwrapped_on_component_rendered,
|
||||
)
|
||||
|
||||
child_component_attrs.update(grandchild_component_attrs)
|
||||
|
||||
# Process the component's content
|
||||
# Split component's content by placeholders, and put the pairs of
|
||||
# `(text_between_components, placeholder_id)`
|
||||
# into the queue.
|
||||
last_index = 0
|
||||
parts_to_process: List[PostRenderQueueItem] = []
|
||||
|
||||
# Split component's content by placeholders, and put the pairs of (content, placeholder_id) into the queue
|
||||
for match in nested_comp_pattern.finditer(curr_comp_content):
|
||||
part_before_component = curr_comp_content[last_index : match.start()] # noqa: E203
|
||||
parts_to_process: List[Union[TextPart, ComponentPart]] = []
|
||||
for match in nested_comp_pattern.finditer(comp_content):
|
||||
part_before_component = comp_content[last_index : match.start()] # noqa: E203
|
||||
last_index = match.end()
|
||||
comp_part = match[0]
|
||||
|
||||
|
@ -274,27 +425,31 @@ def component_post_render(
|
|||
if grandchild_id_match is None:
|
||||
raise ValueError(f"No placeholder ID found in {comp_part}")
|
||||
grandchild_id = grandchild_id_match.group("render_id")
|
||||
parts_to_process.append(
|
||||
PostRenderQueueItem(
|
||||
content_before_component=part_before_component,
|
||||
child_id=grandchild_id,
|
||||
parent_id=curr_item.child_id,
|
||||
grandparent_id=curr_item.parent_id,
|
||||
component_name_path=full_path,
|
||||
)
|
||||
|
||||
parts_to_process.extend(
|
||||
[
|
||||
TextPart(
|
||||
text=part_before_component,
|
||||
is_last=False,
|
||||
parent_id=component_id,
|
||||
),
|
||||
ComponentPart(
|
||||
child_id=grandchild_id,
|
||||
parent_id=component_id,
|
||||
component_name_path=full_path,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Append any remaining text
|
||||
parts_to_process.append(
|
||||
PostRenderQueueItem(
|
||||
content_before_component=curr_comp_content[last_index:],
|
||||
# Setting `child_id` to None means that this is the last part of the component's HTML
|
||||
# and we're done with this component
|
||||
child_id=None,
|
||||
parent_id=curr_item.child_id,
|
||||
grandparent_id=curr_item.parent_id,
|
||||
component_name_path=full_path,
|
||||
)
|
||||
parts_to_process.extend(
|
||||
[
|
||||
TextPart(
|
||||
text=comp_content[last_index:],
|
||||
is_last=True,
|
||||
parent_id=component_id,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
process_queue.extendleft(reversed(parts_to_process))
|
||||
|
@ -305,3 +460,44 @@ def component_post_render(
|
|||
output = on_html_rendered(output)
|
||||
|
||||
return mark_safe(output)
|
||||
|
||||
|
||||
def _call_generator_before_callback(
|
||||
on_render_generator: Optional["OnRenderGenerator"],
|
||||
inner_fn: Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult],
|
||||
) -> Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult]:
|
||||
if on_render_generator is None:
|
||||
return inner_fn
|
||||
|
||||
def on_component_rendered_wrapper(
|
||||
html: Optional[str],
|
||||
error: Optional[Exception],
|
||||
) -> OnComponentRenderedResult:
|
||||
try:
|
||||
on_render_generator.send((html, error))
|
||||
# `Component.on_render()` should contain only one `yield` statement, so calling `.send()`
|
||||
# should reach `return` statement in `Component.on_render()`, which triggers `StopIteration`.
|
||||
# In that case, the value returned from `Component.on_render()` with the `return` keyword
|
||||
# is the new output (if not `None`).
|
||||
except StopIteration as generator_err:
|
||||
# To override what HTML / error gets returned, user may either:
|
||||
# - Return a new HTML at the end of `Component.on_render()` (after yielding),
|
||||
# - Raise a new error
|
||||
new_output = generator_err.value
|
||||
if new_output is not None:
|
||||
html = new_output
|
||||
error = None
|
||||
|
||||
# Catch if `Component.on_render()` raises an exception, in which case this becomes
|
||||
# the new error.
|
||||
except Exception as new_error:
|
||||
error = new_error
|
||||
html = None
|
||||
# This raises if `StopIteration` was not raised, which may be if `Component.on_render()`
|
||||
# contains more than one `yield` statement.
|
||||
else:
|
||||
raise RuntimeError("`Component.on_render()` must include only one `yield` statement")
|
||||
|
||||
return inner_fn(html, error)
|
||||
|
||||
return on_component_rendered_wrapper
|
||||
|
|
|
@ -4477,7 +4477,7 @@ class Tabs(Component):
|
|||
"tabs_data": {"name": name},
|
||||
}
|
||||
|
||||
def on_render_after(self, context, template, rendered) -> str:
|
||||
def on_render_after(self, context, template, rendered, error=None) -> str:
|
||||
# By the time we get here, all child TabItem components should have been
|
||||
# rendered, and they should've populated the tabs list.
|
||||
tabs: List[TabEntry] = context["tabs"]
|
||||
|
@ -4530,7 +4530,7 @@ class TabItem(Component):
|
|||
"disabled": disabled,
|
||||
}
|
||||
|
||||
def on_render_after(self, context, template, content) -> None:
|
||||
def on_render_after(self, context, template, content, error=None) -> None:
|
||||
parent_tabs: List[dict] = context["parent_tabs"]
|
||||
parent_tabs.append({
|
||||
"header": context["header"],
|
||||
|
|
|
@ -5,7 +5,7 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py`
|
|||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, NamedTuple
|
||||
from typing import Any, List, Literal, NamedTuple, Optional
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
|
@ -25,6 +25,7 @@ from django_components import (
|
|||
all_components,
|
||||
get_component_by_class_id,
|
||||
register,
|
||||
registry,
|
||||
types,
|
||||
)
|
||||
from django_components.template import _get_component_template
|
||||
|
@ -1429,260 +1430,555 @@ class TestComponentRender:
|
|||
|
||||
@djc_test
|
||||
class TestComponentHook:
|
||||
def test_on_render_before(self):
|
||||
@register("nested")
|
||||
class NestedComponent(Component):
|
||||
def _gen_slotted_component(self, calls: List[str]):
|
||||
class Slotted(Component):
|
||||
template = "Hello from slotted"
|
||||
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
calls.append("slotted__on_render_before")
|
||||
|
||||
def on_render(self, context: Context, template: Optional[Template]):
|
||||
calls.append("slotted__on_render_pre")
|
||||
html, error = yield template.render(context) # type: ignore[union-attr]
|
||||
|
||||
calls.append("slotted__on_render_post")
|
||||
|
||||
# Check that modifying the context or template does nothing
|
||||
def on_render_after(
|
||||
self,
|
||||
context: Context,
|
||||
template: Optional[Template],
|
||||
html: Optional[str],
|
||||
error: Optional[Exception],
|
||||
) -> None:
|
||||
calls.append("slotted__on_render_after")
|
||||
|
||||
return Slotted
|
||||
|
||||
def _gen_inner_component(self, calls: List[str]):
|
||||
class Inner(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
Hello from nested
|
||||
<div>
|
||||
{% slot "content" default / %}
|
||||
</div>
|
||||
Inner start
|
||||
{% slot "content" default / %}
|
||||
Inner end
|
||||
"""
|
||||
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
calls.append("inner__on_render_before")
|
||||
|
||||
def on_render(self, context: Context, template: Optional[Template]):
|
||||
calls.append("inner__on_render_pre")
|
||||
if template is None:
|
||||
yield None
|
||||
else:
|
||||
html, error = yield template.render(context)
|
||||
|
||||
calls.append("inner__on_render_post")
|
||||
|
||||
# Check that modifying the context or template does nothing
|
||||
def on_render_after(
|
||||
self,
|
||||
context: Context,
|
||||
template: Optional[Template],
|
||||
html: Optional[str],
|
||||
error: Optional[Exception],
|
||||
) -> None:
|
||||
calls.append("inner__on_render_after")
|
||||
|
||||
return Inner
|
||||
|
||||
def _gen_middle_component(self, calls: List[str]):
|
||||
class Middle(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
Middle start
|
||||
{% component "inner" %}
|
||||
{% component "slotted" / %}
|
||||
{% endcomponent %}
|
||||
Middle text
|
||||
{% component "inner" / %}
|
||||
Middle end
|
||||
"""
|
||||
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
calls.append("middle__on_render_before")
|
||||
|
||||
def on_render(self, context: Context, template: Optional[Template]):
|
||||
calls.append("middle__on_render_pre")
|
||||
html, error = yield template.render(context) # type: ignore[union-attr]
|
||||
|
||||
calls.append("middle__on_render_post")
|
||||
|
||||
# Check that modifying the context or template does nothing
|
||||
def on_render_after(
|
||||
self,
|
||||
context: Context,
|
||||
template: Optional[Template],
|
||||
html: Optional[str],
|
||||
error: Optional[Exception],
|
||||
) -> None:
|
||||
calls.append("middle__on_render_after")
|
||||
|
||||
return Middle
|
||||
|
||||
def _gen_outer_component(self, calls: List[str]):
|
||||
class Outer(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
Outer start
|
||||
{% component "middle" / %}
|
||||
Outer text
|
||||
{% component "middle" / %}
|
||||
Outer end
|
||||
"""
|
||||
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
calls.append("outer__on_render_before")
|
||||
|
||||
def on_render(self, context: Context, template: Optional[Template]):
|
||||
calls.append("outer__on_render_pre")
|
||||
html, error = yield template.render(context) # type: ignore[union-attr]
|
||||
|
||||
calls.append("outer__on_render_post")
|
||||
|
||||
# Check that modifying the context or template does nothing
|
||||
def on_render_after(
|
||||
self,
|
||||
context: Context,
|
||||
template: Optional[Template],
|
||||
html: Optional[str],
|
||||
error: Optional[Exception],
|
||||
) -> None:
|
||||
calls.append("outer__on_render_after")
|
||||
|
||||
return Outer
|
||||
|
||||
def _gen_broken_component(self):
|
||||
class BrokenComponent(Component):
|
||||
def on_render(self, context: Context, template: Template):
|
||||
raise ValueError("BROKEN")
|
||||
|
||||
return BrokenComponent
|
||||
|
||||
def test_order(self):
|
||||
calls: List[str] = []
|
||||
|
||||
registry.register("slotted", self._gen_slotted_component(calls))
|
||||
registry.register("inner", self._gen_inner_component(calls))
|
||||
registry.register("middle", self._gen_middle_component(calls))
|
||||
Outer = self._gen_outer_component(calls)
|
||||
|
||||
result = Outer.render()
|
||||
|
||||
assertHTMLEqual(
|
||||
result,
|
||||
"""
|
||||
Outer start
|
||||
Middle start
|
||||
Inner start
|
||||
Hello from slotted
|
||||
Inner end
|
||||
Middle text
|
||||
Inner start
|
||||
Inner end
|
||||
Middle end
|
||||
Outer text
|
||||
Middle start
|
||||
Inner start
|
||||
Hello from slotted
|
||||
Inner end
|
||||
Middle text
|
||||
Inner start
|
||||
Inner end
|
||||
Middle end
|
||||
Outer end
|
||||
""",
|
||||
)
|
||||
|
||||
assert calls == [
|
||||
"outer__on_render_before",
|
||||
"outer__on_render_pre",
|
||||
"middle__on_render_before",
|
||||
"middle__on_render_pre",
|
||||
"inner__on_render_before",
|
||||
"inner__on_render_pre",
|
||||
"slotted__on_render_before",
|
||||
"slotted__on_render_pre",
|
||||
"slotted__on_render_post",
|
||||
"slotted__on_render_after",
|
||||
"inner__on_render_post",
|
||||
"inner__on_render_after",
|
||||
"inner__on_render_before",
|
||||
"inner__on_render_pre",
|
||||
"inner__on_render_post",
|
||||
"inner__on_render_after",
|
||||
"middle__on_render_post",
|
||||
"middle__on_render_after",
|
||||
"middle__on_render_before",
|
||||
"middle__on_render_pre",
|
||||
"inner__on_render_before",
|
||||
"inner__on_render_pre",
|
||||
"slotted__on_render_before",
|
||||
"slotted__on_render_pre",
|
||||
"slotted__on_render_post",
|
||||
"slotted__on_render_after",
|
||||
"inner__on_render_post",
|
||||
"inner__on_render_after",
|
||||
"inner__on_render_before",
|
||||
"inner__on_render_pre",
|
||||
"inner__on_render_post",
|
||||
"inner__on_render_after",
|
||||
"middle__on_render_post",
|
||||
"middle__on_render_after",
|
||||
"outer__on_render_post",
|
||||
"outer__on_render_after",
|
||||
]
|
||||
|
||||
def test_context(self):
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
args: {{ args|safe }}
|
||||
kwargs: {{ kwargs|safe }}
|
||||
---
|
||||
from_on_before: {{ from_on_before }}
|
||||
---
|
||||
{% component "nested" %}
|
||||
Hello from simple
|
||||
{% endcomponent %}
|
||||
from_on_before__edited1: {{ from_on_before__edited1 }}
|
||||
from_on_before__edited2: {{ from_on_before__edited2 }}
|
||||
from_on_render_pre: {{ from_on_render_pre }}
|
||||
from_on_render_post: {{ from_on_render_post }}
|
||||
from_on_render_pre__edited2: {{ from_on_render_pre__edited2 }}
|
||||
from_on_render_post__edited2: {{ from_on_render_post__edited2 }}
|
||||
from_on_after: {{ from_on_after }}
|
||||
"""
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {
|
||||
"args": args,
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
# Insert value into the Context
|
||||
context["from_on_before"] = ":)"
|
||||
context["from_on_before"] = "1"
|
||||
|
||||
def on_render(self, context: Context, template: Template):
|
||||
context["from_on_render_pre"] = "2"
|
||||
# Check we can modify entries set by other methods
|
||||
context["from_on_before__edited1"] = context["from_on_before"] + " (on_render)"
|
||||
|
||||
html, error = yield template.render(context)
|
||||
|
||||
context["from_on_render_post"] = "3"
|
||||
|
||||
# NOTE: Since this is called AFTER the render, the values set here should NOT
|
||||
# make it to the rendered output.
|
||||
def on_render_after(
|
||||
self,
|
||||
context: Context,
|
||||
template: Template,
|
||||
html: Optional[str],
|
||||
error: Optional[Exception],
|
||||
) -> None:
|
||||
context["from_on_after"] = "4"
|
||||
# Check we can modify entries set by other methods
|
||||
# NOTE: These also check that the previous values are available
|
||||
context["from_on_before__edited2"] = context["from_on_before"] + " (on_render_after)"
|
||||
context["from_on_render_pre__edited2"] = context["from_on_render_pre"] + " (on_render_after)"
|
||||
context["from_on_render_post__edited2"] = context["from_on_render_post"] + " (on_render_after)"
|
||||
|
||||
rendered = SimpleComponent.render()
|
||||
|
||||
assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
from_on_before: 1
|
||||
from_on_before__edited1: 1 (on_render)
|
||||
from_on_before__edited2:
|
||||
from_on_render_pre: 2
|
||||
from_on_render_post:
|
||||
from_on_render_pre__edited2:
|
||||
from_on_render_post__edited2:
|
||||
from_on_after:
|
||||
""",
|
||||
)
|
||||
|
||||
def test_template(self):
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
text
|
||||
"""
|
||||
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
# Insert text into the Template
|
||||
#
|
||||
# NOTE: Users should NOT do this, because this will insert the text every time
|
||||
# the component is rendered.
|
||||
template.nodelist.append(TextNode("\n---\nFROM_ON_BEFORE"))
|
||||
|
||||
rendered = SimpleComponent.render()
|
||||
assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
args: []
|
||||
kwargs: {}
|
||||
---
|
||||
from_on_before: :)
|
||||
---
|
||||
Hello from nested
|
||||
<div data-djc-id-ca1bc3e data-djc-id-ca1bc40>
|
||||
Hello from simple
|
||||
</div>
|
||||
---
|
||||
FROM_ON_BEFORE
|
||||
""",
|
||||
)
|
||||
def on_render(self, context: Context, template: Template):
|
||||
template.nodelist.append(TextNode("\n---\nFROM_ON_RENDER_PRE"))
|
||||
|
||||
# Check that modifying the context or template does nothing
|
||||
def test_on_render_after(self):
|
||||
captured_content = None
|
||||
html, error = yield template.render(context)
|
||||
|
||||
@register("nested")
|
||||
class NestedComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
Hello from nested
|
||||
<div>
|
||||
{% slot "content" default / %}
|
||||
</div>
|
||||
"""
|
||||
template.nodelist.append(TextNode("\n---\nFROM_ON_RENDER_POST"))
|
||||
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
args: {{ args|safe }}
|
||||
kwargs: {{ kwargs|safe }}
|
||||
---
|
||||
from_on_after: {{ from_on_after }}
|
||||
---
|
||||
{% component "nested" %}
|
||||
Hello from simple
|
||||
{% endcomponent %}
|
||||
"""
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {
|
||||
"args": args,
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
|
||||
# Check that modifying the context or template does nothing
|
||||
def on_render_after(self, context: Context, template: Template, content: str) -> None:
|
||||
# Insert value into the Context
|
||||
context["from_on_after"] = ":)"
|
||||
|
||||
# Insert text into the Template
|
||||
# NOTE: Since this is called AFTER the render, the values set here should NOT
|
||||
# make it to the rendered output.
|
||||
def on_render_after(
|
||||
self,
|
||||
context: Context,
|
||||
template: Template,
|
||||
html: Optional[str],
|
||||
error: Optional[Exception],
|
||||
) -> None:
|
||||
template.nodelist.append(TextNode("\n---\nFROM_ON_AFTER"))
|
||||
|
||||
nonlocal captured_content
|
||||
captured_content = content
|
||||
|
||||
rendered = SimpleComponent.render()
|
||||
|
||||
assertHTMLEqual(
|
||||
captured_content,
|
||||
"""
|
||||
args: []
|
||||
kwargs: {}
|
||||
---
|
||||
from_on_after:
|
||||
---
|
||||
Hello from nested
|
||||
<div data-djc-id-ca1bc3e data-djc-id-ca1bc40>
|
||||
Hello from simple
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
args: []
|
||||
kwargs: {}
|
||||
text
|
||||
---
|
||||
from_on_after:
|
||||
FROM_ON_BEFORE
|
||||
---
|
||||
Hello from nested
|
||||
<div data-djc-id-ca1bc3e data-djc-id-ca1bc40>
|
||||
Hello from simple
|
||||
</div>
|
||||
FROM_ON_RENDER_PRE
|
||||
""",
|
||||
)
|
||||
|
||||
# Check that modifying the context or template does nothing
|
||||
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
|
||||
def test_on_render_after_override_output(self, components_settings):
|
||||
captured_content = None
|
||||
|
||||
@register("nested")
|
||||
class NestedComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
Hello from nested
|
||||
<div>
|
||||
{% slot "content" default / %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def test_on_render_no_yield(self):
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
args: {{ args|safe }}
|
||||
kwargs: {{ kwargs|safe }}
|
||||
---
|
||||
from_on_before: {{ from_on_before }}
|
||||
---
|
||||
{% component "nested" %}
|
||||
Hello from simple
|
||||
{% endcomponent %}
|
||||
text
|
||||
"""
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {
|
||||
"args": args,
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
|
||||
def on_render_after(self, context: Context, template: Template, content: str) -> str:
|
||||
nonlocal captured_content
|
||||
captured_content = content
|
||||
|
||||
return "Chocolate cookie recipe: " + content
|
||||
def on_render(self, context: Context, template: Template):
|
||||
return "OVERRIDDEN"
|
||||
|
||||
rendered = SimpleComponent.render()
|
||||
assert rendered == "OVERRIDDEN"
|
||||
|
||||
assertHTMLEqual(
|
||||
captured_content,
|
||||
"""
|
||||
args: []
|
||||
kwargs: {}
|
||||
---
|
||||
from_on_before:
|
||||
---
|
||||
Hello from nested
|
||||
<div data-djc-id-ca1bc3e data-djc-id-ca1bc40>
|
||||
Hello from simple
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
Chocolate cookie recipe:
|
||||
args: []
|
||||
kwargs: {}
|
||||
---
|
||||
from_on_before:
|
||||
---
|
||||
Hello from nested
|
||||
<div data-djc-id-ca1bc3e data-djc-id-ca1bc40>
|
||||
Hello from simple
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
|
||||
def test_on_render_before_after_same_context(self):
|
||||
context_in_before = None
|
||||
context_in_after = None
|
||||
|
||||
@register("nested")
|
||||
class NestedComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
Hello from nested
|
||||
<div>
|
||||
{% slot "content" default / %}
|
||||
</div>
|
||||
"""
|
||||
def test_on_render_reraise_error(self):
|
||||
registry.register("broken", self._gen_broken_component())
|
||||
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
args: {{ args|safe }}
|
||||
kwargs: {{ kwargs|safe }}
|
||||
---
|
||||
from_on_after: {{ from_on_after }}
|
||||
---
|
||||
{% component "nested" %}
|
||||
Hello from simple
|
||||
{% endcomponent %}
|
||||
{% component "broken" / %}
|
||||
"""
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {
|
||||
"args": args,
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
def on_render(self, context: Context, template: Template):
|
||||
html, error = yield template.render(context)
|
||||
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
context["from_on_before"] = ":)"
|
||||
nonlocal context_in_before
|
||||
context_in_before = context
|
||||
raise error from None # Re-raise original error
|
||||
|
||||
# Check that modifying the context or template does nothing
|
||||
def on_render_after(self, context: Context, template: Template, html: str) -> None:
|
||||
context["from_on_after"] = ":)"
|
||||
nonlocal context_in_after
|
||||
context_in_after = context
|
||||
with pytest.raises(ValueError, match=re.escape("BROKEN")):
|
||||
SimpleComponent.render()
|
||||
|
||||
SimpleComponent.render()
|
||||
@djc_test(
|
||||
parametrize=(
|
||||
["template", "action", "method"],
|
||||
[
|
||||
["simple", "return_none", "on_render"],
|
||||
["broken", "return_none", "on_render"],
|
||||
[None, "return_none", "on_render"],
|
||||
|
||||
assert context_in_before == context_in_after
|
||||
assert "from_on_before" in context_in_before # type: ignore[operator]
|
||||
assert "from_on_after" in context_in_after # type: ignore[operator]
|
||||
["simple", "return_none", "on_render_after"],
|
||||
["broken", "return_none", "on_render_after"],
|
||||
[None, "return_none", "on_render_after"],
|
||||
|
||||
["simple", "no_return", "on_render"],
|
||||
["broken", "no_return", "on_render"],
|
||||
[None, "no_return", "on_render"],
|
||||
|
||||
["simple", "no_return", "on_render_after"],
|
||||
["broken", "no_return", "on_render_after"],
|
||||
[None, "no_return", "on_render_after"],
|
||||
|
||||
["simple", "raise_error", "on_render"],
|
||||
["broken", "raise_error", "on_render"],
|
||||
[None, "raise_error", "on_render"],
|
||||
|
||||
["simple", "raise_error", "on_render_after"],
|
||||
["broken", "raise_error", "on_render_after"],
|
||||
[None, "raise_error", "on_render_after"],
|
||||
|
||||
["simple", "return_html", "on_render"],
|
||||
["broken", "return_html", "on_render"],
|
||||
[None, "return_html", "on_render"],
|
||||
|
||||
["simple", "return_html", "on_render_after"],
|
||||
["broken", "return_html", "on_render_after"],
|
||||
[None, "return_html", "on_render_after"],
|
||||
],
|
||||
None
|
||||
)
|
||||
)
|
||||
def test_result_interception(
|
||||
self,
|
||||
template: Literal["simple", "broken", None],
|
||||
action: Literal["return_none", "no_return", "raise_error", "return_html"],
|
||||
method: Literal["on_render", "on_render_after"],
|
||||
):
|
||||
calls: List[str] = []
|
||||
|
||||
Broken = self._gen_broken_component()
|
||||
Slotted = self._gen_slotted_component(calls)
|
||||
Inner = self._gen_inner_component(calls)
|
||||
Middle = self._gen_middle_component(calls)
|
||||
Outer = self._gen_outer_component(calls)
|
||||
|
||||
# Make modifications to the components based on the parameters
|
||||
|
||||
# Set template
|
||||
if template is None:
|
||||
class Inner(Inner): # type: ignore
|
||||
template = None
|
||||
|
||||
elif template == "broken":
|
||||
class Inner(Inner): # type: ignore
|
||||
template = "{% component 'broken' / %}"
|
||||
|
||||
elif template == "simple":
|
||||
pass
|
||||
|
||||
# Set `on_render` behavior
|
||||
if method == "on_render":
|
||||
if action == "return_none":
|
||||
class Inner(Inner): # type: ignore
|
||||
def on_render(self, context: Context, template: Optional[Template]):
|
||||
if template is None:
|
||||
yield None
|
||||
else:
|
||||
html, error = yield template.render(context)
|
||||
return None
|
||||
|
||||
elif action == "no_return":
|
||||
class Inner(Inner): # type: ignore
|
||||
def on_render(self, context: Context, template: Optional[Template]):
|
||||
if template is None:
|
||||
yield None
|
||||
else:
|
||||
html, error = yield template.render(context)
|
||||
|
||||
elif action == "raise_error":
|
||||
class Inner(Inner): # type: ignore
|
||||
def on_render(self, context: Context, template: Optional[Template]):
|
||||
if template is None:
|
||||
yield None
|
||||
else:
|
||||
html, error = yield template.render(context)
|
||||
raise ValueError("ERROR_FROM_ON_RENDER")
|
||||
|
||||
elif action == "return_html":
|
||||
class Inner(Inner): # type: ignore
|
||||
def on_render(self, context: Context, template: Optional[Template]):
|
||||
if template is None:
|
||||
yield None
|
||||
else:
|
||||
html, error = yield template.render(context)
|
||||
return "HTML_FROM_ON_RENDER"
|
||||
else:
|
||||
raise pytest.fail(f"Unknown action: {action}")
|
||||
|
||||
# Set `on_render_after` behavior
|
||||
elif method == "on_render_after":
|
||||
if action == "return_none":
|
||||
class Inner(Inner): # type: ignore
|
||||
def on_render_after(self, context: Context, template: Template, html: Optional[str], error: Optional[Exception]): # noqa: E501
|
||||
return None
|
||||
|
||||
elif action == "no_return":
|
||||
class Inner(Inner): # type: ignore
|
||||
def on_render_after(self, context: Context, template: Template, html: Optional[str], error: Optional[Exception]): # noqa: E501
|
||||
pass
|
||||
|
||||
elif action == "raise_error":
|
||||
class Inner(Inner): # type: ignore
|
||||
def on_render_after(self, context: Context, template: Template, html: Optional[str], error: Optional[Exception]): # noqa: E501
|
||||
raise ValueError("ERROR_FROM_ON_RENDER")
|
||||
|
||||
elif action == "return_html":
|
||||
class Inner(Inner): # type: ignore
|
||||
def on_render_after(self, context: Context, template: Template, html: Optional[str], error: Optional[Exception]): # noqa: E501
|
||||
return "HTML_FROM_ON_RENDER"
|
||||
else:
|
||||
raise pytest.fail(f"Unknown action: {action}")
|
||||
else:
|
||||
raise pytest.fail(f"Unknown method: {method}")
|
||||
|
||||
registry.register("broken", Broken)
|
||||
registry.register("slotted", Slotted)
|
||||
registry.register("inner", Inner)
|
||||
registry.register("middle", Middle)
|
||||
registry.register("outer", Outer)
|
||||
|
||||
def _gen_expected_output(inner1: str, inner2: str):
|
||||
return f"""
|
||||
Outer start
|
||||
Middle start
|
||||
{inner1}
|
||||
Middle text
|
||||
{inner2}
|
||||
Middle end
|
||||
Outer text
|
||||
Middle start
|
||||
{inner1}
|
||||
Middle text
|
||||
{inner2}
|
||||
Middle end
|
||||
Outer end
|
||||
"""
|
||||
|
||||
# Assert based on the behavior
|
||||
if template is None:
|
||||
# Overriden HTML
|
||||
if action == "return_html":
|
||||
expected = _gen_expected_output(inner1="HTML_FROM_ON_RENDER", inner2="HTML_FROM_ON_RENDER")
|
||||
result = Outer.render()
|
||||
assertHTMLEqual(result, expected)
|
||||
# Overriden error
|
||||
elif action == "raise_error":
|
||||
with pytest.raises(ValueError, match="ERROR_FROM_ON_RENDER"):
|
||||
Outer.render()
|
||||
# Original output
|
||||
elif action in ["return_none", "no_return"]:
|
||||
expected = _gen_expected_output(inner1="", inner2="")
|
||||
result = Outer.render()
|
||||
assertHTMLEqual(result, expected)
|
||||
else:
|
||||
raise pytest.fail(f"Unknown action: {action}")
|
||||
|
||||
elif template == "simple":
|
||||
# Overriden HTML
|
||||
if action == "return_html":
|
||||
expected = _gen_expected_output(inner1="HTML_FROM_ON_RENDER", inner2="HTML_FROM_ON_RENDER")
|
||||
result = Outer.render()
|
||||
assertHTMLEqual(result, expected)
|
||||
# Overriden error
|
||||
elif action == "raise_error":
|
||||
with pytest.raises(ValueError, match="ERROR_FROM_ON_RENDER"):
|
||||
Outer.render()
|
||||
# Original output
|
||||
elif action in ["return_none", "no_return"]:
|
||||
expected = _gen_expected_output(
|
||||
inner1="Inner start Hello from slotted Inner end",
|
||||
inner2="Inner start Inner end",
|
||||
)
|
||||
result = Outer.render()
|
||||
assertHTMLEqual(result, expected)
|
||||
else:
|
||||
raise pytest.fail(f"Unknown action: {action}")
|
||||
|
||||
elif template == "broken":
|
||||
# Overriden HTML
|
||||
if action == "return_html":
|
||||
expected = _gen_expected_output(inner1="HTML_FROM_ON_RENDER", inner2="HTML_FROM_ON_RENDER")
|
||||
result = Outer.render()
|
||||
assertHTMLEqual(result, expected)
|
||||
# Overriden error
|
||||
elif action == "raise_error":
|
||||
with pytest.raises(ValueError, match="ERROR_FROM_ON_RENDER"):
|
||||
Outer.render()
|
||||
# Original output
|
||||
elif action in ["return_none", "no_return"]:
|
||||
with pytest.raises(ValueError, match="broken"):
|
||||
Outer.render()
|
||||
else:
|
||||
raise pytest.fail(f"Unknown action: {action}")
|
||||
|
||||
else:
|
||||
raise pytest.fail(f"Unknown template: {template}")
|
||||
|
||||
|
||||
@djc_test
|
||||
|
|
|
@ -1218,11 +1218,17 @@ class TestContextVarsIsFilled:
|
|||
|
||||
@register("is_filled_vars")
|
||||
class IsFilledVarsComponent(self.IsFilledVarsComponent): # type: ignore[name-defined]
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
def on_render_before(self, context: Context, template: Optional[Template]) -> None:
|
||||
nonlocal captured_before
|
||||
captured_before = self.is_filled.copy()
|
||||
|
||||
def on_render_after(self, context: Context, template: Template, content: str) -> None:
|
||||
def on_render_after(
|
||||
self,
|
||||
context: Context,
|
||||
template: Optional[Template],
|
||||
content: Optional[str],
|
||||
error: Optional[Exception],
|
||||
) -> None:
|
||||
nonlocal captured_after
|
||||
captured_after = self.is_filled.copy()
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ from django_components.extension import (
|
|||
OnComponentUnregisteredContext,
|
||||
OnComponentInputContext,
|
||||
OnComponentDataContext,
|
||||
OnComponentRenderedContext,
|
||||
OnSlotRenderedContext,
|
||||
)
|
||||
from django_components.extensions.cache import CacheExtension
|
||||
|
@ -82,6 +83,7 @@ class DummyExtension(ComponentExtension):
|
|||
"on_component_unregistered": [],
|
||||
"on_component_input": [],
|
||||
"on_component_data": [],
|
||||
"on_component_rendered": [],
|
||||
"on_slot_rendered": [],
|
||||
}
|
||||
|
||||
|
@ -118,6 +120,9 @@ class DummyExtension(ComponentExtension):
|
|||
def on_component_data(self, ctx: OnComponentDataContext) -> None:
|
||||
self.calls["on_component_data"].append(ctx)
|
||||
|
||||
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> None:
|
||||
self.calls["on_component_rendered"].append(ctx)
|
||||
|
||||
def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> None:
|
||||
self.calls["on_slot_rendered"].append(ctx)
|
||||
|
||||
|
@ -147,6 +152,20 @@ class SlotOverrideExtension(ComponentExtension):
|
|||
return "OVERRIDEN BY EXTENSION"
|
||||
|
||||
|
||||
class ErrorOnComponentRenderedExtension(ComponentExtension):
|
||||
name = "error_on_component_rendered"
|
||||
|
||||
def on_component_rendered(self, ctx: OnComponentRenderedContext):
|
||||
raise RuntimeError("Custom error from extension")
|
||||
|
||||
|
||||
class ReturnHtmlOnComponentRenderedExtension(ComponentExtension):
|
||||
name = "return_html_on_component_rendered"
|
||||
|
||||
def on_component_rendered(self, ctx: OnComponentRenderedContext):
|
||||
return f"<div>OVERRIDDEN: {ctx.result}</div>"
|
||||
|
||||
|
||||
def with_component_cls(on_created: Callable):
|
||||
class TempComponent(Component):
|
||||
template = "Hello {{ name }}!"
|
||||
|
@ -340,6 +359,45 @@ class TestExtensionHooks:
|
|||
assert data_call.js_data == {"script": "console.log('Hello!')"}
|
||||
assert data_call.css_data == {"style": "body { color: blue; }"}
|
||||
|
||||
# Verify on_component_rendered was called with correct args
|
||||
assert len(extension.calls["on_component_rendered"]) == 1
|
||||
rendered_call: OnComponentRenderedContext = extension.calls["on_component_rendered"][0]
|
||||
assert rendered_call.component_cls == TestComponent
|
||||
assert isinstance(rendered_call.component, TestComponent)
|
||||
assert isinstance(rendered_call.component_id, str)
|
||||
assert rendered_call.result == "<!-- _RENDERED TestComponent_f4a4f0,ca1bc3e,, -->Hello Test!"
|
||||
assert rendered_call.error is None
|
||||
|
||||
@djc_test(components_settings={"extensions": [DummyExtension]})
|
||||
def test_component_render_hooks__error(self):
|
||||
@register("test_comp")
|
||||
class TestComponent(Component):
|
||||
template = "Hello {{ name }}!"
|
||||
|
||||
def on_render_after(self, context, template, result, error):
|
||||
raise Exception("Oopsie woopsie")
|
||||
|
||||
with pytest.raises(Exception, match="Oopsie woopsie"):
|
||||
# Render the component with some args and kwargs
|
||||
TestComponent.render(
|
||||
context=Context({"foo": "bar"}),
|
||||
args=("arg1", "arg2"),
|
||||
kwargs={"name": "Test"},
|
||||
slots={"content": "Some content"},
|
||||
)
|
||||
|
||||
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
|
||||
|
||||
# Verify on_component_rendered was called with correct args
|
||||
assert len(extension.calls["on_component_rendered"]) == 1
|
||||
rendered_call: OnComponentRenderedContext = extension.calls["on_component_rendered"][0]
|
||||
assert rendered_call.component_cls == TestComponent
|
||||
assert isinstance(rendered_call.component, TestComponent)
|
||||
assert isinstance(rendered_call.component_id, str)
|
||||
assert rendered_call.result is None
|
||||
assert isinstance(rendered_call.error, Exception)
|
||||
assert str(rendered_call.error) == "An error occured while rendering components TestComponent:\nOopsie woopsie"
|
||||
|
||||
@djc_test(components_settings={"extensions": [DummyExtension]})
|
||||
def test_on_slot_rendered(self):
|
||||
@register("test_comp")
|
||||
|
@ -387,6 +445,30 @@ class TestExtensionHooks:
|
|||
|
||||
assert rendered == "Hello OVERRIDEN BY EXTENSION!"
|
||||
|
||||
@djc_test(components_settings={"extensions": [ErrorOnComponentRenderedExtension]})
|
||||
def test_on_component_rendered__error_from_extension(self):
|
||||
@register("test_comp_error_ext")
|
||||
class TestComponent(Component):
|
||||
template = "Hello {{ name }}!"
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {"name": kwargs.get("name", "World")}
|
||||
|
||||
with pytest.raises(RuntimeError, match="Custom error from extension"):
|
||||
TestComponent.render(args=(), kwargs={"name": "Test"})
|
||||
|
||||
@djc_test(components_settings={"extensions": [ReturnHtmlOnComponentRenderedExtension]})
|
||||
def test_on_component_rendered__return_html_from_extension(self):
|
||||
@register("test_comp_html_ext")
|
||||
class TestComponent(Component):
|
||||
template = "Hello {{ name }}!"
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {"name": kwargs.get("name", "World")}
|
||||
|
||||
rendered = TestComponent.render(args=(), kwargs={"name": "Test"})
|
||||
assert rendered == "<div>OVERRIDDEN: Hello Test!</div>"
|
||||
|
||||
|
||||
@djc_test
|
||||
class TestExtensionViews:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue