feat: Typing for component inputs and access inputs during render (#585)

This commit is contained in:
Juro Oravec 2024-08-22 23:42:34 +02:00 committed by GitHub
parent 4dd3e3d5b3
commit efd05d6150
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 470 additions and 59 deletions

177
README.md
View file

@ -49,6 +49,13 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
## Release notes ## 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** **Version 0.90**
- All tags (`component`, `slot`, `fill`, ...) now support "self-closing" or "inline" form, where you can omit the closing tag: - All tags (`component`, `slot`, `fill`, ...) now support "self-closing" or "inline" form, where you can omit the closing tag:
```django ```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` ### 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`. 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_ _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: Here's an example of a calendar component defined as a view:
```python ```python
# In a file called [project root]/components/calendar.py # In a file called [project root]/components/calendar.py
from django_components import Component, register from django_components import Component, ComponentView, register
@register("calendar") @register("calendar")
class Calendar(Component): class Calendar(Component):
@ -638,6 +698,7 @@ class Calendar(Component):
</div> </div>
""" """
# Handle GET requests
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
context = { context = {
"date": request.GET.get("date", "2020-06-06"), "date": request.GET.get("date", "2020-06-06"),
@ -645,7 +706,11 @@ class Calendar(Component):
slots = { slots = {
"header": "Calendar header", "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: 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. 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: 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). 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 ## Registering components
In previous examples you could repeatedly see us using `@register()` to "register" 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}} > {"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 ## Rendering HTML attributes
_New in version 0.74_: _New in version 0.74_:

View file

@ -7,7 +7,10 @@ from django_components.autodiscover import (
autodiscover as autodiscover, autodiscover as autodiscover,
import_libraries as import_libraries, 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 ( from django_components.component_registry import (
AlreadyRegistered as AlreadyRegistered, AlreadyRegistered as AlreadyRegistered,
ComponentRegistry as ComponentRegistry, ComponentRegistry as ComponentRegistry,

View file

@ -1,10 +1,28 @@
import inspect import inspect
import types 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.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media 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.base import FilterExpression, Node, NodeList, Template, TextNode
from django.template.context import Context from django.template.context import Context
from django.template.exceptions import TemplateSyntaxError 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} -->" 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): class ComponentMeta(MediaMeta):
def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type: 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) 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 # Either template_name or template must be set on subclass OR subclass must implement get_template() with
# non-null return. # non-null return.
_class_hash: ClassVar[int] _class_hash: ClassVar[int]
@ -90,6 +162,8 @@ class Component(View, metaclass=ComponentMeta):
Media = ComponentMediaInput Media = ComponentMediaInput
"""Defines JS and CSS media files associated with this component.""" """Defines JS and CSS media files associated with this component."""
View = ComponentView
def __init__( def __init__(
self, self,
registered_name: Optional[str] = None, registered_name: Optional[str] = None,
@ -97,12 +171,6 @@ class Component(View, metaclass=ComponentMeta):
outer_context: Optional[Context] = None, outer_context: Optional[Context] = None,
fill_content: Optional[Dict[str, FillContent]] = 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 # When user first instantiates the component class before calling
# `render` or `render_to_response`, then we want to allow the render # `render` or `render_to_response`, then we want to allow the render
# function to make use of the instantiated object. # 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_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.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: def __init_subclass__(cls, **kwargs: Any) -> None:
cls._class_hash = hash(inspect.getfile(cls) + cls.__name__) cls._class_hash = hash(inspect.getfile(cls) + cls.__name__)
@ -124,8 +198,18 @@ class Component(View, metaclass=ComponentMeta):
def name(self) -> str: def name(self) -> str:
return self.registered_name or self.__class__.__name__ return self.registered_name or self.__class__.__name__
def get_context_data(self, *args: Any, **kwargs: Any) -> Dict[str, Any]: @property
return {} 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]: def get_template_name(self, context: Context) -> Optional[str]:
return self.template_name return self.template_name
@ -220,21 +304,30 @@ class Component(View, metaclass=ComponentMeta):
As the `{{ data.hello }}` is taken from the "provider". As the `{{ data.hello }}` is taken from the "provider".
""" """
if self._context is None: if self.input is None:
raise RuntimeError( raise RuntimeError(
f"Method 'inject()' of component '{self.name}' was called outside of 'get_context_data()'" 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 @classmethod
def render_to_response( def render_to_response(
cls, cls,
context: Union[Dict[str, Any], Context] = None, context: Optional[Union[Dict[str, Any], Context]] = None,
slots: Optional[Mapping[SlotName, SlotContent]] = None, slots: Optional[SlotsType] = None,
escape_slots_content: bool = True, escape_slots_content: bool = True,
args: Optional[Union[List, Tuple]] = None, args: Optional[ArgsType] = None,
kwargs: Optional[Dict[str, Any]] = None, kwargs: Optional[KwargsType] = None,
*response_args: Any, *response_args: Any,
**response_kwargs: Any, **response_kwargs: Any,
) -> HttpResponse: ) -> HttpResponse:
@ -294,9 +387,9 @@ class Component(View, metaclass=ComponentMeta):
def render( def render(
cls, cls,
context: Optional[Union[Dict[str, Any], Context]] = None, context: Optional[Union[Dict[str, Any], Context]] = None,
args: Optional[Union[List, Tuple]] = None, args: Optional[ArgsType] = None,
kwargs: Optional[Dict[str, Any]] = None, kwargs: Optional[KwargsType] = None,
slots: Optional[Mapping[SlotName, SlotContent]] = None, slots: Optional[SlotsType] = None,
escape_slots_content: bool = True, escape_slots_content: bool = True,
) -> str: ) -> str:
""" """
@ -343,10 +436,10 @@ class Component(View, metaclass=ComponentMeta):
# This is the internal entrypoint for the render function # This is the internal entrypoint for the render function
def _render( def _render(
self, self,
context: Union[Dict[str, Any], Context] = None, context: Optional[Union[Dict[str, Any], Context]] = None,
args: Optional[Union[List, Tuple]] = None, args: Optional[ArgsType] = None,
kwargs: Optional[Dict[str, Any]] = None, kwargs: Optional[KwargsType] = None,
slots: Optional[Mapping[SlotName, SlotContent]] = None, slots: Optional[SlotsType] = None,
escape_slots_content: bool = True, escape_slots_content: bool = True,
) -> str: ) -> str:
try: try:
@ -356,16 +449,18 @@ class Component(View, metaclass=ComponentMeta):
def _render_impl( def _render_impl(
self, self,
context: Union[Dict[str, Any], Context] = None, context: Optional[Union[Dict[str, Any], Context]] = None,
args: Optional[Union[List, Tuple]] = None, args: Optional[ArgsType] = None,
kwargs: Optional[Dict[str, Any]] = None, kwargs: Optional[KwargsType] = None,
slots: Optional[Mapping[SlotName, SlotContent]] = None, slots: Optional[SlotsType] = None,
escape_slots_content: bool = True, escape_slots_content: bool = True,
) -> str: ) -> str:
has_slots = slots is not None
# Allow to provide no args/kwargs/slots/context # Allow to provide no args/kwargs/slots/context
args = args or [] # type: ignore[assignment] args = cast(ArgsType, args or ())
kwargs = kwargs or {} # type: ignore[assignment] kwargs = cast(KwargsType, kwargs or {})
slots = slots or {} # type: ignore[assignment] slots = cast(SlotsType, slots or {})
context = context or Context() context = context or Context()
# Allow to provide a dict instead of 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) context = context if isinstance(context, Context) else Context(context)
prepare_context(context, self.component_id) prepare_context(context, self.component_id)
# Temporarily populate _context so user can call `self.inject()` from # By adding the current input to the stack, we temporarily allow users
# within `get_context_data()` # to access the provided context, slots, etc. Also required so users can
self._context = context # 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) context_data = self.get_context_data(*args, **kwargs)
self._context = None
with context.update(context_data): with context.update(context_data):
template = self.get_template(context) 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)) template._dc_is_component_nested = bool(context.render_context.get(BLOCK_CONTEXT_KEY))
# Support passing slots explicitly to `render` method # Support passing slots explicitly to `render` method
if slots: if has_slots:
fill_content = self._fills_from_slots_data(slots, escape_slots_content) fill_content = self._fills_from_slots_data(
slots,
escape_slots_content,
)
else: else:
fill_content = self.fill_content fill_content = self.fill_content
@ -407,7 +514,7 @@ class Component(View, metaclass=ComponentMeta):
if not context[_PARENT_COMP_CONTEXT_KEY]: if not context[_PARENT_COMP_CONTEXT_KEY]:
slot_context_data = self.outer_context.flatten() slot_context_data = self.outer_context.flatten()
slots, resolved_fills = resolve_slots( _, resolved_fills = resolve_slots(
context, context,
template, template,
component_name=self.name, component_name=self.name,
@ -444,6 +551,10 @@ class Component(View, metaclass=ComponentMeta):
else: else:
output = rendered_component 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 return output
def _fills_from_slots_data( def _fills_from_slots_data(

View file

@ -2,7 +2,7 @@ import difflib
import json import json
import re import re
from collections import deque 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 import Context, Template
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode
@ -86,7 +86,7 @@ class SlotFill(NamedTuple):
escaped_name: str escaped_name: str
is_filled: bool is_filled: bool
content_func: SlotRenderFunc content_func: SlotRenderFunc
context_data: Dict context_data: Mapping
slot_default_var: Optional[SlotDefaultName] slot_default_var: Optional[SlotDefaultName]
slot_data_var: Optional[SlotDataName] slot_data_var: Optional[SlotDataName]
@ -372,7 +372,7 @@ def resolve_slots(
context: Context, context: Context,
template: Template, template: Template,
component_name: Optional[str], component_name: Optional[str],
context_data: Dict[str, Any], context_data: Mapping[str, Any],
fill_content: Dict[SlotName, FillContent], fill_content: Dict[SlotName, FillContent],
) -> Tuple[Dict[SlotId, Slot], Dict[SlotId, SlotFill]]: ) -> Tuple[Dict[SlotId, Slot], Dict[SlotId, SlotFill]]:
""" """

View file

@ -3,7 +3,7 @@ Tests focusing on the Component class.
For tests focusing on the `component` tag, see `test_templatetags_component.py` 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.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse 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): class ComponentRenderTest(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])

View file

@ -6,12 +6,12 @@ from django.template import Context, Template
from django.test import Client from django.test import Client
from django.urls import path 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 .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config() setup_test_config({"autodiscover": False})
class CustomClient(Client): class CustomClient(Client):
@ -40,9 +40,6 @@ class TestComponentAsView(BaseTestCase):
</form> </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]: def get_context_data(self, variable, *args, **kwargs) -> Dict[str, Any]:
return {"variable": variable} return {"variable": variable}
@ -73,12 +70,37 @@ class TestComponentAsView(BaseTestCase):
</form> </form>
""" """
def get(self, request, *args, **kwargs) -> HttpResponse: def get_context_data(self, variable):
return self.render_to_response(kwargs={"variable": "GET"}) 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): def get_context_data(self, variable):
return {"inner_var": 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())]) client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
response = client.get("/test/") response = client.get("/test/")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -89,7 +111,7 @@ class TestComponentAsView(BaseTestCase):
def test_post_request(self): def test_post_request(self):
class MockComponentRequest(Component): class MockComponentRequest(Component):
template = """ template: types.django_html = """
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<input type="text" name="variable" value="{{ inner_var }}"> <input type="text" name="variable" value="{{ inner_var }}">
@ -97,13 +119,39 @@ class TestComponentAsView(BaseTestCase):
</form> </form>
""" """
def post(self, request, *args, **kwargs) -> HttpResponse: def get_context_data(self, variable):
variable = request.POST.get("variable") return {"inner_var": variable}
return self.render_to_response(kwargs={"variable": 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): def get_context_data(self, variable):
return {"inner_var": 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())]) client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
response = client.post("/test/", {"variable": "POST"}) response = client.post("/test/", {"variable": "POST"})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)