mirror of
https://github.com/django-components/django-components.git
synced 2025-08-09 16:57:59 +00:00
feat: Typing for component inputs and access inputs during render (#585)
This commit is contained in:
parent
4dd3e3d5b3
commit
efd05d6150
6 changed files with 470 additions and 59 deletions
177
README.md
177
README.md
|
@ -49,6 +49,13 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
|
|||
|
||||
## Release notes
|
||||
|
||||
🚨📢 **Version 0.92**
|
||||
- BREAKING CHANGE: `Component` class is no longer a subclass of `View`. To configure the `View` class, set the `Component.View` nested class. HTTP methods like `get` or `post` can still be defined directly on `Component` class, and `Component.as_view()` internally calls `Component.View.as_view()`. (See [Modifying the View class](#modifying-the-view-class))
|
||||
|
||||
- The inputs (args, kwargs, slots, context, ...) that you pass to `Component.render()` can be accessed from within `get_context_data`, `get_template_string` and `get_template_name` via `self.input`. (See [Accessing data passed to the component](#accessing-data-passed-to-the-component))
|
||||
|
||||
- Typing: `Component` class supports generics that specify types for `Component.render` (See [Adding type hints with Generics](#adding-type-hints-with-generics))
|
||||
|
||||
**Version 0.90**
|
||||
- All tags (`component`, `slot`, `fill`, ...) now support "self-closing" or "inline" form, where you can omit the closing tag:
|
||||
```django
|
||||
|
@ -588,6 +595,52 @@ MyComponent.render_to_response(
|
|||
)
|
||||
```
|
||||
|
||||
### Adding type hints with Generics
|
||||
|
||||
The `Component` class optionally accepts type parameters
|
||||
that allow you to specify the types of args, kwargs, slots, and
|
||||
data.
|
||||
|
||||
```py
|
||||
from typing import NotRequired, Tuple, TypedDict
|
||||
|
||||
# Tuple
|
||||
Args = Tuple[int, str]
|
||||
|
||||
# Mapping
|
||||
class Kwargs(TypedDict):
|
||||
variable: str
|
||||
another: int
|
||||
maybe_var: NotRequired[int]
|
||||
|
||||
# Mapping
|
||||
class Data(TypedDict):
|
||||
variable: str
|
||||
|
||||
# Mapping
|
||||
class Slots(TypedDict):
|
||||
my_slot: NotRequired[str]
|
||||
|
||||
class Button(Component[Args, Kwargs, Data, Slots]):
|
||||
def get_context_data(self, variable, another):
|
||||
return {
|
||||
"variable": variable,
|
||||
}
|
||||
```
|
||||
|
||||
When you then call `Component.render` or `Component.render_to_response`, you will get type hints:
|
||||
|
||||
```py
|
||||
Button.render(
|
||||
# Error: First arg must be `int`, got `float`
|
||||
args=(1.25, "abc"),
|
||||
# Error: Key "another" is missing
|
||||
kwargs={
|
||||
"variable": "text",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### Response class of `render_to_response`
|
||||
|
||||
While `render` method returns a plain string, `render_to_response` wraps the rendered content in a "Response" class. By default, this is `django.http.HttpResponse`.
|
||||
|
@ -614,15 +667,22 @@ assert isinstance(response, MyResponse)
|
|||
|
||||
_New in version 0.34_
|
||||
|
||||
Components can now be used as views. To do this, `Component` subclasses Django's `View` class. This means that you can use all of the [methods](https://docs.djangoproject.com/en/5.0/ref/class-based-views/base/#view) of `View` in your component. For example, you can override `get` and `post` to handle GET and POST requests, respectively.
|
||||
_Note: Since 0.92, Component no longer subclasses View. To configure the View class, set the nested `Component.View` class_
|
||||
|
||||
In addition, `Component` now has a [`render_to_response`](#inputs-of-render-and-render_to_response) method that renders the component template based on the provided context and slots' data and returns an `HttpResponse` object.
|
||||
Components can now be used as views:
|
||||
- Components define the `Component.as_view()` class method that can be used the same as [`View.as_view()`](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View.as_view).
|
||||
|
||||
- By default, you can define GET, POST or other HTTP handlers directly on the Component, same as you do with [View](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#view). For example, you can override `get` and `post` to handle GET and POST requests, respectively.
|
||||
|
||||
- In addition, `Component` now has a [`render_to_response`](#inputs-of-render-and-render_to_response) method that renders the component template based on the provided context and slots' data and returns an `HttpResponse` object.
|
||||
|
||||
### Component as view example
|
||||
|
||||
Here's an example of a calendar component defined as a view:
|
||||
|
||||
```python
|
||||
# In a file called [project root]/components/calendar.py
|
||||
from django_components import Component, register
|
||||
from django_components import Component, ComponentView, register
|
||||
|
||||
@register("calendar")
|
||||
class Calendar(Component):
|
||||
|
@ -638,6 +698,7 @@ class Calendar(Component):
|
|||
</div>
|
||||
"""
|
||||
|
||||
# Handle GET requests
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = {
|
||||
"date": request.GET.get("date", "2020-06-06"),
|
||||
|
@ -645,7 +706,11 @@ class Calendar(Component):
|
|||
slots = {
|
||||
"header": "Calendar header",
|
||||
}
|
||||
return self.render_to_response(context=context, slots=slots)
|
||||
# Return HttpResponse with the rendered content
|
||||
return self.render_to_response(
|
||||
context=context,
|
||||
slots=slots,
|
||||
)
|
||||
```
|
||||
|
||||
Then, to use this component as a view, you should create a `urls.py` file in your components directory, and add a path to the component's view:
|
||||
|
@ -660,6 +725,9 @@ urlpatterns = [
|
|||
]
|
||||
```
|
||||
|
||||
`Component.as_view()` is a shorthand for calling [`View.as_view()`](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View.as_view) and passing the component
|
||||
instance as one of the arguments.
|
||||
|
||||
Remember to add `__init__.py` to your components directory, so that Django can find the `urls.py` file.
|
||||
|
||||
Finally, include the component's urls in your project's `urls.py` file:
|
||||
|
@ -673,10 +741,77 @@ urlpatterns = [
|
|||
]
|
||||
```
|
||||
|
||||
Note: slots content are automatically escaped by default to prevent XSS attacks. To disable escaping, set `escape_slots_content=False` in the `render_to_response` method. If you do so, you should make sure that any content you pass to the slots is safe, especially if it comes from user input.
|
||||
Note: Slots content are automatically escaped by default to prevent XSS attacks. To disable escaping, set `escape_slots_content=False` in the `render_to_response` method. If you do so, you should make sure that any content you pass to the slots is safe, especially if it comes from user input.
|
||||
|
||||
If you're planning on passing an HTML string, check Django's use of [`format_html`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.html.format_html) and [`mark_safe`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.safestring.mark_safe).
|
||||
|
||||
### Modifying the View class
|
||||
|
||||
The View class that handles the requests is defined on `Component.View`.
|
||||
|
||||
When you define a GET or POST handlers on the `Component` class, like so:
|
||||
|
||||
```py
|
||||
class MyComponent(Component):
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self.render_to_response(
|
||||
context={
|
||||
"date": request.GET.get("date", "2020-06-06"),
|
||||
},
|
||||
)
|
||||
|
||||
def post(self, request, *args, **kwargs) -> HttpResponse:
|
||||
variable = request.POST.get("variable")
|
||||
return self.render_to_response(
|
||||
kwargs={"variable": variable}
|
||||
)
|
||||
```
|
||||
|
||||
Then the request is still handled by `Component.View.get()` or `Component.View.post()`
|
||||
methods. However, by default, `Component.View.get()` points to `Component.get()`, and so on.
|
||||
|
||||
```py
|
||||
class ComponentView(View):
|
||||
component: Component = None
|
||||
...
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self.component.get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
return self.component.post(request, *args, **kwargs)
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
If you want to define your own `View` class, you need to:
|
||||
1. Set the class as `Component.View`
|
||||
2. Subclass from `ComponentView`, so the View instance has access to the component class.
|
||||
|
||||
In the example below, we added extra logic into `View.setup()`.
|
||||
|
||||
Note that the POST handler is still defined at the top. This is because `View` subclasses `ComponentView`, which defines the `post()` method that calls `Component.post()`.
|
||||
|
||||
If you were to overwrite the `View.post()` method, then `Component.post()` would be ignored.
|
||||
|
||||
```py
|
||||
from django_components import Component, ComponentView
|
||||
|
||||
class MyComponent(Component):
|
||||
|
||||
def post(self, request, *args, **kwargs) -> HttpResponse:
|
||||
variable = request.POST.get("variable")
|
||||
return self.component.render_to_response(
|
||||
kwargs={"variable": variable}
|
||||
)
|
||||
|
||||
class View(ComponentView):
|
||||
def setup(self, request, *args, **kwargs):
|
||||
super(request, *args, **kwargs)
|
||||
|
||||
do_something_extra(request, *args, **kwargs)
|
||||
```
|
||||
|
||||
## Registering components
|
||||
|
||||
In previous examples you could repeatedly see us using `@register()` to "register"
|
||||
|
@ -1389,6 +1524,38 @@ Sweet! Now all the relevant HTML is inside the template, and we can move it to a
|
|||
> {"attrs": {"my_key:two": 2}}
|
||||
> ```
|
||||
|
||||
### Accessing data passed to the component
|
||||
|
||||
When you call `Component.render` or `Component.render_to_response`, the inputs to these methods can be accessed from within the instance under `self.input`.
|
||||
|
||||
This means that you can use `self.input` inside:
|
||||
- `get_context_data`
|
||||
- `get_template_name`
|
||||
- `get_template_string`
|
||||
|
||||
`self.input` is defined only for the duration of `Component.render`, and returns `None` when called outside of this.
|
||||
|
||||
`self.input` has the same fields as the input to `Component.render`:
|
||||
|
||||
```py
|
||||
class TestComponent(Component):
|
||||
def get_context_data(self, var1, var2, variable, another, **attrs):
|
||||
assert self.input.args == (123, "str")
|
||||
assert self.input.kwargs == {"variable": "test", "another": 1}
|
||||
assert self.input.slots == {"my_slot": "MY_SLOT"}
|
||||
assert isinstance(self.input.context, Context)
|
||||
|
||||
return {
|
||||
"variable": variable,
|
||||
}
|
||||
|
||||
rendered = TestComponent.render(
|
||||
kwargs={"variable": "test", "another": 1},
|
||||
args=(123, "str"),
|
||||
slots={"my_slot": "MY_SLOT"},
|
||||
)
|
||||
```
|
||||
|
||||
## Rendering HTML attributes
|
||||
|
||||
_New in version 0.74_:
|
||||
|
|
|
@ -7,7 +7,10 @@ from django_components.autodiscover import (
|
|||
autodiscover as autodiscover,
|
||||
import_libraries as import_libraries,
|
||||
)
|
||||
from django_components.component import Component as Component
|
||||
from django_components.component import (
|
||||
Component as Component,
|
||||
ComponentView as ComponentView,
|
||||
)
|
||||
from django_components.component_registry import (
|
||||
AlreadyRegistered as AlreadyRegistered,
|
||||
ComponentRegistry as ComponentRegistry,
|
||||
|
|
|
@ -1,10 +1,28 @@
|
|||
import inspect
|
||||
import types
|
||||
from typing import Any, ClassVar, Dict, List, Mapping, Optional, Tuple, Type, Union
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Deque,
|
||||
Dict,
|
||||
Generic,
|
||||
List,
|
||||
Mapping,
|
||||
Optional,
|
||||
Protocol,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.forms.widgets import Media
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.template.base import FilterExpression, Node, NodeList, Template, TextNode
|
||||
from django.template.context import Context
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
|
@ -53,6 +71,25 @@ from django_components.component_registry import registry as registry # NOQA
|
|||
|
||||
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
|
||||
|
||||
# Define TypeVars for args and kwargs
|
||||
ArgsType = TypeVar("ArgsType", bound=tuple, contravariant=True)
|
||||
KwargsType = TypeVar("KwargsType", bound=Mapping[str, Any], contravariant=True)
|
||||
DataType = TypeVar("DataType", bound=Mapping[str, Any], covariant=True)
|
||||
SlotsType = TypeVar("SlotsType", bound=Mapping[SlotName, SlotContent])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
|
||||
context: Context
|
||||
args: ArgsType
|
||||
kwargs: KwargsType
|
||||
slots: SlotsType
|
||||
escape_slots_content: bool
|
||||
|
||||
|
||||
class ViewFn(Protocol):
|
||||
def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704
|
||||
|
||||
|
||||
class ComponentMeta(MediaMeta):
|
||||
def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type:
|
||||
|
@ -64,7 +101,42 @@ class ComponentMeta(MediaMeta):
|
|||
return super().__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class Component(View, metaclass=ComponentMeta):
|
||||
# NOTE: We use metaclass to automatically define the HTTP methods as defined
|
||||
# in `View.http_method_names`.
|
||||
class ComponentViewMeta(type):
|
||||
def __new__(cls, name: str, bases: Any, dct: Dict) -> Any:
|
||||
# Default implementation shared by all HTTP methods
|
||||
def create_handler(method: str) -> Callable:
|
||||
def handler(self, request: HttpRequest, *args: Any, **kwargs: Any): # type: ignore[no-untyped-def]
|
||||
component: "Component" = self.component
|
||||
return getattr(component, method)(request, *args, **kwargs)
|
||||
|
||||
return handler
|
||||
|
||||
# Add methods to the class
|
||||
for method_name in View.http_method_names:
|
||||
if method_name not in dct:
|
||||
dct[method_name] = create_handler(method_name)
|
||||
|
||||
return super().__new__(cls, name, bases, dct)
|
||||
|
||||
|
||||
class ComponentView(View, metaclass=ComponentViewMeta):
|
||||
"""
|
||||
Subclass of `django.views.View` where the `Component` instance is available
|
||||
via `self.component`.
|
||||
"""
|
||||
|
||||
# NOTE: This attribute must be declared on the class for `View.as_view` to allow
|
||||
# us to pass `component` kwarg.
|
||||
component = cast("Component", None)
|
||||
|
||||
def __init__(self, component: "Component", **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.component = component
|
||||
|
||||
|
||||
class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=ComponentMeta):
|
||||
# Either template_name or template must be set on subclass OR subclass must implement get_template() with
|
||||
# non-null return.
|
||||
_class_hash: ClassVar[int]
|
||||
|
@ -90,6 +162,8 @@ class Component(View, metaclass=ComponentMeta):
|
|||
Media = ComponentMediaInput
|
||||
"""Defines JS and CSS media files associated with this component."""
|
||||
|
||||
View = ComponentView
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registered_name: Optional[str] = None,
|
||||
|
@ -97,12 +171,6 @@ class Component(View, metaclass=ComponentMeta):
|
|||
outer_context: Optional[Context] = None,
|
||||
fill_content: Optional[Dict[str, FillContent]] = None,
|
||||
):
|
||||
self.registered_name: Optional[str] = registered_name
|
||||
self.outer_context: Context = outer_context or Context()
|
||||
self.fill_content = fill_content or {}
|
||||
self.component_id = component_id or gen_id()
|
||||
self._context: Optional[Context] = None
|
||||
|
||||
# When user first instantiates the component class before calling
|
||||
# `render` or `render_to_response`, then we want to allow the render
|
||||
# function to make use of the instantiated object.
|
||||
|
@ -117,6 +185,12 @@ class Component(View, metaclass=ComponentMeta):
|
|||
self.render_to_response = types.MethodType(self.__class__.render_to_response.__func__, self) # type: ignore
|
||||
self.render = types.MethodType(self.__class__.render.__func__, self) # type: ignore
|
||||
|
||||
self.registered_name: Optional[str] = registered_name
|
||||
self.outer_context: Context = outer_context or Context()
|
||||
self.fill_content = fill_content or {}
|
||||
self.component_id = component_id or gen_id()
|
||||
self._render_stack: Deque[RenderInput[ArgsType, KwargsType, SlotsType]] = deque()
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
cls._class_hash = hash(inspect.getfile(cls) + cls.__name__)
|
||||
|
||||
|
@ -124,8 +198,18 @@ class Component(View, metaclass=ComponentMeta):
|
|||
def name(self) -> str:
|
||||
return self.registered_name or self.__class__.__name__
|
||||
|
||||
def get_context_data(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
|
||||
return {}
|
||||
@property
|
||||
def input(self) -> Optional[RenderInput[ArgsType, KwargsType, SlotsType]]:
|
||||
"""
|
||||
Input holds the data (like arg, kwargs, slots) that were passsed to
|
||||
the current execution of the `render` method.
|
||||
"""
|
||||
# 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] if len(self._render_stack) else None
|
||||
|
||||
def get_context_data(self, *args: Any, **kwargs: Any) -> DataType:
|
||||
return cast(DataType, {})
|
||||
|
||||
def get_template_name(self, context: Context) -> Optional[str]:
|
||||
return self.template_name
|
||||
|
@ -220,21 +304,30 @@ class Component(View, metaclass=ComponentMeta):
|
|||
|
||||
As the `{{ data.hello }}` is taken from the "provider".
|
||||
"""
|
||||
if self._context is None:
|
||||
if self.input is None:
|
||||
raise RuntimeError(
|
||||
f"Method 'inject()' of component '{self.name}' was called outside of 'get_context_data()'"
|
||||
)
|
||||
|
||||
return get_injected_context_var(self.name, self._context, key, default)
|
||||
return get_injected_context_var(self.name, self.input.context, key, default)
|
||||
|
||||
@classmethod
|
||||
def as_view(cls, **initkwargs: Any) -> ViewFn:
|
||||
"""
|
||||
Shortcut for calling `Component.View.as_view` and passing component instance to it.
|
||||
"""
|
||||
# Allow the View class to access this component via `self.component`
|
||||
component = cls()
|
||||
return component.View.as_view(**initkwargs, component=component)
|
||||
|
||||
@classmethod
|
||||
def render_to_response(
|
||||
cls,
|
||||
context: Union[Dict[str, Any], Context] = None,
|
||||
slots: Optional[Mapping[SlotName, SlotContent]] = None,
|
||||
context: Optional[Union[Dict[str, Any], Context]] = None,
|
||||
slots: Optional[SlotsType] = None,
|
||||
escape_slots_content: bool = True,
|
||||
args: Optional[Union[List, Tuple]] = None,
|
||||
kwargs: Optional[Dict[str, Any]] = None,
|
||||
args: Optional[ArgsType] = None,
|
||||
kwargs: Optional[KwargsType] = None,
|
||||
*response_args: Any,
|
||||
**response_kwargs: Any,
|
||||
) -> HttpResponse:
|
||||
|
@ -294,9 +387,9 @@ class Component(View, metaclass=ComponentMeta):
|
|||
def render(
|
||||
cls,
|
||||
context: Optional[Union[Dict[str, Any], Context]] = None,
|
||||
args: Optional[Union[List, Tuple]] = None,
|
||||
kwargs: Optional[Dict[str, Any]] = None,
|
||||
slots: Optional[Mapping[SlotName, SlotContent]] = None,
|
||||
args: Optional[ArgsType] = None,
|
||||
kwargs: Optional[KwargsType] = None,
|
||||
slots: Optional[SlotsType] = None,
|
||||
escape_slots_content: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
|
@ -343,10 +436,10 @@ class Component(View, metaclass=ComponentMeta):
|
|||
# This is the internal entrypoint for the render function
|
||||
def _render(
|
||||
self,
|
||||
context: Union[Dict[str, Any], Context] = None,
|
||||
args: Optional[Union[List, Tuple]] = None,
|
||||
kwargs: Optional[Dict[str, Any]] = None,
|
||||
slots: Optional[Mapping[SlotName, SlotContent]] = None,
|
||||
context: Optional[Union[Dict[str, Any], Context]] = None,
|
||||
args: Optional[ArgsType] = None,
|
||||
kwargs: Optional[KwargsType] = None,
|
||||
slots: Optional[SlotsType] = None,
|
||||
escape_slots_content: bool = True,
|
||||
) -> str:
|
||||
try:
|
||||
|
@ -356,16 +449,18 @@ class Component(View, metaclass=ComponentMeta):
|
|||
|
||||
def _render_impl(
|
||||
self,
|
||||
context: Union[Dict[str, Any], Context] = None,
|
||||
args: Optional[Union[List, Tuple]] = None,
|
||||
kwargs: Optional[Dict[str, Any]] = None,
|
||||
slots: Optional[Mapping[SlotName, SlotContent]] = None,
|
||||
context: Optional[Union[Dict[str, Any], Context]] = None,
|
||||
args: Optional[ArgsType] = None,
|
||||
kwargs: Optional[KwargsType] = None,
|
||||
slots: Optional[SlotsType] = None,
|
||||
escape_slots_content: bool = True,
|
||||
) -> str:
|
||||
has_slots = slots is not None
|
||||
|
||||
# Allow to provide no args/kwargs/slots/context
|
||||
args = args or [] # type: ignore[assignment]
|
||||
kwargs = kwargs or {} # type: ignore[assignment]
|
||||
slots = slots or {} # type: ignore[assignment]
|
||||
args = cast(ArgsType, args or ())
|
||||
kwargs = cast(KwargsType, kwargs or {})
|
||||
slots = cast(SlotsType, slots or {})
|
||||
context = context or Context()
|
||||
|
||||
# Allow to provide a dict instead of Context
|
||||
|
@ -374,11 +469,20 @@ class Component(View, metaclass=ComponentMeta):
|
|||
context = context if isinstance(context, Context) else Context(context)
|
||||
prepare_context(context, self.component_id)
|
||||
|
||||
# Temporarily populate _context so user can call `self.inject()` from
|
||||
# within `get_context_data()`
|
||||
self._context = context
|
||||
# By adding the current input to the stack, we temporarily allow users
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
|
||||
context_data = self.get_context_data(*args, **kwargs)
|
||||
self._context = None
|
||||
|
||||
with context.update(context_data):
|
||||
template = self.get_template(context)
|
||||
|
@ -397,8 +501,11 @@ class Component(View, metaclass=ComponentMeta):
|
|||
template._dc_is_component_nested = bool(context.render_context.get(BLOCK_CONTEXT_KEY))
|
||||
|
||||
# Support passing slots explicitly to `render` method
|
||||
if slots:
|
||||
fill_content = self._fills_from_slots_data(slots, escape_slots_content)
|
||||
if has_slots:
|
||||
fill_content = self._fills_from_slots_data(
|
||||
slots,
|
||||
escape_slots_content,
|
||||
)
|
||||
else:
|
||||
fill_content = self.fill_content
|
||||
|
||||
|
@ -407,7 +514,7 @@ class Component(View, metaclass=ComponentMeta):
|
|||
if not context[_PARENT_COMP_CONTEXT_KEY]:
|
||||
slot_context_data = self.outer_context.flatten()
|
||||
|
||||
slots, resolved_fills = resolve_slots(
|
||||
_, resolved_fills = resolve_slots(
|
||||
context,
|
||||
template,
|
||||
component_name=self.name,
|
||||
|
@ -444,6 +551,10 @@ class Component(View, metaclass=ComponentMeta):
|
|||
else:
|
||||
output = rendered_component
|
||||
|
||||
# After rendering is done, remove the current state from the stack, which means
|
||||
# properties like `self.context` will no longer return the current state.
|
||||
self._render_stack.pop()
|
||||
|
||||
return output
|
||||
|
||||
def _fills_from_slots_data(
|
||||
|
|
|
@ -2,7 +2,7 @@ import difflib
|
|||
import json
|
||||
import re
|
||||
from collections import deque
|
||||
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, Type, Union
|
||||
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Set, Tuple, Type, Union
|
||||
|
||||
from django.template import Context, Template
|
||||
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode
|
||||
|
@ -86,7 +86,7 @@ class SlotFill(NamedTuple):
|
|||
escaped_name: str
|
||||
is_filled: bool
|
||||
content_func: SlotRenderFunc
|
||||
context_data: Dict
|
||||
context_data: Mapping
|
||||
slot_default_var: Optional[SlotDefaultName]
|
||||
slot_data_var: Optional[SlotDataName]
|
||||
|
||||
|
@ -372,7 +372,7 @@ def resolve_slots(
|
|||
context: Context,
|
||||
template: Template,
|
||||
component_name: Optional[str],
|
||||
context_data: Dict[str, Any],
|
||||
context_data: Mapping[str, Any],
|
||||
fill_content: Dict[SlotName, FillContent],
|
||||
) -> Tuple[Dict[SlotId, Slot], Dict[SlotId, SlotFill]]:
|
||||
"""
|
||||
|
|
|
@ -3,7 +3,7 @@ Tests focusing on the Component class.
|
|||
For tests focusing on the `component` tag, see `test_templatetags_component.py`
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
from typing import Dict, Tuple, TypedDict, no_type_check
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
@ -186,6 +186,88 @@ class ComponentTest(BaseTestCase):
|
|||
""",
|
||||
)
|
||||
|
||||
def test_typed(self):
|
||||
TestCompArgs = Tuple[int, str]
|
||||
|
||||
class TestCompKwargs(TypedDict):
|
||||
variable: str
|
||||
another: int
|
||||
|
||||
class TestCompData(TypedDict):
|
||||
abc: int
|
||||
|
||||
class TestCompSlots(TypedDict):
|
||||
my_slot: str
|
||||
|
||||
class TestComponent(Component[TestCompArgs, TestCompKwargs, TestCompData, TestCompSlots]):
|
||||
def get_context_data(self, var1, var2, variable, another, **attrs):
|
||||
return {
|
||||
"variable": variable,
|
||||
}
|
||||
|
||||
def get_template(self, context):
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
Variable: <strong>{{ variable }}</strong>
|
||||
{% slot 'my_slot' / %}
|
||||
"""
|
||||
return Template(template_str)
|
||||
|
||||
rendered = TestComponent.render(
|
||||
kwargs={"variable": "test", "another": 1},
|
||||
args=(123, "str"),
|
||||
slots={"my_slot": "MY_SLOT"},
|
||||
)
|
||||
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
Variable: <strong>test</strong> MY_SLOT
|
||||
""",
|
||||
)
|
||||
|
||||
def test_input(self):
|
||||
tester = self
|
||||
|
||||
class TestComponent(Component):
|
||||
@no_type_check
|
||||
def get_context_data(self, var1, var2, variable, another, **attrs):
|
||||
tester.assertEqual(self.input.args, (123, "str"))
|
||||
tester.assertEqual(self.input.kwargs, {"variable": "test", "another": 1})
|
||||
tester.assertIsInstance(self.input.context, Context)
|
||||
tester.assertEqual(self.input.slots, {"my_slot": "MY_SLOT"})
|
||||
|
||||
return {
|
||||
"variable": variable,
|
||||
}
|
||||
|
||||
@no_type_check
|
||||
def get_template(self, context):
|
||||
tester.assertEqual(self.input.args, (123, "str"))
|
||||
tester.assertEqual(self.input.kwargs, {"variable": "test", "another": 1})
|
||||
tester.assertIsInstance(self.input.context, Context)
|
||||
tester.assertEqual(self.input.slots, {"my_slot": "MY_SLOT"})
|
||||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
Variable: <strong>{{ variable }}</strong>
|
||||
{% slot 'my_slot' / %}
|
||||
"""
|
||||
return Template(template_str)
|
||||
|
||||
rendered = TestComponent.render(
|
||||
kwargs={"variable": "test", "another": 1},
|
||||
args=(123, "str"),
|
||||
slots={"my_slot": "MY_SLOT"},
|
||||
)
|
||||
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
Variable: <strong>test</strong> MY_SLOT
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
class ComponentRenderTest(BaseTestCase):
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
|
|
|
@ -6,12 +6,12 @@ from django.template import Context, Template
|
|||
from django.test import Client
|
||||
from django.urls import path
|
||||
|
||||
from django_components import Component, register
|
||||
from django_components import Component, ComponentView, register, types
|
||||
|
||||
from .django_test_setup import setup_test_config
|
||||
from .testutils import BaseTestCase, parametrize_context_behavior
|
||||
|
||||
setup_test_config()
|
||||
setup_test_config({"autodiscover": False})
|
||||
|
||||
|
||||
class CustomClient(Client):
|
||||
|
@ -40,9 +40,6 @@ class TestComponentAsView(BaseTestCase):
|
|||
</form>
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs) -> HttpResponse:
|
||||
return self.render_to_response({"variable": "GET"})
|
||||
|
||||
def get_context_data(self, variable, *args, **kwargs) -> Dict[str, Any]:
|
||||
return {"variable": variable}
|
||||
|
||||
|
@ -73,12 +70,37 @@ class TestComponentAsView(BaseTestCase):
|
|||
</form>
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs) -> HttpResponse:
|
||||
return self.render_to_response(kwargs={"variable": "GET"})
|
||||
def get_context_data(self, variable):
|
||||
return {"inner_var": variable}
|
||||
|
||||
class View(ComponentView):
|
||||
def get(self, request, *args, **kwargs) -> HttpResponse:
|
||||
return self.component.render_to_response(kwargs={"variable": "GET"})
|
||||
|
||||
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
|
||||
response = client.get("/test/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
b'<input type="text" name="variable" value="GET">',
|
||||
response.content,
|
||||
)
|
||||
|
||||
def test_get_request_shortcut(self):
|
||||
class MockComponentRequest(Component):
|
||||
template = """
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="text" name="variable" value="{{ inner_var }}">
|
||||
<input type="submit">
|
||||
</form>
|
||||
"""
|
||||
|
||||
def get_context_data(self, variable):
|
||||
return {"inner_var": variable}
|
||||
|
||||
def get(self, request, *args, **kwargs) -> HttpResponse:
|
||||
return self.render_to_response(kwargs={"variable": "GET"})
|
||||
|
||||
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
|
||||
response = client.get("/test/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
@ -89,7 +111,7 @@ class TestComponentAsView(BaseTestCase):
|
|||
|
||||
def test_post_request(self):
|
||||
class MockComponentRequest(Component):
|
||||
template = """
|
||||
template: types.django_html = """
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="text" name="variable" value="{{ inner_var }}">
|
||||
|
@ -97,13 +119,39 @@ class TestComponentAsView(BaseTestCase):
|
|||
</form>
|
||||
"""
|
||||
|
||||
def post(self, request, *args, **kwargs) -> HttpResponse:
|
||||
variable = request.POST.get("variable")
|
||||
return self.render_to_response(kwargs={"variable": variable})
|
||||
def get_context_data(self, variable):
|
||||
return {"inner_var": variable}
|
||||
|
||||
class View(ComponentView):
|
||||
def post(self, request, *args, **kwargs) -> HttpResponse:
|
||||
variable = request.POST.get("variable")
|
||||
return self.component.render_to_response(kwargs={"variable": variable})
|
||||
|
||||
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
|
||||
response = client.post("/test/", {"variable": "POST"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
b'<input type="text" name="variable" value="POST">',
|
||||
response.content,
|
||||
)
|
||||
|
||||
def test_post_request_shortcut(self):
|
||||
class MockComponentRequest(Component):
|
||||
template: types.django_html = """
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="text" name="variable" value="{{ inner_var }}">
|
||||
<input type="submit">
|
||||
</form>
|
||||
"""
|
||||
|
||||
def get_context_data(self, variable):
|
||||
return {"inner_var": variable}
|
||||
|
||||
def post(self, request, *args, **kwargs) -> HttpResponse:
|
||||
variable = request.POST.get("variable")
|
||||
return self.render_to_response(kwargs={"variable": variable})
|
||||
|
||||
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
|
||||
response = client.post("/test/", {"variable": "POST"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue