mirror of
https://github.com/django-components/django-components.git
synced 2025-07-07 17:34:59 +00:00
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
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:
parent
efe5eb0ba5
commit
09bcf8dbcc
11 changed files with 720 additions and 113 deletions
34
CHANGELOG.md
34
CHANGELOG.md
|
@ -1,5 +1,39 @@
|
|||
# Release notes
|
||||
|
||||
## v0.141.0
|
||||
|
||||
#### Feat
|
||||
|
||||
- New extension hooks `on_template_loaded`, `on_js_loaded`, `on_css_loaded`, and `on_template_compiled`
|
||||
|
||||
The first 3 hooks are called when Component's template / JS / CSS is loaded as a string.
|
||||
|
||||
The `on_template_compiled` hook is called when Component's template is compiled to a Template.
|
||||
|
||||
The `on_xx_loaded` hooks can modify the content by returning the new value.
|
||||
|
||||
```py
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_template_loaded(self, ctx: OnTemplateLoadedContext) -> Optional[str]:
|
||||
return ctx.content + "<!-- Hello! -->"
|
||||
|
||||
def on_js_loaded(self, ctx: OnJsLoadedContext) -> Optional[str]:
|
||||
return ctx.content + "// Hello!"
|
||||
|
||||
def on_css_loaded(self, ctx: OnCssLoadedContext) -> Optional[str]:
|
||||
return ctx.content + "/* Hello! */"
|
||||
```
|
||||
|
||||
See all [Extension hooks](https://django-components.github.io/django-components/0.141.0/reference/extension_hooks/).
|
||||
|
||||
#### Fix
|
||||
|
||||
- Subclassing - Previously, if a parent component defined `Component.template` or `Component.template_file`, it's subclass would use the same `Template` instance.
|
||||
|
||||
This could lead to unexpected behavior, where a change to the template of the subclass would also change the template of the parent class.
|
||||
|
||||
Now, each subclass has it's own `Template` instance, and changes to the template of the subclass do not affect the template of the parent class.
|
||||
|
||||
## v0.140.1
|
||||
|
||||
#### Fix
|
||||
|
|
|
@ -148,6 +148,42 @@ name | type | description
|
|||
`name` | `str` | The name the component was registered under
|
||||
`registry` | [`ComponentRegistry`](../api#django_components.ComponentRegistry) | The registry the component was unregistered from
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_css_loaded
|
||||
options:
|
||||
heading_level: 3
|
||||
show_root_heading: true
|
||||
show_signature: true
|
||||
separate_signature: true
|
||||
show_symbol_type_heading: false
|
||||
show_symbol_type_toc: false
|
||||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
**Available data:**
|
||||
|
||||
name | type | description
|
||||
--|--|--
|
||||
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class whose CSS was loaded
|
||||
`content` | `str` | The CSS content (string)
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_js_loaded
|
||||
options:
|
||||
heading_level: 3
|
||||
show_root_heading: true
|
||||
show_signature: true
|
||||
separate_signature: true
|
||||
show_symbol_type_heading: false
|
||||
show_symbol_type_toc: false
|
||||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
**Available data:**
|
||||
|
||||
name | type | description
|
||||
--|--|--
|
||||
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class whose JS was loaded
|
||||
`content` | `str` | The JS content (string)
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_registry_created
|
||||
options:
|
||||
heading_level: 3
|
||||
|
@ -207,6 +243,44 @@ name | type | description
|
|||
`slot_name` | `str` | The name of the `{% slot %}` tag
|
||||
`slot_node` | `SlotNode` | The node instance of the `{% slot %}` tag
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_template_compiled
|
||||
options:
|
||||
heading_level: 3
|
||||
show_root_heading: true
|
||||
show_signature: true
|
||||
separate_signature: true
|
||||
show_symbol_type_heading: false
|
||||
show_symbol_type_toc: false
|
||||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
**Available data:**
|
||||
|
||||
name | type | description
|
||||
--|--|--
|
||||
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class whose template was loaded
|
||||
`template` | `django.template.base.Template` | The compiled template object
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_template_loaded
|
||||
options:
|
||||
heading_level: 3
|
||||
show_root_heading: true
|
||||
show_signature: true
|
||||
separate_signature: true
|
||||
show_symbol_type_heading: false
|
||||
show_symbol_type_toc: false
|
||||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
**Available data:**
|
||||
|
||||
name | type | description
|
||||
--|--|--
|
||||
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class whose template was loaded
|
||||
`content` | `str` | The template string
|
||||
`name` | `Optional[str]` | The name of the template
|
||||
`origin` | `Optional[django.template.base.Origin]` | The origin of the template
|
||||
|
||||
## Objects
|
||||
|
||||
::: django_components.extension.OnComponentClassCreatedContext
|
||||
|
|
|
@ -32,7 +32,7 @@ from django_components.component_media import ComponentMediaInput, ComponentMedi
|
|||
from django_components.component_registry import ComponentRegistry
|
||||
from django_components.component_registry import registry as registry_
|
||||
from django_components.constants import COMP_ID_PREFIX
|
||||
from django_components.context import _COMPONENT_CONTEXT_KEY, make_isolated_context_copy
|
||||
from django_components.context import _COMPONENT_CONTEXT_KEY, COMPONENT_IS_NESTED_KEY, make_isolated_context_copy
|
||||
from django_components.dependencies import (
|
||||
DependenciesStrategy,
|
||||
cache_component_css,
|
||||
|
@ -2314,9 +2314,6 @@ class Component(metaclass=ComponentMeta):
|
|||
cls.class_id = hash_comp_cls(cls)
|
||||
comp_cls_id_mapping[cls.class_id] = cls
|
||||
|
||||
# Make sure that subclassed component will store it's own template, not the parent's.
|
||||
cls._template = None
|
||||
|
||||
ALL_COMPONENTS.append(cached_ref(cls)) # type: ignore[arg-type]
|
||||
extensions._init_component_class(cls)
|
||||
extensions.on_component_class_created(OnComponentClassCreatedContext(cls))
|
||||
|
@ -3517,14 +3514,14 @@ class Component(metaclass=ComponentMeta):
|
|||
# Then we can simply apply `template_data` to the context in the same layer
|
||||
# where we apply `context_processor_data` and `component_vars`.
|
||||
with prepare_component_template(component, template_data) as template:
|
||||
# Set `Template._djc_is_component_nested` based on whether we're currently INSIDE
|
||||
# Set `_DJC_COMPONENT_IS_NESTED` based on whether we're currently INSIDE
|
||||
# the `{% extends %}` tag.
|
||||
# Part of fix for https://github.com/django-components/django-components/issues/508
|
||||
# See django_monkeypatch.py
|
||||
if template is not None:
|
||||
template._djc_is_component_nested = bool(
|
||||
context.render_context.get(BLOCK_CONTEXT_KEY) # type: ignore[union-attr]
|
||||
)
|
||||
comp_is_nested = bool(context.render_context.get(BLOCK_CONTEXT_KEY)) # type: ignore[union-attr]
|
||||
else:
|
||||
comp_is_nested = False
|
||||
|
||||
# Capture the template name so we can print better error messages (currently used in slots)
|
||||
component_ctx.template_name = template.name if template else None
|
||||
|
@ -3535,6 +3532,7 @@ class Component(metaclass=ComponentMeta):
|
|||
**component.context_processors_data,
|
||||
# Private context fields
|
||||
_COMPONENT_CONTEXT_KEY: render_id,
|
||||
COMPONENT_IS_NESTED_KEY: comp_is_nested,
|
||||
# NOTE: Public API for variables accessible from within a component's template
|
||||
# See https://github.com/django-components/django-components/issues/280#issuecomment-2081180940
|
||||
"component_vars": ComponentVars(
|
||||
|
|
|
@ -27,10 +27,12 @@ from weakref import WeakKeyDictionary
|
|||
from django.contrib.staticfiles import finders
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.forms.widgets import Media as MediaCls
|
||||
from django.template import Template
|
||||
from django.utils.safestring import SafeData
|
||||
from typing_extensions import TypeGuard
|
||||
|
||||
from django_components.template import load_component_template
|
||||
from django_components.extension import OnCssLoadedContext, OnJsLoadedContext, extensions
|
||||
from django_components.template import ensure_unique_template, load_component_template
|
||||
from django_components.util.loader import get_component_dirs, resolve_file
|
||||
from django_components.util.logger import logger
|
||||
from django_components.util.misc import flatten, get_import_path, get_module_info, is_glob
|
||||
|
@ -43,7 +45,7 @@ T = TypeVar("T")
|
|||
|
||||
|
||||
# These are all the attributes that are handled by ComponentMedia and lazily-resolved
|
||||
COMP_MEDIA_LAZY_ATTRS = ("media", "template", "template_file", "js", "js_file", "css", "css_file")
|
||||
COMP_MEDIA_LAZY_ATTRS = ("media", "template", "template_file", "js", "js_file", "css", "css_file", "_template")
|
||||
|
||||
|
||||
# Sentinel value to indicate that a media attribute is not set.
|
||||
|
@ -267,6 +269,8 @@ class ComponentMedia:
|
|||
js_file: Union[str, Unset, None] = UNSET
|
||||
css: Union[str, Unset, None] = UNSET
|
||||
css_file: Union[str, Unset, None] = UNSET
|
||||
# Template instance that was loaded for this component
|
||||
_template: Union[Template, Unset, None] = UNSET
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for inlined_attr in ("template", "js", "css"):
|
||||
|
@ -299,6 +303,7 @@ class ComponentMedia:
|
|||
def reset(self) -> None:
|
||||
self.__dict__.update(self._original.__dict__)
|
||||
self.resolved = False
|
||||
self.resolved_relative_files = False
|
||||
|
||||
|
||||
# This metaclass is all about one thing - lazily resolving the media files.
|
||||
|
@ -487,6 +492,10 @@ def _get_comp_cls_media(comp_cls: Type["Component"]) -> Any:
|
|||
if curr_cls in media_cache:
|
||||
continue
|
||||
|
||||
comp_media: Optional[ComponentMedia] = getattr(curr_cls, "_component_media", None)
|
||||
if comp_media is not None and not comp_media.resolved:
|
||||
_resolve_media(curr_cls, comp_media)
|
||||
|
||||
# Prepare base classes
|
||||
# NOTE: If the `Component.Media` class is explicitly set to `None`, then we should not inherit
|
||||
# from any parent classes.
|
||||
|
@ -611,20 +620,39 @@ def _resolve_media(comp_cls: Type["Component"], comp_media: ComponentMedia) -> N
|
|||
# Effectively, even if the Component class defined `js_file` (or others), at "runtime" the `js` attribute
|
||||
# will be set to the content of the file.
|
||||
# So users can access `Component.js` even if they defined `Component.js_file`.
|
||||
comp_media.template = _get_asset(
|
||||
template_str, template_obj = _get_asset(
|
||||
comp_cls,
|
||||
comp_media,
|
||||
inlined_attr="template",
|
||||
file_attr="template_file",
|
||||
comp_dirs=comp_dirs,
|
||||
type="template",
|
||||
)
|
||||
comp_media.js = _get_asset(
|
||||
comp_cls, comp_media, inlined_attr="js", file_attr="js_file", comp_dirs=comp_dirs, type="static"
|
||||
)
|
||||
comp_media.css = _get_asset(
|
||||
comp_cls, comp_media, inlined_attr="css", file_attr="css_file", comp_dirs=comp_dirs, type="static"
|
||||
)
|
||||
comp_media.template = template_str
|
||||
|
||||
js_str, _ = _get_asset(comp_cls, comp_media, inlined_attr="js", file_attr="js_file", comp_dirs=comp_dirs)
|
||||
comp_media.js = js_str
|
||||
|
||||
css_str, _ = _get_asset(comp_cls, comp_media, inlined_attr="css", file_attr="css_file", comp_dirs=comp_dirs)
|
||||
comp_media.css = css_str
|
||||
|
||||
# If `Component.template` or `Component.template_file` were explicitly set on this class,
|
||||
# then Template instance was already created.
|
||||
#
|
||||
# Otherwise, search for Template instance in parent classes, and make a copy of it.
|
||||
if not isinstance(template_obj, Unset):
|
||||
comp_media._template = template_obj
|
||||
else:
|
||||
parent_template = _get_comp_cls_attr(comp_cls, "_template")
|
||||
|
||||
# One of base classes has set `template` or `template_file` to `None`,
|
||||
# or none of the base classes had set `template` or `template_file`
|
||||
if parent_template is None:
|
||||
comp_media._template = parent_template
|
||||
|
||||
# One of base classes has set `template` or `template_file` to string.
|
||||
# Make a copy of the Template instance.
|
||||
else:
|
||||
comp_media._template = ensure_unique_template(comp_cls, parent_template)
|
||||
|
||||
|
||||
def _normalize_media(media: Type[ComponentMediaInput]) -> None:
|
||||
|
@ -973,11 +1001,10 @@ def _find_component_dir_containing_file(
|
|||
def _get_asset(
|
||||
comp_cls: Type["Component"],
|
||||
comp_media: ComponentMedia,
|
||||
inlined_attr: str,
|
||||
file_attr: str,
|
||||
inlined_attr: Literal["template", "js", "css"],
|
||||
file_attr: Literal["template_file", "js_file", "css_file"],
|
||||
comp_dirs: List[Path],
|
||||
type: Literal["template", "static"],
|
||||
) -> Union[str, Unset, None]:
|
||||
) -> Tuple[Union[str, Unset, None], Union[Template, Unset, None]]: # Tuple of (content, Template)
|
||||
"""
|
||||
In case of Component's JS or CSS, one can either define that as "inlined" or as a file.
|
||||
|
||||
|
@ -1010,7 +1037,7 @@ def _get_asset(
|
|||
# pass
|
||||
# ```
|
||||
if asset_content is UNSET and asset_file is UNSET:
|
||||
return UNSET
|
||||
return UNSET, UNSET
|
||||
|
||||
# Either file or content attr was set to `None`
|
||||
# ```py
|
||||
|
@ -1031,7 +1058,7 @@ def _get_asset(
|
|||
if (asset_content in (UNSET, None) and asset_file is None) or (
|
||||
asset_content is None and asset_file in (UNSET, None)
|
||||
):
|
||||
return None
|
||||
return None, None
|
||||
|
||||
# Received both inlined content and file name
|
||||
# ```py
|
||||
|
@ -1061,42 +1088,68 @@ def _get_asset(
|
|||
# At this point we can tell that only EITHER `asset_content` OR `asset_file` is set.
|
||||
|
||||
# If the content was inlined into the component (e.g. `Component.template = "..."`)
|
||||
# then there's nothing to resolve. Return as is.
|
||||
if asset_content is not UNSET:
|
||||
return asset_content
|
||||
# then there's nothing to resolve. Use it as is.
|
||||
if not isinstance(asset_content, Unset):
|
||||
if asset_content is None:
|
||||
return None, None
|
||||
|
||||
if asset_file is None:
|
||||
return None
|
||||
content: str = asset_content
|
||||
|
||||
# The rest of the code assumes that we were given only a file name
|
||||
asset_file = cast(str, asset_file)
|
||||
# If we got inlined `Component.template`, then create a Template instance from it
|
||||
# to trigger the extension hooks that may modify the template string.
|
||||
if inlined_attr == "template":
|
||||
# NOTE: `load_component_template()` applies `on_template_loaded()` and `on_template_compiled()` hooks.
|
||||
template = load_component_template(comp_cls, filepath=None, content=content)
|
||||
return template.source, template
|
||||
|
||||
if type == "template":
|
||||
# NOTE: While we return on the "source" (plain string) of the template,
|
||||
# by calling `load_component_template()`, we also cache the Template instance.
|
||||
# So later in Component's `render_impl()`, we don't have to re-compile the Template.
|
||||
template = load_component_template(comp_cls, asset_file)
|
||||
return template.source
|
||||
# This else branch assumes that we were given a file name (possibly None)
|
||||
# Load the contents of the file.
|
||||
else:
|
||||
if asset_file is None:
|
||||
return None, None
|
||||
|
||||
# For static files, we have a few options:
|
||||
# 1. Check if the file is in one of the components' directories
|
||||
full_path = resolve_file(asset_file, comp_dirs)
|
||||
asset_file = cast(str, asset_file)
|
||||
|
||||
# 2. If not, check if it's in the static files
|
||||
if full_path is None:
|
||||
full_path = finders.find(asset_file)
|
||||
if inlined_attr == "template":
|
||||
# NOTE: `load_component_template()` applies `on_template_loaded()` and `on_template_compiled()` hooks.
|
||||
template = load_component_template(comp_cls, filepath=asset_file, content=None)
|
||||
return template.source, template
|
||||
|
||||
if full_path is None:
|
||||
# NOTE: The short name, e.g. `js` or `css` is used in the error message for convenience
|
||||
raise ValueError(f"Could not find {inlined_attr} file {asset_file}")
|
||||
# Following code concerns with loading JS / CSS files.
|
||||
# Here we have a few options:
|
||||
#
|
||||
# 1. Check if the file is in one of the components' directories
|
||||
full_path = resolve_file(asset_file, comp_dirs)
|
||||
|
||||
# NOTE: Use explicit encoding for compat with Windows, see #1074
|
||||
asset_content = Path(full_path).read_text(encoding="utf8")
|
||||
# 2. If not, check if it's in the static files
|
||||
if full_path is None:
|
||||
full_path = finders.find(asset_file)
|
||||
|
||||
# TODO: Apply `extensions.on_js_preprocess()` and `extensions.on_css_preprocess()`
|
||||
# NOTE: `on_template_preprocess()` is already applied inside `load_component_template()`
|
||||
if full_path is None:
|
||||
# NOTE: The short name, e.g. `js` or `css` is used in the error message for convenience
|
||||
raise ValueError(f"Could not find {inlined_attr} file {asset_file}")
|
||||
|
||||
return asset_content
|
||||
# NOTE: Use explicit encoding for compat with Windows, see #1074
|
||||
content = Path(full_path).read_text(encoding="utf8")
|
||||
|
||||
# NOTE: `on_template_loaded()` is already applied inside `load_component_template()`
|
||||
# but we still need to call extension hooks for JS / CSS content (whether inlined or not).
|
||||
if inlined_attr == "js":
|
||||
content = extensions.on_js_loaded(
|
||||
OnJsLoadedContext(
|
||||
component_cls=comp_cls,
|
||||
content=content,
|
||||
)
|
||||
)
|
||||
elif inlined_attr == "css":
|
||||
content = extensions.on_css_loaded(
|
||||
OnCssLoadedContext(
|
||||
component_cls=comp_cls,
|
||||
content=content,
|
||||
)
|
||||
)
|
||||
|
||||
return content, None
|
||||
|
||||
|
||||
def is_set(value: Union[T, Unset, None]) -> TypeGuard[T]:
|
||||
|
|
|
@ -11,6 +11,7 @@ from django.template import Context
|
|||
from django_components.util.misc import get_last_index
|
||||
|
||||
_COMPONENT_CONTEXT_KEY = "_DJC_COMPONENT_CTX"
|
||||
COMPONENT_IS_NESTED_KEY = "_DJC_COMPONENT_IS_NESTED"
|
||||
_STRATEGY_CONTEXT_KEY = "DJC_DEPS_STRATEGY"
|
||||
_INJECT_CONTEXT_KEY_PREFIX = "_DJC_INJECT__"
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ from typing import (
|
|||
)
|
||||
|
||||
import django.urls
|
||||
from django.template import Context
|
||||
from django.template import Context, Origin, Template
|
||||
from django.urls import URLPattern, URLResolver, get_resolver, get_urlconf
|
||||
|
||||
from django_components.app_settings import app_settings
|
||||
|
@ -168,6 +168,42 @@ class OnSlotRenderedContext(NamedTuple):
|
|||
"""The rendered result of the slot"""
|
||||
|
||||
|
||||
@mark_extension_hook_api
|
||||
class OnTemplateLoadedContext(NamedTuple):
|
||||
component_cls: Type["Component"]
|
||||
"""The Component class whose template was loaded"""
|
||||
content: str
|
||||
"""The template string"""
|
||||
origin: Optional[Origin]
|
||||
"""The origin of the template"""
|
||||
name: Optional[str]
|
||||
"""The name of the template"""
|
||||
|
||||
|
||||
@mark_extension_hook_api
|
||||
class OnTemplateCompiledContext(NamedTuple):
|
||||
component_cls: Type["Component"]
|
||||
"""The Component class whose template was loaded"""
|
||||
template: Template
|
||||
"""The compiled template object"""
|
||||
|
||||
|
||||
@mark_extension_hook_api
|
||||
class OnCssLoadedContext(NamedTuple):
|
||||
component_cls: Type["Component"]
|
||||
"""The Component class whose CSS was loaded"""
|
||||
content: str
|
||||
"""The CSS content (string)"""
|
||||
|
||||
|
||||
@mark_extension_hook_api
|
||||
class OnJsLoadedContext(NamedTuple):
|
||||
component_cls: Type["Component"]
|
||||
"""The Component class whose JS was loaded"""
|
||||
content: str
|
||||
"""The JS content (string)"""
|
||||
|
||||
|
||||
################################################
|
||||
# EXTENSIONS CORE
|
||||
################################################
|
||||
|
@ -763,6 +799,108 @@ class ComponentExtension(metaclass=ExtensionMeta):
|
|||
"""
|
||||
pass
|
||||
|
||||
##########################
|
||||
# Template / JS / CSS hooks
|
||||
##########################
|
||||
|
||||
def on_template_loaded(self, ctx: OnTemplateLoadedContext) -> Optional[str]:
|
||||
"""
|
||||
Called when a Component's template is loaded as a string.
|
||||
|
||||
This hook runs only once per [`Component`](../api#django_components.Component) class and works for both
|
||||
[`Component.template`](../api#django_components.Component.template) and
|
||||
[`Component.template_file`](../api#django_components.Component.template_file).
|
||||
|
||||
Use this hook to read or modify the template before it's compiled.
|
||||
|
||||
To modify the template, return a new string from this hook.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnTemplateLoadedContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_template_loaded(self, ctx: OnTemplateLoadedContext) -> Optional[str]:
|
||||
# Modify the template
|
||||
return ctx.content.replace("Hello", "Hi")
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_template_compiled(self, ctx: OnTemplateCompiledContext) -> None:
|
||||
"""
|
||||
Called when a Component's template is compiled
|
||||
into a [`Template`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template) object.
|
||||
|
||||
This hook runs only once per [`Component`](../api#django_components.Component) class and works for both
|
||||
[`Component.template`](../api#django_components.Component.template) and
|
||||
[`Component.template_file`](../api#django_components.Component.template_file).
|
||||
|
||||
Use this hook to read or modify the template (in-place) after it's compiled.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnTemplateCompiledContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_template_compiled(self, ctx: OnTemplateCompiledContext) -> None:
|
||||
print(f"Template origin: {ctx.template.origin.name}")
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_css_loaded(self, ctx: OnCssLoadedContext) -> Optional[str]:
|
||||
"""
|
||||
Called when a Component's CSS is loaded as a string.
|
||||
|
||||
This hook runs only once per [`Component`](../api#django_components.Component) class and works for both
|
||||
[`Component.css`](../api#django_components.Component.css) and
|
||||
[`Component.css_file`](../api#django_components.Component.css_file).
|
||||
|
||||
Use this hook to read or modify the CSS.
|
||||
|
||||
To modify the CSS, return a new string from this hook.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnCssLoadedContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_css_loaded(self, ctx: OnCssLoadedContext) -> Optional[str]:
|
||||
# Modify the CSS
|
||||
return ctx.content.replace("Hello", "Hi")
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_js_loaded(self, ctx: OnJsLoadedContext) -> Optional[str]:
|
||||
"""
|
||||
Called when a Component's JS is loaded as a string.
|
||||
|
||||
This hook runs only once per [`Component`](../api#django_components.Component) class and works for both
|
||||
[`Component.js`](../api#django_components.Component.js) and
|
||||
[`Component.js_file`](../api#django_components.Component.js_file).
|
||||
|
||||
Use this hook to read or modify the JS.
|
||||
|
||||
To modify the JS, return a new string from this hook.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnCssLoadedContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_js_loaded(self, ctx: OnJsLoadedContext) -> Optional[str]:
|
||||
# Modify the JS
|
||||
return ctx.content.replace("Hello", "Hi")
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
##########################
|
||||
# Tags lifecycle hooks
|
||||
##########################
|
||||
|
@ -1169,9 +1307,34 @@ class ExtensionManager:
|
|||
return ctx.result, ctx.error
|
||||
|
||||
##########################
|
||||
# Tags lifecycle hooks
|
||||
# Template / JS / CSS hooks
|
||||
##########################
|
||||
|
||||
def on_template_loaded(self, ctx: OnTemplateLoadedContext) -> str:
|
||||
for extension in self.extensions:
|
||||
content = extension.on_template_loaded(ctx)
|
||||
if content is not None:
|
||||
ctx = ctx._replace(content=content)
|
||||
return ctx.content
|
||||
|
||||
def on_template_compiled(self, ctx: OnTemplateCompiledContext) -> None:
|
||||
for extension in self.extensions:
|
||||
extension.on_template_compiled(ctx)
|
||||
|
||||
def on_css_loaded(self, ctx: OnCssLoadedContext) -> str:
|
||||
for extension in self.extensions:
|
||||
content = extension.on_css_loaded(ctx)
|
||||
if content is not None:
|
||||
ctx = ctx._replace(content=content)
|
||||
return ctx.content
|
||||
|
||||
def on_js_loaded(self, ctx: OnJsLoadedContext) -> str:
|
||||
for extension in self.extensions:
|
||||
content = extension.on_js_loaded(ctx)
|
||||
if content is not None:
|
||||
ctx = ctx._replace(content=content)
|
||||
return ctx.content
|
||||
|
||||
def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:
|
||||
for extension in self.extensions:
|
||||
result = extension.on_slot_rendered(ctx)
|
||||
|
|
|
@ -28,7 +28,7 @@ from django.utils.html import conditional_escape
|
|||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components.app_settings import ContextBehavior
|
||||
from django_components.context import _COMPONENT_CONTEXT_KEY, _INJECT_CONTEXT_KEY_PREFIX
|
||||
from django_components.context import _COMPONENT_CONTEXT_KEY, _INJECT_CONTEXT_KEY_PREFIX, COMPONENT_IS_NESTED_KEY
|
||||
from django_components.extension import OnSlotRenderedContext, extensions
|
||||
from django_components.node import BaseNode
|
||||
from django_components.perfutil.component import component_context_cache
|
||||
|
@ -1585,8 +1585,6 @@ def _nodelist_to_slot(
|
|||
# and binds the context.
|
||||
template = Template("")
|
||||
template.nodelist = nodelist
|
||||
# This allows the template to access current RenderContext layer.
|
||||
template._djc_is_component_nested = True
|
||||
|
||||
def render_func(ctx: SlotContext) -> SlotResult:
|
||||
context = ctx.context or Context()
|
||||
|
@ -1639,10 +1637,12 @@ def _nodelist_to_slot(
|
|||
|
||||
trace_component_msg("RENDER_NODELIST", component_name, component_id=None, slot_name=slot_name)
|
||||
|
||||
# We wrap the slot nodelist in Template. However, we also override Django's `Template.render()`
|
||||
# to call `render_dependencies()` on the results. So we need to set the strategy to `ignore`
|
||||
# so that the dependencies are processed only once the whole component tree is rendered.
|
||||
with context.push({"DJC_DEPS_STRATEGY": "ignore"}):
|
||||
# NOTE 1: We wrap the slot nodelist in Template. However, we also override Django's `Template.render()`
|
||||
# to call `render_dependencies()` on the results. So we need to set the strategy to `ignore`
|
||||
# so that the dependencies are processed only once the whole component tree is rendered.
|
||||
# NOTE 2: We also set `_DJC_COMPONENT_IS_NESTED` to `True` so that the template can access
|
||||
# current RenderContext layer.
|
||||
with context.push({"DJC_DEPS_STRATEGY": "ignore", COMPONENT_IS_NESTED_KEY: True}):
|
||||
rendered = template.render(context)
|
||||
|
||||
# After the rendering is done, remove the `extra_context` from the context stack
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import sys
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Any, Dict, Generator, List, Optional, Type, Union, cast
|
||||
from typing import TYPE_CHECKING, Any, Dict, Generator, List, Optional, Type, Union
|
||||
from weakref import ReferenceType, ref
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
@ -170,30 +170,45 @@ def _maybe_bind_template(context: Context, template: Template) -> Generator[None
|
|||
loading_components: List["ComponentRef"] = []
|
||||
|
||||
|
||||
def load_component_template(component_cls: Type["Component"], filepath: str) -> Template:
|
||||
if component_cls._template is not None:
|
||||
return component_cls._template
|
||||
def load_component_template(
|
||||
component_cls: Type["Component"],
|
||||
filepath: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
) -> Template:
|
||||
if filepath is None and content is None:
|
||||
raise ValueError("Either `filepath` or `content` must be provided.")
|
||||
|
||||
loading_components.append(ref(component_cls))
|
||||
|
||||
# Use Django's `get_template()` to load the template
|
||||
template = _load_django_template(filepath)
|
||||
if filepath is not None:
|
||||
# Use Django's `get_template()` to load the template file
|
||||
template = _load_django_template(filepath)
|
||||
template = ensure_unique_template(component_cls, template)
|
||||
|
||||
# If template.origin.component_cls is already set, then this
|
||||
# Template instance was cached by Django / template loaders.
|
||||
# In that case we want to make a copy of the template which would
|
||||
# be owned by the current Component class.
|
||||
# Thus each Component has it's own Template instance with their own Origins
|
||||
# pointing to the correct Component class.
|
||||
if get_component_from_origin(template.origin) is not None:
|
||||
elif content is not None:
|
||||
template = _create_template_from_string(component_cls, content, is_component_template=True)
|
||||
else:
|
||||
raise ValueError("Received both `filepath` and `content`. These are mutually exclusive.")
|
||||
|
||||
loading_components.pop()
|
||||
|
||||
return template
|
||||
|
||||
|
||||
# When loading a Template instance, it may be cached by Django / template loaders.
|
||||
# In that case we want to make a copy of the template which would
|
||||
# be owned by the current Component class.
|
||||
# Thus each Component has it's own Template instance with their own Origins
|
||||
# pointing to the correct Component class.
|
||||
def ensure_unique_template(component_cls: Type["Component"], template: Template) -> Template:
|
||||
# Use `template.origin.component_cls` to check if the template was cached by Django / template loaders.
|
||||
if get_component_from_origin(template.origin) is None:
|
||||
set_component_to_origin(template.origin, component_cls)
|
||||
else:
|
||||
origin_copy = Origin(template.origin.name, template.origin.template_name, template.origin.loader)
|
||||
set_component_to_origin(origin_copy, component_cls)
|
||||
template = Template(template.source, origin=origin_copy, name=template.name, engine=template.engine)
|
||||
|
||||
component_cls._template = template
|
||||
|
||||
loading_components.pop()
|
||||
|
||||
return template
|
||||
|
||||
|
||||
|
@ -250,24 +265,13 @@ def _get_component_template(component: "Component") -> Optional[Template]:
|
|||
template = None
|
||||
template_string = template_sources["get_template"]
|
||||
elif component.template or component.template_file:
|
||||
# If the template was loaded from `Component.template_file`, then the Template
|
||||
# instance was already created and cached in `Component._template`.
|
||||
# If the template was loaded from `Component.template` or `Component.template_file`,
|
||||
# then the Template instance was already created and cached in `Component._template`.
|
||||
#
|
||||
# NOTE: This is important to keep in mind, because the implication is that we should
|
||||
# treat Templates AND their nodelists as IMMUTABLE.
|
||||
if component.__class__._template is not None:
|
||||
template = component.__class__._template
|
||||
template_string = None
|
||||
# Otherwise user have set `Component.template` as string and we still need to
|
||||
# create the instance.
|
||||
else:
|
||||
template = _create_template_from_string(
|
||||
component,
|
||||
# NOTE: We can't reach this branch if `Component.template` is None
|
||||
cast(str, component.template),
|
||||
is_component_template=True,
|
||||
)
|
||||
template_string = None
|
||||
template = component.__class__._component_media._template # type: ignore[attr-defined]
|
||||
template_string = None
|
||||
# No template
|
||||
else:
|
||||
template = None
|
||||
|
@ -278,7 +282,7 @@ def _get_component_template(component: "Component") -> Optional[Template]:
|
|||
return template
|
||||
# Create the template from the string
|
||||
elif template_string is not None:
|
||||
return _create_template_from_string(component, template_string)
|
||||
return _create_template_from_string(component.__class__, template_string)
|
||||
|
||||
# Otherwise, Component has no template - this is valid, as it may be instead rendered
|
||||
# via `Component.on_render()`
|
||||
|
@ -286,7 +290,7 @@ def _get_component_template(component: "Component") -> Optional[Template]:
|
|||
|
||||
|
||||
def _create_template_from_string(
|
||||
component: "Component",
|
||||
component: Type["Component"],
|
||||
template_string: str,
|
||||
is_component_template: bool = False,
|
||||
) -> Template:
|
||||
|
@ -307,18 +311,17 @@ def _create_template_from_string(
|
|||
# ```
|
||||
#
|
||||
# See https://docs.djangoproject.com/en/5.2/howto/custom-template-backend/#template-origin-api
|
||||
_, _, module_filepath = get_module_info(component.__class__)
|
||||
_, _, module_filepath = get_module_info(component)
|
||||
origin = Origin(
|
||||
name=f"{module_filepath}::{component.__class__.__name__}",
|
||||
name=f"{module_filepath}::{component.__name__}",
|
||||
template_name=None,
|
||||
loader=None,
|
||||
)
|
||||
|
||||
set_component_to_origin(origin, component.__class__)
|
||||
set_component_to_origin(origin, component)
|
||||
|
||||
if is_component_template:
|
||||
template = Template(template_string, name=origin.template_name, origin=origin)
|
||||
component.__class__._template = template
|
||||
else:
|
||||
# TODO_V1 - `cached_template()` won't be needed as there will be only 1 template per component
|
||||
# so we will be able to instead use `template_cache` to store the template
|
||||
|
|
|
@ -3,21 +3,25 @@ from typing import Any, Optional, Type
|
|||
from django.template import Context, NodeList, Template
|
||||
from django.template.base import Origin, Parser
|
||||
|
||||
from django_components.context import _COMPONENT_CONTEXT_KEY, _STRATEGY_CONTEXT_KEY
|
||||
from django_components.context import _COMPONENT_CONTEXT_KEY, _STRATEGY_CONTEXT_KEY, COMPONENT_IS_NESTED_KEY
|
||||
from django_components.dependencies import COMPONENT_COMMENT_REGEX, render_dependencies
|
||||
from django_components.extension import OnTemplateCompiledContext, OnTemplateLoadedContext, extensions
|
||||
from django_components.util.template_parser import parse_template
|
||||
|
||||
|
||||
# In some cases we can't work around Django's design, and need to patch the template class.
|
||||
def monkeypatch_template_cls(template_cls: Type[Template]) -> None:
|
||||
if is_template_cls_patched(template_cls):
|
||||
return
|
||||
|
||||
monkeypatch_template_init(template_cls)
|
||||
monkeypatch_template_compile_nodelist(template_cls)
|
||||
monkeypatch_template_render(template_cls)
|
||||
template_cls._djc_patched = True
|
||||
|
||||
|
||||
# Patch `Template.__init__` to apply `extensions.on_template_preprocess()` if the template
|
||||
# belongs to a Component.
|
||||
# Patch `Template.__init__` to apply `on_template_loaded()` and `on_template_compiled()`
|
||||
# extension hooks if the template belongs to a Component.
|
||||
def monkeypatch_template_init(template_cls: Type[Template]) -> None:
|
||||
original_init = template_cls.__init__
|
||||
|
||||
|
@ -27,6 +31,7 @@ def monkeypatch_template_init(template_cls: Type[Template]) -> None:
|
|||
self: Template,
|
||||
template_string: Any,
|
||||
origin: Optional[Origin] = None,
|
||||
name: Optional[str] = None,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
|
@ -56,11 +61,27 @@ def monkeypatch_template_init(template_cls: Type[Template]) -> None:
|
|||
component_cls = None
|
||||
|
||||
if component_cls is not None:
|
||||
# TODO - Apply extensions.on_template_preprocess() here.
|
||||
# Then also test both cases when template as `template` or `template_file`.
|
||||
pass
|
||||
template_string = str(template_string)
|
||||
template_string = extensions.on_template_loaded(
|
||||
OnTemplateLoadedContext(
|
||||
component_cls=component_cls,
|
||||
content=template_string,
|
||||
origin=origin,
|
||||
name=name,
|
||||
)
|
||||
)
|
||||
|
||||
original_init(self, template_string, origin, *args, **kwargs) # type: ignore[misc]
|
||||
# Calling original `Template.__init__` should also compile the template into a Nodelist
|
||||
# via `Template.compile_nodelist()`.
|
||||
original_init(self, template_string, origin, name, *args, **kwargs) # type: ignore[misc]
|
||||
|
||||
if component_cls is not None:
|
||||
extensions.on_template_compiled(
|
||||
OnTemplateCompiledContext(
|
||||
component_cls=component_cls,
|
||||
template=self,
|
||||
)
|
||||
)
|
||||
|
||||
template_cls.__init__ = __init__
|
||||
|
||||
|
@ -110,7 +131,7 @@ def monkeypatch_template_compile_nodelist(template_cls: Type[Template]) -> None:
|
|||
|
||||
def monkeypatch_template_render(template_cls: Type[Template]) -> None:
|
||||
# Modify `Template.render` to set `isolated_context` kwarg of `push_state`
|
||||
# based on our custom `Template._djc_is_component_nested`.
|
||||
# based on our custom `_DJC_COMPONENT_IS_NESTED`.
|
||||
#
|
||||
# Part of fix for https://github.com/django-components/django-components/issues/508
|
||||
#
|
||||
|
@ -125,7 +146,7 @@ def monkeypatch_template_render(template_cls: Type[Template]) -> None:
|
|||
# doesn't require the source to be parsed multiple times. User can pass extra args/kwargs,
|
||||
# and can modify the rendering behavior by overriding the `_render` method.
|
||||
#
|
||||
# NOTE 2: Instead of setting `Template._djc_is_component_nested`, alternatively we could
|
||||
# NOTE 2: Instead of setting `_DJC_COMPONENT_IS_NESTED` context key, alternatively we could
|
||||
# have passed the value to `monkeypatch_template_render` directly. However, we intentionally
|
||||
# did NOT do that, so the monkey-patched method is more robust, and can be e.g. copied
|
||||
# to other.
|
||||
|
@ -137,12 +158,12 @@ def monkeypatch_template_render(template_cls: Type[Template]) -> None:
|
|||
def _template_render(self: Template, context: Context, *args: Any, **kwargs: Any) -> str:
|
||||
"Display stage -- can be called many times"
|
||||
# We parametrized `isolated_context`, which was `True` in the original method.
|
||||
if not hasattr(self, "_djc_is_component_nested"):
|
||||
if COMPONENT_IS_NESTED_KEY not in context:
|
||||
isolated_context = True
|
||||
else:
|
||||
# MUST be `True` for templates that are NOT import with `{% extends %}` tag,
|
||||
# and `False` otherwise.
|
||||
isolated_context = not self._djc_is_component_nested
|
||||
isolated_context = not context[COMPONENT_IS_NESTED_KEY]
|
||||
|
||||
# This is original implementation, except we override `isolated_context`,
|
||||
# and we post-process the result with `render_dependencies()`.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue