feat: on_xx_loaded extension hooks (#1242)
Some checks failed
Run tests / test_sampleproject (3.13) (push) Has been cancelled
Run tests / build (ubuntu-latest, 3.13) (push) Has been cancelled
Run tests / build (ubuntu-latest, 3.8) (push) Has been cancelled
Run tests / build (ubuntu-latest, 3.9) (push) Has been cancelled
Run tests / build (windows-latest, 3.10) (push) Has been cancelled
Run tests / build (windows-latest, 3.11) (push) Has been cancelled
Run tests / build (windows-latest, 3.12) (push) Has been cancelled
Run tests / build (windows-latest, 3.13) (push) Has been cancelled
Run tests / build (windows-latest, 3.8) (push) Has been cancelled
Run tests / build (windows-latest, 3.9) (push) Has been cancelled
Run tests / test_docs (3.13) (push) Has been cancelled
Docs - build & deploy / docs (push) Has been cancelled
Run tests / build (ubuntu-latest, 3.10) (push) Has been cancelled
Run tests / build (ubuntu-latest, 3.11) (push) Has been cancelled
Run tests / build (ubuntu-latest, 3.12) (push) Has been cancelled

* feat: on_xx_loaded extension hooks

* refactor: fix tests
This commit is contained in:
Juro Oravec 2025-06-08 17:28:10 +02:00 committed by GitHub
parent efe5eb0ba5
commit 09bcf8dbcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 720 additions and 113 deletions

View file

@ -46,7 +46,7 @@ class TestMainMedia:
rendered = TestComponent.render()
assertInHTML(
'<div class="html-css-only" data-djc-id-ca1bc3e>Content</div>',
'<div class="html-css-only" data-djc-id-ca1bc40>Content</div>',
rendered,
)
assertInHTML(
@ -70,6 +70,9 @@ class TestMainMedia:
assert TestComponent.css == ".html-css-only { color: blue; }"
assert TestComponent.js == "console.log('HTML and JS only');"
assert isinstance(TestComponent._template, Template)
assert TestComponent._template.origin.component_cls is TestComponent
@djc_test(
django_settings={
"STATICFILES_DIRS": [
@ -127,6 +130,9 @@ class TestMainMedia:
assert TestComponent.css == ".html-css-only {\n color: blue;\n}\n"
assert TestComponent.js == 'console.log("JS file");\n'
assert isinstance(TestComponent._template, Template)
assert TestComponent._template.origin.component_cls is TestComponent
@djc_test(
django_settings={
"STATICFILES_DIRS": [
@ -151,6 +157,9 @@ class TestMainMedia:
assert ".html-css-only {\n color: blue;\n}" in TestComponent.css # type: ignore[operator]
assert 'console.log("HTML and JS only");' in TestComponent.js # type: ignore[operator]
assert isinstance(TestComponent._template, Template)
assert TestComponent._template.origin.component_cls is TestComponent
rendered = Template(
"""
{% load component_tags %}
@ -199,6 +208,7 @@ class TestMainMedia:
# the corresponding ComponentMedia instance is also on the parent class.
assert AppLvlCompComponent._component_media.css is UNSET # type: ignore[attr-defined]
assert AppLvlCompComponent._component_media.css_file == "app_lvl_comp.css" # type: ignore[attr-defined]
assert AppLvlCompComponent._component_media._template is UNSET # type: ignore[attr-defined]
# Access the property to load the CSS
_ = TestComponent.css
@ -218,6 +228,9 @@ class TestMainMedia:
assert AppLvlCompComponent._component_media.js == 'console.log("JS file");\n' # type: ignore[attr-defined]
assert AppLvlCompComponent._component_media.js_file == "app_lvl_comp/app_lvl_comp.js" # type: ignore[attr-defined]
assert isinstance(AppLvlCompComponent._component_media._template, Template) # type: ignore[attr-defined]
assert AppLvlCompComponent._component_media._template.origin.component_cls is AppLvlCompComponent # type: ignore[attr-defined]
def test_html_variable_filtered(self):
class FilteredComponent(Component):
template: types.django_html = """
@ -1037,73 +1050,121 @@ class TestSubclassingAttributes:
class TestComp(Component):
js = None
js_file = None
template = None
template_file = None
assert TestComp.js is None
assert TestComp.js_file is None
assert TestComp.template is None
assert TestComp.template_file is None
def test_mixing_none_and_non_none_raises(self):
with pytest.raises(
ImproperlyConfigured,
match=re.escape("Received non-empty value from both 'js' and 'js_file' in Component TestComp"),
match=re.escape("Received non-empty value from both 'template' and 'template_file' in Component TestComp"),
):
class TestComp(Component):
js = "console.log('hi')"
js_file = None
template = "<h1>hi</h1>"
template_file = None
def test_both_non_none_raises(self):
with pytest.raises(
ImproperlyConfigured,
match=re.escape("Received non-empty value from both 'js' and 'js_file' in Component TestComp"),
match=re.escape("Received non-empty value from both 'template' and 'template_file' in Component TestComp"),
):
class TestComp(Component):
js = "console.log('hi')"
js_file = "file.js"
template = "<h1>hi</h1>"
template_file = "file.html"
def test_parent_non_null_child_non_null(self):
class ParentComp(Component):
js = "console.log('parent')"
template = "<h1>parent</h1>"
class TestComp(ParentComp):
js = "console.log('child')"
template = "<h1>child</h1>"
assert TestComp.js == "console.log('child')"
assert TestComp.js_file is None
assert TestComp.template == "<h1>child</h1>"
assert TestComp.template_file is None
assert isinstance(ParentComp._template, Template)
assert ParentComp._template.source == "<h1>parent</h1>"
assert ParentComp._template.origin.component_cls == ParentComp
assert isinstance(TestComp._template, Template)
assert TestComp._template.source == "<h1>child</h1>"
assert TestComp._template.origin.component_cls == TestComp
def test_parent_null_child_non_null(self):
class ParentComp(Component):
js = None
template = None
class TestComp(ParentComp):
js = "console.log('child')"
template = "<h1>child</h1>"
assert TestComp.js == "console.log('child')"
assert TestComp.js_file is None
assert TestComp.template == "<h1>child</h1>"
assert TestComp.template_file is None
assert ParentComp._template is None
assert isinstance(TestComp._template, Template)
assert TestComp._template.source == "<h1>child</h1>"
assert TestComp._template.origin.component_cls == TestComp
def test_parent_non_null_child_null(self):
class ParentComp(Component):
js: Optional[str] = "console.log('parent')"
template: Optional[str] = "<h1>parent</h1>"
class TestComp(ParentComp):
js = None
template = None
assert TestComp.js is None
assert TestComp.js_file is None
assert TestComp.template is None
assert TestComp.template_file is None
assert TestComp._template is None
assert isinstance(ParentComp._template, Template)
assert ParentComp._template.source == "<h1>parent</h1>"
assert ParentComp._template.origin.component_cls == ParentComp
def test_parent_null_child_null(self):
class ParentComp(Component):
js = None
template = None
class TestComp(ParentComp):
js = None
template = None
assert TestComp.js is None
assert TestComp.js_file is None
assert TestComp.template is None
assert TestComp.template_file is None
assert TestComp._template is None
assert ParentComp._template is None
def test_grandparent_non_null_parent_pass_child_pass(self):
class GrandParentComp(Component):
js = "console.log('grandparent')"
template = "<h1>grandparent</h1>"
class ParentComp(GrandParentComp):
pass
@ -1113,45 +1174,97 @@ class TestSubclassingAttributes:
assert TestComp.js == "console.log('grandparent')"
assert TestComp.js_file is None
assert TestComp.template == "<h1>grandparent</h1>"
assert TestComp.template_file is None
assert isinstance(GrandParentComp._template, Template)
assert GrandParentComp._template.source == "<h1>grandparent</h1>"
assert GrandParentComp._template.origin.component_cls == GrandParentComp
assert isinstance(ParentComp._template, Template)
assert ParentComp._template.source == "<h1>grandparent</h1>"
assert ParentComp._template.origin.component_cls == ParentComp
assert isinstance(TestComp._template, Template)
assert TestComp._template.source == "<h1>grandparent</h1>"
assert TestComp._template.origin.component_cls == TestComp
def test_grandparent_non_null_parent_null_child_pass(self):
class GrandParentComp(Component):
js: Optional[str] = "console.log('grandparent')"
template: Optional[str] = "<h1>grandparent</h1>"
class ParentComp(GrandParentComp):
js = None
template = None
class TestComp(ParentComp):
pass
assert TestComp.js is None
assert TestComp.js_file is None
assert TestComp.template is None
assert TestComp.template_file is None
assert isinstance(GrandParentComp._template, Template)
assert GrandParentComp._template.source == "<h1>grandparent</h1>"
assert GrandParentComp._template.origin.component_cls == GrandParentComp
assert ParentComp._template is None
assert TestComp._template is None
def test_grandparent_non_null_parent_pass_child_non_null(self):
class GrandParentComp(Component):
js = "console.log('grandparent')"
template = "<h1>grandparent</h1>"
class ParentComp(GrandParentComp):
pass
class TestComp(ParentComp):
js = "console.log('child')"
template = "<h1>child</h1>"
assert TestComp.js == "console.log('child')"
assert TestComp.js_file is None
assert TestComp.template == "<h1>child</h1>"
assert TestComp.template_file is None
assert isinstance(GrandParentComp._template, Template)
assert GrandParentComp._template.source == "<h1>grandparent</h1>"
assert GrandParentComp._template.origin.component_cls == GrandParentComp
assert isinstance(ParentComp._template, Template)
assert ParentComp._template.source == "<h1>grandparent</h1>"
assert ParentComp._template.origin.component_cls == ParentComp
assert isinstance(TestComp._template, Template)
assert TestComp._template.source == "<h1>child</h1>"
assert TestComp._template.origin.component_cls == TestComp
def test_grandparent_null_parent_pass_child_non_null(self):
class GrandParentComp(Component):
js = None
template = None
class ParentComp(GrandParentComp):
pass
class TestComp(ParentComp):
js = "console.log('child')"
template = "<h1>child</h1>"
assert TestComp.js == "console.log('child')"
assert TestComp.js_file is None
assert TestComp.template == "<h1>child</h1>"
assert TestComp.template_file is None
assert GrandParentComp._template is None
assert ParentComp._template is None
assert isinstance(TestComp._template, Template)
assert TestComp._template.source == "<h1>child</h1>"
assert TestComp._template.origin.component_cls == TestComp
@djc_test

View file

@ -3,7 +3,7 @@ from typing import Any, Callable, Dict, List, cast
import pytest
from django.http import HttpRequest, HttpResponse
from django.template import Context
from django.template import Context, Origin, Template
from django.test import Client
from django_components import Component, Slot, SlotNode, register, registry
@ -23,6 +23,10 @@ from django_components.extension import (
OnComponentDataContext,
OnComponentRenderedContext,
OnSlotRenderedContext,
OnTemplateLoadedContext,
OnTemplateCompiledContext,
OnJsLoadedContext,
OnCssLoadedContext,
)
from django_components.extensions.cache import CacheExtension
from django_components.extensions.debug_highlight import DebugHighlightExtension
@ -85,6 +89,10 @@ class DummyExtension(ComponentExtension):
"on_component_data": [],
"on_component_rendered": [],
"on_slot_rendered": [],
"on_template_loaded": [],
"on_template_compiled": [],
"on_js_loaded": [],
"on_css_loaded": [],
}
urls = [
@ -126,6 +134,18 @@ class DummyExtension(ComponentExtension):
def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> None:
self.calls["on_slot_rendered"].append(ctx)
def on_template_loaded(self, ctx):
self.calls["on_template_loaded"].append(ctx)
def on_template_compiled(self, ctx):
self.calls["on_template_compiled"].append(ctx)
def on_js_loaded(self, ctx):
self.calls["on_js_loaded"].append(ctx)
def on_css_loaded(self, ctx):
self.calls["on_css_loaded"].append(ctx)
class DummyNestedExtension(ComponentExtension):
name = "test_nested_extension"
@ -182,6 +202,19 @@ def with_registry(on_created: Callable):
on_created(registry)
class OverrideAssetExtension(ComponentExtension):
name = "override_asset_extension"
def on_template_loaded(self, ctx):
return "OVERRIDDEN TEMPLATE"
def on_js_loaded(self, ctx):
return "OVERRIDDEN JS"
def on_css_loaded(self, ctx):
return "OVERRIDDEN CSS"
@djc_test
class TestExtension:
@djc_test(components_settings={"extensions": [DummyExtension]})
@ -469,6 +502,120 @@ class TestExtensionHooks:
rendered = TestComponent.render(args=(), kwargs={"name": "Test"})
assert rendered == "<div>OVERRIDDEN: Hello Test!</div>"
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_asset_hooks__inlined(self):
@register("test_comp_hooks")
class TestComponent(Component):
template = "Hello {{ name }}!"
js = "console.log('hi');"
css = "body { color: red; }"
def get_template_data(self, args, kwargs, slots, context):
return {"name": kwargs.get("name", "World")}
# Render the component to trigger all hooks
TestComponent.render(args=(), kwargs={"name": "Test"})
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
# on_template_loaded
assert len(extension.calls["on_template_loaded"]) == 1
ctx1: OnTemplateLoadedContext = extension.calls["on_template_loaded"][0]
assert ctx1.component_cls == TestComponent
assert ctx1.content == "Hello {{ name }}!"
assert isinstance(ctx1.origin, Origin)
assert ctx1.origin.name.endswith("test_extension.py::TestComponent")
assert ctx1.name is None
# on_template_compiled
assert len(extension.calls["on_template_compiled"]) == 1
ctx2: OnTemplateCompiledContext = extension.calls["on_template_compiled"][0]
assert ctx2.component_cls == TestComponent
assert isinstance(ctx2.template, Template)
# on_js_loaded
assert len(extension.calls["on_js_loaded"]) == 1
ctx3: OnJsLoadedContext = extension.calls["on_js_loaded"][0]
assert ctx3.component_cls == TestComponent
assert ctx3.content == "console.log('hi');"
# on_css_loaded
assert len(extension.calls["on_css_loaded"]) == 1
ctx4: OnCssLoadedContext = extension.calls["on_css_loaded"][0]
assert ctx4.component_cls == TestComponent
assert ctx4.content == "body { color: red; }"
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_asset_hooks__file(self):
@register("test_comp_hooks")
class TestComponent(Component):
template_file = "relative_file/relative_file.html"
js_file = "relative_file/relative_file.js"
css_file = "relative_file/relative_file.css"
def get_template_data(self, args, kwargs, slots, context):
return {"name": kwargs.get("name", "World")}
# Render the component to trigger all hooks
TestComponent.render(args=(), kwargs={"name": "Test"})
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
# on_template_loaded
# NOTE: The template file gets picked up by 'django.template.loaders.filesystem.Loader',
# as well as our own loader, so we get two calls here.
assert len(extension.calls["on_template_loaded"]) == 2
ctx1: OnTemplateLoadedContext = extension.calls["on_template_loaded"][0]
assert ctx1.component_cls == TestComponent
assert ctx1.content == (
'<form method="post">\n'
' {% csrf_token %}\n'
' <input type="text" name="variable" value="{{ variable }}">\n'
' <input type="submit">\n'
'</form>\n'
)
assert isinstance(ctx1.origin, Origin)
assert ctx1.origin.name.endswith("relative_file.html")
assert ctx1.name == "relative_file/relative_file.html"
# on_template_compiled
assert len(extension.calls["on_template_compiled"]) == 2
ctx2: OnTemplateCompiledContext = extension.calls["on_template_compiled"][0]
assert ctx2.component_cls == TestComponent
assert isinstance(ctx2.template, Template)
# on_js_loaded
assert len(extension.calls["on_js_loaded"]) == 1
ctx3: OnJsLoadedContext = extension.calls["on_js_loaded"][0]
assert ctx3.component_cls == TestComponent
assert ctx3.content == 'console.log("JS file");\n'
# on_css_loaded
assert len(extension.calls["on_css_loaded"]) == 1
ctx4: OnCssLoadedContext = extension.calls["on_css_loaded"][0]
assert ctx4.component_cls == TestComponent
assert ctx4.content == (
'.html-css-only {\n'
' color: blue;\n'
'}\n'
)
@djc_test(components_settings={"extensions": [OverrideAssetExtension]})
def test_asset_hooks_override(self):
@register("test_comp_override")
class TestComponent(Component):
template = "Hello {{ name }}!"
js = "console.log('hi');"
css = "body { color: red; }"
def get_template_data(self, args, kwargs, slots, context):
return {"name": kwargs.get("name", "World")}
# No need to render, accessing the attributes should trigger the hooks
assert TestComponent.template == "OVERRIDDEN TEMPLATE"
assert TestComponent.js == "OVERRIDDEN JS"
assert TestComponent.css == "OVERRIDDEN CSS"
@djc_test
class TestExtensionViews: