diff --git a/README.md b/README.md index fdec02a7..f6a6df3f 100644 --- a/README.md +++ b/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): """ + # 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_: diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index e93c5ada..162a7035 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -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, diff --git a/src/django_components/component.py b/src/django_components/component.py index 3a5d1951..4ac52433 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -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 = "" +# 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( diff --git a/src/django_components/slots.py b/src/django_components/slots.py index 9eed633b..364d8536 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -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]]: """ diff --git a/tests/test_component.py b/tests/test_component.py index 25f44412..15617d41 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -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: {{ variable }} + {% 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: test 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: {{ variable }} + {% 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: test MY_SLOT + """, + ) + class ComponentRenderTest(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) diff --git a/tests/test_component_as_view.py b/tests/test_component_as_view.py index 9c26938d..eea2ca94 100644 --- a/tests/test_component_as_view.py +++ b/tests/test_component_as_view.py @@ -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): """ - 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): """ - 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'', + response.content, + ) + + def test_get_request_shortcut(self): + class MockComponentRequest(Component): + template = """ +
+ """ 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 = """ """ - 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'', + response.content, + ) + + def test_post_request_shortcut(self): + class MockComponentRequest(Component): + template: types.django_html = """ + + """ 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)