diff --git a/CHANGELOG.md b/CHANGELOG.md index 5596ef12..b366d3ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Release notes +## v0.142.3 + +#### Refactor + +- `Component.View.public = True` is now optional. + + Before, to create component endpoints, you had to set both: + + 1. HTTP handlers on `Component.View` + 2. `Component.View.public = True`. + + Now, you can set only the HTTP handlers, and the component will be automatically exposed + when any of the HTTP handlers are defined. + + You can still explicitly expose/hide the component with `Component.View.public = True/False`. + + Before: + + ```py + class MyTable(Component): + class View: + public = True + + def get(self, request): + return self.render_to_response() + + url = get_component_url(MyTable) + ``` + + After: + + ```py + class MyTable(Component): + class View: + def get(self, request): + return self.render_to_response() + + url = get_component_url(MyTable) + ``` + ## v0.142.2 _06 Oct 2025_ diff --git a/README.md b/README.md index bb8f2f2a..36272b9b 100644 --- a/README.md +++ b/README.md @@ -347,13 +347,10 @@ class Calendar(Component): template_file = "calendar.html" class View: - # Register Component with `urlpatterns` - public = True - # Define handlers def get(self, request, *args, **kwargs): page = request.GET.get("page", 1) - return self.component.render_to_response( + return Calendar.render_to_response( request=request, kwargs={ "page": page, diff --git a/docs/concepts/fundamentals/component_views_urls.md b/docs/concepts/fundamentals/component_views_urls.md index 00719f74..cc2ef610 100644 --- a/docs/concepts/fundamentals/component_views_urls.md +++ b/docs/concepts/fundamentals/component_views_urls.md @@ -13,7 +13,9 @@ django-components has a suite of features that help you write and manage views a - Use [`Component.as_view()`](../../../reference/api#django_components.Component.as_view) to be able to use your Components with Django's [`urlpatterns`](https://docs.djangoproject.com/en/5.2/topics/http/urls/). This works the same way as [`View.as_view()`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#django.views.generic.base.View.as_view). -- To avoid having to manually define the endpoints for each component, you can set the component to be "public" with [`Component.View.public = True`](../../../reference/api#django_components.ComponentView.public). This will automatically create a URL for the component. To retrieve the component URL, use [`get_component_url()`](../../../reference/api#django_components.get_component_url). +- Or use [`get_component_url()`](../../../reference/api#django_components.get_component_url) to retrieve the component URL - an anonymous HTTP endpoint that triggers the component's handlers without having to register the component in `urlpatterns`. + + To expose a component, simply define a handler, or explicitly expose/hide the component with [`Component.View.public = True/False`](../../../reference/api#django_components.ComponentView.public). - In addition, [`Component`](../../../reference/api#django_components.Component) has a [`render_to_response()`](../../../reference/api#django_components.Component.render_to_response) method that renders the component template based on the provided input and returns an `HttpResponse` object. @@ -114,19 +116,13 @@ class as one of the arguments. ## Register URLs automatically -If you don't care about the exact URL of the component, you can let django-components manage the URLs for you by setting the [`Component.View.public`](../../../reference/api#django_components.ComponentView.public) attribute to `True`: +If you don't care about the exact URL of the component, you can let django-components manage the URLs. -```py -class MyComponent(Component): - class View: - public = True +Each component has an "anonymous" URL that triggers the component's HTTP handlers without having to define the component in `urlpatterns`. - def get(self, request): - return self.component_cls.render_to_response(request=request) - ... -``` +This way you don't have to mix your app URLs with component URLs. -Then, to get the URL for the component, use [`get_component_url()`](../../../reference/api#django_components.get_component_url): +To obtain such "anonymous" URL, use [`get_component_url()`](../../../reference/api#django_components.get_component_url): ```py from django_components import get_component_url @@ -134,7 +130,17 @@ from django_components import get_component_url url = get_component_url(MyComponent) ``` -This way you don't have to mix your app URLs with component URLs. +The component is automatically registered in `urlpatterns` when you define a handler. You can also explicitly expose/hide the component with [`Component.View.public`](../../../reference/api#django_components.ComponentView.public): + +```py +class MyComponent(Component): + class View: + public = False + + def get(self, request): + return self.component_cls.render_to_response(request=request) + ... +``` !!! info diff --git a/docs/examples/form_submission/component.py b/docs/examples/form_submission/component.py index 3743151b..410220ad 100644 --- a/docs/examples/form_submission/component.py +++ b/docs/examples/form_submission/component.py @@ -55,8 +55,6 @@ class ContactFormComponent(Component): """ # noqa: E501 class View: - public = True - # Submit handler def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: # Access the submitted data diff --git a/docs/examples/fragments/page.py b/docs/examples/fragments/page.py index 2ea62811..60214114 100644 --- a/docs/examples/fragments/page.py +++ b/docs/examples/fragments/page.py @@ -121,8 +121,6 @@ class FragmentsPage(Component): """ class View: - public = True - # The same GET endpoint handles rendering either the whole page or a fragment. # We use the `type` query parameter to determine which one to render. def get(self, request: HttpRequest) -> HttpResponse: diff --git a/docs/getting_started/rendering_components.md b/docs/getting_started/rendering_components.md index 72205149..7e42f333 100644 --- a/docs/getting_started/rendering_components.md +++ b/docs/getting_started/rendering_components.md @@ -261,18 +261,22 @@ Next, you need to set the URL for the component. You can either: -1. Automatically assign the URL by setting the [`Component.View.public`](../../reference/api#django_components.ComponentView.public) attribute to `True`. +1. Use [`get_component_url()`](../../reference/api#django_components.get_component_url) to retrieve the component URL - an anonymous HTTP endpoint that triggers the component's handlers without having to register the component in `urlpatterns`. - In this case, use [`get_component_url()`](../../reference/api#django_components.get_component_url) to get the URL for the component view. + ```py + from django_components import get_component_url + + url = get_component_url(Calendar) + ``` + + The component endpoint is automatically registered in `urlpatterns` when you define a handler. To explicitly expose/hide the component, use [`Component.View.public`](../../../reference/api#django_components.ComponentView.public). ```djc_py - from django_components import Component, get_component_url + from django_components import Component class Calendar(Component): class View: - public = True - - url = get_component_url(Calendar) + public = False ``` 2. Manually assign the URL by setting [`Component.as_view()`](../../reference/api#django_components.Component.as_view) to your `urlpatterns`: diff --git a/docs/overview/welcome.md b/docs/overview/welcome.md index 34f2de75..536044bb 100644 --- a/docs/overview/welcome.md +++ b/docs/overview/welcome.md @@ -335,13 +335,10 @@ class Calendar(Component): template_file = "calendar.html" class View: - # Register Component with `urlpatterns` - public = True - # Define handlers def get(self, request, *args, **kwargs): page = request.GET.get("page", 1) - return self.component.render_to_response( + return Calendar.render_to_response( request=request, kwargs={ "page": page, diff --git a/src/django_components/extensions/view.py b/src/django_components/extensions/view.py index 7a3f89a1..ee5468c7 100644 --- a/src/django_components/extensions/view.py +++ b/src/django_components/extensions/view.py @@ -44,6 +44,11 @@ def get_component_url( Raises `RuntimeError` if the component is not public. + Component is public when: + + - You set any of the HTTP methods in the [`Component.View`](../api#django_components.ComponentView) class, + - Or you explicitly set [`Component.View.public = True`](../api#django_components.ComponentView.public). + Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls). `get_component_url()` optionally accepts `query` and `fragment` arguments. @@ -59,9 +64,10 @@ def get_component_url( ```py from django_components import Component, get_component_url - class MyComponent(Component): + class MyTable(Component): class View: - public = True + def get(self, request: HttpRequest, **kwargs: Any): + return MyTable.render_to_response() # Get the URL for the component url = get_component_url( @@ -89,15 +95,18 @@ class ComponentView(ExtensionComponentConfig, View): This class is a subclass of [`django.views.View`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#view). - The [`Component`](../api#django_components.Component) class is available - via `self.component_cls`. Override the methods of this class to define the behavior of the component. Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls). + The [`Component`](../api#django_components.Component) class is available + via `self.component_cls`. + **Example:** + Define a handler that runs for GET HTTP requests: + ```python class MyComponent(Component): class View: @@ -107,27 +116,26 @@ class ComponentView(ExtensionComponentConfig, View): **Component URL:** - If the `public` attribute is set to `True`, the component will have its own URL - that will point to the Component's View. + Use [`get_component_url()`](../api#django_components.get_component_url) to retrieve + the component URL - an anonymous HTTP endpoint that triggers the component's handlers without having to register + the component in `urlpatterns`. + + A component is automatically exposed when you define at least one HTTP handler. To explicitly + expose/hide the component, use + [`Component.View.public = True`](../api#django_components.ComponentView.public). ```py - from django_components import Component + from django_components import Component, get_component_url class MyComponent(Component): class View: - public = True - def get(self, request, *args, **kwargs): return HttpResponse("Hello, world!") - ``` - Will create a URL route like `/components/ext/view/components/a1b2c3/`. - - To get the URL for the component, use [`get_component_url()`](../api#django_components.get_component_url): - - ```py url = get_component_url(MyComponent) ``` + + This will create a URL route like `/components/ext/view/components/a1b2c3/`. """ # NOTE: The `component` / `component_cls` attributes are NOT user input, but still must be declared @@ -176,15 +184,17 @@ class ComponentView(ExtensionComponentConfig, View): The URL for the component. Raises `RuntimeError` if the component is not public. + See [`Component.View.public`](../api#django_components.ComponentView.public). This is the same as calling [`get_component_url()`](../api#django_components.get_component_url) - with the parent [`Component`](../api#django_components.Component) class: + with the current [`Component`](../api#django_components.Component) class: ```py class MyComponent(Component): class View: def get(self, request): - assert self.url == get_component_url(self.component_cls) + component_url = get_component_url(self.component_cls) + assert self.url == component_url ``` """ return get_component_url(self.component_cls) @@ -193,26 +203,42 @@ class ComponentView(ExtensionComponentConfig, View): # PUBLIC API (Configurable by users) # ##################################### - public: ClassVar[bool] = False + public: ClassVar[Optional[bool]] = None """ - Whether the component should be available via a URL. + Whether the component HTTP handlers should be available via a URL. + + By default (`None`), the component HTTP handlers are available via a URL + if any of the HTTP methods are defined. + + You can explicitly set `public` to `True` or `False` to override this behaviour. **Example:** + Define the component HTTP handlers and get its URL using + [`get_component_url()`](../api#django_components.get_component_url): + ```py - from django_components import Component + from django_components import Component, get_component_url class MyComponent(Component): class View: - public = True + def get(self, request): + return self.component_cls.render_to_response(request=request) + + url = get_component_url(MyComponent) ``` - Will create a URL route like `/components/ext/view/components/a1b2c3/`. + This will create a URL route like `/components/ext/view/components/a1b2c3/`. - To get the URL for the component, use [`get_component_url()`](../api#django_components.get_component_url): + To explicitly hide the component, set `public = False`: ```py - url = get_component_url(MyComponent) + class MyComponent(Component): + class View: + public = False + + def get(self, request): + return self.component_cls.render_to_response(request=request) ``` """ @@ -222,10 +248,13 @@ class ComponentView(ExtensionComponentConfig, View): # Each method actually delegates to the component's method of the same name. # E.g. When `get()` is called, it delegates to `component.get()`. - # TODO_V1 - In v1 handlers like `get()` should be defined on the Component.View class, - # not the Component class directly. This is to align Views with the extensions API - # where each extension should keep its methods in the extension class. - # Instead, the defaults for these methods should be something like + # TODO_V1 - For backwards compatibility, the HTTP methods can be defined directly on + # the Component class, e.g. `Component.post()`. + # This should be no longer supported in v1. + # In v1, handlers like `get()` should be defined on the Component.View class. + # This is to align Views with the extensions API, where each extension should + # keep its methods in the extension class. + # And instead, the defaults for these methods should be something like # `return self.component_cls.render_to_response(request, *args, **kwargs)` or similar # or raise NotImplementedError. def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: @@ -306,4 +335,25 @@ class ViewExtension(ComponentExtension): def _is_view_public(view_cls: Optional[Type[ComponentView]]) -> bool: if view_cls is None: return False - return getattr(view_cls, "public", False) + + # Allow users to skip setting `View.public = True` if any of the HTTP methods + # are defined. Users can still opt-out by explicitly setting `View.public` to `True` or `False`. + public = getattr(view_cls, "public", None) + if public is not None: + return public + + # Auto-decide whether the view is public by checking if any of the HTTP methods + # are overridden in the user's View class. + # We do this only once, so if user dynamically adds or removes the methods, + # we will not pick up on that. + http_methods = ["get", "post", "put", "patch", "delete", "head", "options", "trace"] + for method in http_methods: + if not hasattr(view_cls, method): + continue + did_change_method = getattr(view_cls, method) != getattr(ComponentView, method) + if did_change_method: + view_cls.public = True + return True + + view_cls.public = False + return False diff --git a/tests/test_component.py b/tests/test_component.py index a567c795..63c207d2 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -19,7 +19,6 @@ from pytest_django.asserts import assertHTMLEqual, assertInHTML from django_components import ( Component, ComponentRegistry, - ComponentView, Slot, SlotInput, all_components, @@ -1240,12 +1239,12 @@ class TestComponentRender: def get_template_data(self, args, kwargs, slots, context): return {"how": kwargs.pop("how")} - class View(ComponentView): + class View: def get(self, request): how = "via GET request" - return self.component.render_to_response( - context=RequestContext(self.request), + return self.component_cls.render_to_response( # type: ignore[attr-defined] + context=RequestContext(request), kwargs={"how": how}, ) diff --git a/tests/test_component_view.py b/tests/test_component_view.py index 80d6b95b..9b9ac4f9 100644 --- a/tests/test_component_view.py +++ b/tests/test_component_view.py @@ -8,7 +8,7 @@ from django.test import Client from django.urls import path from pytest_django.asserts import assertInHTML -from django_components import Component, ComponentView, get_component_url, register, types +from django_components import Component, get_component_url, register, types from django_components.testing import djc_test from django_components.urls import urlpatterns as dc_urlpatterns from django_components.util.misc import format_url @@ -96,9 +96,9 @@ class TestComponentAsView: def get_template_data(self, args, kwargs, slots, context): return {"inner_var": kwargs["variable"]} - class View(ComponentView): + class View: def get(self, request, *args, **kwargs) -> HttpResponse: - return self.component.render_to_response(kwargs={"variable": "GET"}) + return self.component_cls.render_to_response(kwargs={"variable": "GET"}) # type: ignore[attr-defined] client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())]) response = client.get("/test/") @@ -145,10 +145,10 @@ class TestComponentAsView: def get_template_data(self, args, kwargs, slots, context): return {"inner_var": kwargs["variable"]} - class View(ComponentView): + class View: def post(self, request, *args, **kwargs) -> HttpResponse: variable = request.POST.get("variable") - return self.component.render_to_response(kwargs={"variable": variable}) + return self.component_cls.render_to_response(kwargs={"variable": variable}) # type: ignore[attr-defined] client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())]) response = client.post("/test/", {"variable": "POST"}) @@ -366,7 +366,20 @@ class TestComponentAsView: assert response.content == b"Hello" assert did_call_get - def test_non_public_url(self): + def test_public_url_implicit(self): + class TestComponent(Component): + template = "Hello" + + class View: + def get(self, request: HttpRequest, **kwargs: Any): + component: Component = self.component # type: ignore[attr-defined] + return component.render_to_response() + + # Check if the URL is correctly generated + component_url = get_component_url(TestComponent) + assert component_url == f"/components/ext/view/components/{TestComponent.class_id}/" + + def test_public_url_disabled(self): did_call_get = False class TestComponent(Component): diff --git a/tests/test_extension.py b/tests/test_extension.py index 694a27b1..80df2f3d 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,5 +1,5 @@ import gc -from typing import Any, Callable, Dict, List, cast +from typing import Any, Callable, Dict, List, Optional, cast import pytest from django.http import HttpRequest, HttpResponse @@ -215,7 +215,7 @@ class OverrideAssetExtension(ComponentExtension): @djc_test -class TestExtension: +class TestExtensions: @djc_test(components_settings={"extensions": [DummyExtension]}) def test_extensions_setting(self): assert len(app_settings.EXTENSIONS) == 6 @@ -258,6 +258,35 @@ class TestExtension: with pytest.raises(ValueError, match="Multiple extensions cannot have the same name 'test_extension'"): inner() + @djc_test(components_settings={"extensions": [DummyExtension]}) + def test_nested_extension_config_inheritance(self): + component: Optional[Component] = None + + class TestExtensionParent: + parent_var = "from_parent" + + class MyComponent(Component): + template = "hello" + + class TestExtension(TestExtensionParent): + nested_var = "from_nested" + + def get_template_data(self, args, kwargs, slots, context): + nonlocal component + component = self + + # Rendering the component will execute get_template_data + MyComponent.render() + + assert component is not None + # Check properties from DummyExtension.ComponentConfig + assert component.test_extension.foo == "1" # type: ignore[attr-defined] + assert component.test_extension.bar == "2" # type: ignore[attr-defined] + # Check properties from nested class + assert component.test_extension.nested_var == "from_nested" # type: ignore[attr-defined] + # Check properties from parent of nested class + assert component.test_extension.parent_var == "from_parent" # type: ignore[attr-defined] + @djc_test class TestExtensionHooks: