mirror of
https://github.com/django-components/django-components.git
synced 2025-08-04 14:28:18 +00:00
feat: add self
context var and make is_filled
into attribute (#632)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
2d0f270df4
commit
e712800f5e
5 changed files with 265 additions and 31 deletions
22
README.md
22
README.md
|
@ -45,7 +45,6 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
|
|||
- [Use components in templates](#use-components-in-templates)
|
||||
- [Use components outside of templates](#use-components-outside-of-templates)
|
||||
- [Use components as views](#use-components-as-views)
|
||||
- [Pre-defined components](#pre-defined-components)
|
||||
- [Typing and validating components](#typing-and-validating-components)
|
||||
- [Pre-defined components](#pre-defined-components)
|
||||
- [Registering components](#registering-components)
|
||||
|
@ -57,6 +56,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
|
|||
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject)
|
||||
- [Component hooks](#component-hooks)
|
||||
- [Component context and scope](#component-context-and-scope)
|
||||
- [Pre-defined template variables](#pre-defined-template-variables)
|
||||
- [Customizing component tags with TagFormatter](#customizing-component-tags-with-tagformatter)
|
||||
- [Defining HTML/JS/CSS files](#defining-htmljscss-files)
|
||||
- [Rendering JS/CSS dependencies](#rendering-jscss-dependencies)
|
||||
|
@ -1474,7 +1474,7 @@ This produces:
|
|||
|
||||
_Added in version 0.26._
|
||||
|
||||
> NOTE: In version 0.70, `{% if_filled %}` tags were replaced with `{{ component_vars.is_filled }}` variables. If your slot name contained special characters, see the section "Accessing slot names with special characters".
|
||||
> NOTE: In version 0.70, `{% if_filled %}` tags were replaced with `{{ component_vars.is_filled }}` variables. If your slot name contained special characters, see the section [Accessing `is_filled` of slot names with special characters](#accessing-is_filled-of-slot-names-with-special-characters).
|
||||
|
||||
In certain circumstances, you may want the behavior of slot filling to depend on
|
||||
whether or not a particular slot is filled.
|
||||
|
@ -2659,6 +2659,24 @@ If you find yourself using the `only` modifier often, you can set the [context_b
|
|||
|
||||
Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`.
|
||||
|
||||
## Pre-defined template variables
|
||||
|
||||
Here is a list of all variables that are automatically available from within the component's template and `on_render_before` / `on_render_after` hooks.
|
||||
|
||||
- `component_vars.is_filled`
|
||||
|
||||
_New in version 0.70_
|
||||
|
||||
Dictonary describing which slots are filled (`True`) or are not (`False`).
|
||||
|
||||
Example:
|
||||
|
||||
```django
|
||||
{% if component_vars.is_filled.my_slot %}
|
||||
{% slot "my_slot" / %}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
## Customizing component tags with TagFormatter
|
||||
|
||||
_New in version 0.89_
|
||||
|
|
|
@ -65,7 +65,7 @@ from django_components.slots import (
|
|||
)
|
||||
from django_components.utils import gen_id, validate_typed_dict, validate_typed_tuple
|
||||
|
||||
# TODO_DEPRECATE_V1 - REMOVE IN V1, users should use top-level import instead
|
||||
# TODO_REMOVE_IN_V1 - Users should use top-level import instead
|
||||
# isort: off
|
||||
from django_components.component_registry import AlreadyRegistered as AlreadyRegistered # NOQA
|
||||
from django_components.component_registry import ComponentRegistry as ComponentRegistry # NOQA
|
||||
|
@ -94,6 +94,12 @@ class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
|
|||
escape_slots_content: bool
|
||||
|
||||
|
||||
@dataclass()
|
||||
class RenderStackItem(Generic[ArgsType, KwargsType, SlotsType]):
|
||||
input: RenderInput[ArgsType, KwargsType, SlotsType]
|
||||
is_filled: Optional[Dict[str, bool]]
|
||||
|
||||
|
||||
class ViewFn(Protocol):
|
||||
def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704
|
||||
|
||||
|
@ -219,7 +225,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
|||
self.fill_content = fill_content or {}
|
||||
self.component_id = component_id or gen_id()
|
||||
self.registry = registry or registry_
|
||||
self._render_stack: Deque[RenderInput[ArgsType, KwargsType, SlotsType]] = deque()
|
||||
self._render_stack: Deque[RenderStackItem[ArgsType, KwargsType, SlotsType]] = deque()
|
||||
# None == uninitialized, False == No types, Tuple == types
|
||||
self._types: Optional[Union[Tuple[Any, Any, Any, Any], Literal[False]]] = None
|
||||
|
||||
|
@ -241,7 +247,29 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
|||
|
||||
# NOTE: Input is managed as a stack, so if `render` is called within another `render`,
|
||||
# the propertes below will return only the inner-most state.
|
||||
return self._render_stack[-1]
|
||||
return self._render_stack[-1].input
|
||||
|
||||
@property
|
||||
def is_filled(self) -> Dict[str, bool]:
|
||||
"""
|
||||
Dictionary describing which slots have or have not been filled.
|
||||
|
||||
This attribute is available for use only within the template as `{{ component_vars.is_filled.slot_name }}`,
|
||||
and within `on_render_before` and `on_render_after` hooks.
|
||||
"""
|
||||
if not len(self._render_stack):
|
||||
raise RuntimeError(
|
||||
f"{self.name}: Tried to access Component's `is_filled` attribute "
|
||||
"while outside of rendering execution"
|
||||
)
|
||||
|
||||
ctx = self._render_stack[-1]
|
||||
if ctx.is_filled is None:
|
||||
raise RuntimeError(
|
||||
f"{self.name}: Tried to access Component's `is_filled` attribute " "before slots were resolved"
|
||||
)
|
||||
|
||||
return ctx.is_filled
|
||||
|
||||
def get_context_data(self, *args: Any, **kwargs: Any) -> DataType:
|
||||
return cast(DataType, {})
|
||||
|
@ -508,13 +536,16 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
|||
# to access the provided context, slots, etc. Also required so users can
|
||||
# call `self.inject()` from within `get_context_data()`.
|
||||
self._render_stack.append(
|
||||
RenderInput(
|
||||
context=context,
|
||||
slots=slots,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
escape_slots_content=escape_slots_content,
|
||||
)
|
||||
RenderStackItem(
|
||||
input=RenderInput(
|
||||
context=context,
|
||||
slots=slots,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
escape_slots_content=escape_slots_content,
|
||||
),
|
||||
is_filled=None,
|
||||
),
|
||||
)
|
||||
|
||||
self._validate_inputs()
|
||||
|
@ -557,6 +588,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
|||
# to see if given slot was filled, e.g.:
|
||||
# `{% if variable > 8 and component_vars.is_filled.header %}`
|
||||
slot_bools = {slot_fill.escaped_name: slot_fill.is_filled for slot_fill in resolved_fills.values()}
|
||||
self._render_stack[-1].is_filled = slot_bools
|
||||
|
||||
with context.update(
|
||||
{
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import sys
|
||||
import typing
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, List, Mapping, Sequence, Tuple, Union, get_type_hints
|
||||
|
||||
|
@ -39,6 +40,49 @@ def watch_files_for_autoreload(watch_list: Sequence[Union[str, Path]]) -> None:
|
|||
autoreload_started.connect(autoreload_hook)
|
||||
|
||||
|
||||
# Get all types that users may use from the `typing` module.
|
||||
#
|
||||
# These are the types that we do NOT try to resolve when it's a typed generic,
|
||||
# e.g. `Union[int, str]`.
|
||||
# If we get a typed generic that's NOT part of this set, we assume it's a user-made
|
||||
# generic, e.g. `Component[Args, Kwargs]`. In such case we assert that a given value
|
||||
# is an instance of the base class, e.g. `Component`.
|
||||
_typing_exports = frozenset(
|
||||
[
|
||||
value
|
||||
for value in typing.__dict__.values()
|
||||
if isinstance(
|
||||
value,
|
||||
(
|
||||
typing._SpecialForm,
|
||||
# Used in 3.8 and 3.9
|
||||
getattr(typing, "_GenericAlias", ()),
|
||||
# Used in 3.11+ (possibly 3.10?)
|
||||
getattr(typing, "_SpecialGenericAlias", ()),
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _prepare_type_for_validation(the_type: Any) -> Any:
|
||||
# If we got a typed generic (AKA "subscripted" generic), e.g.
|
||||
# `Component[CompArgs, CompKwargs, ...]`
|
||||
# then we cannot use that generic in `isintance()`, because we get this error:
|
||||
# `TypeError("Subscripted generics cannot be used with class and instance checks")`
|
||||
#
|
||||
# Instead, we resolve the generic to its original class, e.g. `Component`,
|
||||
# which can then be used in instance assertion.
|
||||
if hasattr(the_type, "__origin__"):
|
||||
is_custom_typing = the_type.__origin__ not in _typing_exports
|
||||
if is_custom_typing:
|
||||
return the_type.__origin__
|
||||
else:
|
||||
return the_type
|
||||
else:
|
||||
return the_type
|
||||
|
||||
|
||||
# NOTE: tuple_type is a _GenericAlias - See https://stackoverflow.com/questions/74412803
|
||||
def validate_typed_tuple(
|
||||
value: Tuple[Any, ...],
|
||||
|
@ -63,7 +107,8 @@ def validate_typed_tuple(
|
|||
|
||||
for index, arg_type in enumerate(tuple_type.__args__):
|
||||
arg = value[index]
|
||||
if not isinstance(arg, arg_type):
|
||||
arg_type = _prepare_type_for_validation(arg_type)
|
||||
if sys.version_info >= (3, 11) and not isinstance(arg, arg_type):
|
||||
# Generate errors like below (listed for searchability)
|
||||
# `Component 'name' expected positional argument at index 0 to be <class 'int'>, got 123.5 of type <class 'float'>` # noqa: E501
|
||||
raise TypeError(
|
||||
|
@ -101,6 +146,7 @@ def validate_typed_dict(value: Mapping[str, Any], dict_type: Any, prefix: str, k
|
|||
else:
|
||||
unseen_keys.remove(key)
|
||||
kwarg = value[key]
|
||||
kwarg_type = _prepare_type_for_validation(kwarg_type)
|
||||
|
||||
# NOTE: `isinstance()` cannot be used with the version of TypedDict prior to 3.11.
|
||||
# So we do type validation for TypedDicts only in 3.11 and later.
|
||||
|
|
|
@ -5,7 +5,7 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py`
|
|||
|
||||
import re
|
||||
import sys
|
||||
from typing import Any, Dict, Tuple, Union, no_type_check
|
||||
from typing import Any, Dict, List, Tuple, Union, no_type_check
|
||||
|
||||
# See https://peps.python.org/pep-0655/#usage-in-python-3-11
|
||||
if sys.version_info >= (3, 11):
|
||||
|
@ -506,6 +506,111 @@ class ComponentValidationTest(BaseTestCase):
|
|||
},
|
||||
)
|
||||
|
||||
def test_handles_components_in_typing(self):
|
||||
class InnerKwargs(TypedDict):
|
||||
one: str
|
||||
|
||||
class InnerData(TypedDict):
|
||||
one: Union[str, int]
|
||||
self: "InnerComp" # type: ignore[misc]
|
||||
|
||||
InnerComp = Component[Any, InnerKwargs, InnerData, Any] # type: ignore[misc]
|
||||
|
||||
class Inner(InnerComp):
|
||||
def get_context_data(self, one):
|
||||
return {
|
||||
"self": self,
|
||||
"one": one,
|
||||
}
|
||||
|
||||
template = ""
|
||||
|
||||
TodoArgs = Tuple[Inner] # type: ignore[misc]
|
||||
|
||||
class TodoKwargs(TypedDict):
|
||||
inner: Inner
|
||||
|
||||
class TodoData(TypedDict):
|
||||
one: Union[str, int]
|
||||
self: "TodoComp" # type: ignore[misc]
|
||||
inner: str
|
||||
|
||||
TodoComp = Component[TodoArgs, TodoKwargs, TodoData, Any] # type: ignore[misc]
|
||||
|
||||
# NOTE: Since we're using ForwardRef for "TodoComp" and "InnerComp", we need
|
||||
# to ensure that the actual types are set as globals, so the ForwardRef class
|
||||
# can resolve them.
|
||||
globals()["TodoComp"] = TodoComp
|
||||
globals()["InnerComp"] = InnerComp
|
||||
|
||||
class TestComponent(TodoComp):
|
||||
def get_context_data(self, var1, inner):
|
||||
return {
|
||||
"self": self,
|
||||
"one": "2123",
|
||||
# NOTE: All of this is typed
|
||||
"inner": self.input.kwargs["inner"].render(kwargs={"one": "abc"}),
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
Name: <strong>{{ self.name }}</strong>
|
||||
"""
|
||||
|
||||
rendered = TestComponent.render(args=(Inner(),), kwargs={"inner": Inner()})
|
||||
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
Name: <strong>TestComponent</strong>
|
||||
""",
|
||||
)
|
||||
|
||||
def test_handles_typing_module(self):
|
||||
TodoArgs = Tuple[
|
||||
Union[str, int],
|
||||
Dict[str, int],
|
||||
List[str],
|
||||
Tuple[int, Union[str, int]],
|
||||
]
|
||||
|
||||
class TodoKwargs(TypedDict):
|
||||
one: Union[str, int]
|
||||
two: Dict[str, int]
|
||||
three: List[str]
|
||||
four: Tuple[int, Union[str, int]]
|
||||
|
||||
class TodoData(TypedDict):
|
||||
one: Union[str, int]
|
||||
two: Dict[str, int]
|
||||
three: List[str]
|
||||
four: Tuple[int, Union[str, int]]
|
||||
|
||||
TodoComp = Component[TodoArgs, TodoKwargs, TodoData, Any]
|
||||
|
||||
# NOTE: Since we're using ForwardRef for "TodoComp", we need
|
||||
# to ensure that the actual types are set as globals, so the ForwardRef class
|
||||
# can resolve them.
|
||||
globals()["TodoComp"] = TodoComp
|
||||
|
||||
class TestComponent(TodoComp):
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
return {
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
template = ""
|
||||
|
||||
TestComponent.render(
|
||||
args=("str", {"str": 123}, ["a", "b", "c"], (123, "123")),
|
||||
kwargs={
|
||||
"one": "str",
|
||||
"two": {"str": 123},
|
||||
"three": ["a", "b", "c"],
|
||||
"four": (123, "123"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ComponentRenderTest(BaseTestCase):
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from django.template import Context, Template
|
||||
|
||||
from django_components import Component, registry, types
|
||||
from django_components import Component, register, registry, types
|
||||
|
||||
from .django_test_setup import setup_test_config
|
||||
from .testutils import BaseTestCase, parametrize_context_behavior
|
||||
|
@ -591,20 +591,6 @@ class ContextVarsIsFilledTests(BaseTestCase):
|
|||
</div>
|
||||
"""
|
||||
|
||||
class ComponentWithNegatedConditionalSlot(Component):
|
||||
template: types.django_html = """
|
||||
{# Example from django-components/issues/98 #}
|
||||
{% load component_tags %}
|
||||
<div class="frontmatter-component">
|
||||
<div class="title">{% slot "title" %}Title{% endslot %}</div>
|
||||
{% if not component_vars.is_filled.subtitle %}
|
||||
<div class="warning">Subtitle not filled!</div>
|
||||
{% else %}
|
||||
<div class="subtitle">{% slot "alt_subtitle" %}Why would you want this?{% endslot %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
registry.register("is_filled_vars", self.IsFilledVarsComponent)
|
||||
|
@ -613,7 +599,6 @@ class ContextVarsIsFilledTests(BaseTestCase):
|
|||
"complex_conditional_slots",
|
||||
self.ComponentWithComplexConditionalSlots,
|
||||
)
|
||||
registry.register("negated_conditional_slot", self.ComponentWithNegatedConditionalSlot)
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_is_filled_vars(self):
|
||||
|
@ -679,7 +664,7 @@ class ContextVarsIsFilledTests(BaseTestCase):
|
|||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "conditional_slots" %}
|
||||
{% fill "subtitle" %} My subtitle {% endfill %}
|
||||
{% fill "subtitle" %} My subtitle {% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
expected = """
|
||||
|
@ -736,6 +721,21 @@ class ContextVarsIsFilledTests(BaseTestCase):
|
|||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_component_with_negated_conditional_slot(self):
|
||||
@register("negated_conditional_slot")
|
||||
class ComponentWithNegatedConditionalSlot(Component):
|
||||
template: types.django_html = """
|
||||
{# Example from django-components/issues/98 #}
|
||||
{% load component_tags %}
|
||||
<div class="frontmatter-component">
|
||||
<div class="title">{% slot "title" %}Title{% endslot %}</div>
|
||||
{% if not component_vars.is_filled.subtitle %}
|
||||
<div class="warning">Subtitle not filled!</div>
|
||||
{% else %}
|
||||
<div class="subtitle">{% slot "alt_subtitle" %}Why would you want this?{% endslot %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "negated_conditional_slot" %}
|
||||
|
@ -752,3 +752,36 @@ class ContextVarsIsFilledTests(BaseTestCase):
|
|||
"""
|
||||
rendered = Template(template).render(Context({}))
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_is_filled_vars_in_hooks(self):
|
||||
captured_before = None
|
||||
captured_after = None
|
||||
|
||||
@register("is_filled_vars")
|
||||
class IsFilledVarsComponent(self.IsFilledVarsComponent): # type: ignore[name-defined]
|
||||
def on_render_before(self, context: Context, template: Template) -> None:
|
||||
nonlocal captured_before
|
||||
captured_before = self.is_filled.copy()
|
||||
|
||||
def on_render_after(self, context: Context, template: Template, content: str) -> None:
|
||||
nonlocal captured_after
|
||||
captured_after = self.is_filled.copy()
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "is_filled_vars" %}
|
||||
bla bla
|
||||
{% endcomponent %}
|
||||
"""
|
||||
Template(template).render(Context())
|
||||
|
||||
expected = {
|
||||
"title": True,
|
||||
"my_title": False,
|
||||
"my_title_1": False,
|
||||
"my_title_2": False,
|
||||
"escape_this_________": False,
|
||||
}
|
||||
self.assertEqual(captured_before, expected)
|
||||
self.assertEqual(captured_after, expected)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue