feat: component URL (#1088)

* feat: allow to set defaults

* refactor: remove input validation and link to it

* feat: component URL

* refactor: fix linter errors

* refactor: fix linter errors + update examples to use Component.View..get

* docs: update comment

* refactor: revert change to hash_comp_cls

* docs: update comment
This commit is contained in:
Juro Oravec 2025-04-07 10:44:41 +02:00 committed by GitHub
parent 3555411f1e
commit a49f5e51dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 987 additions and 314 deletions

View file

@ -1,5 +1,56 @@
# Release notes # Release notes
## v0.137
#### Feat
- It's now easier to create URLs for component views.
Before, you had to call `Component.as_view()` and pass that to `urlpatterns`.
Now this can be done for you if you set `Component.Url.public` to `True`:
```py
class MyComponent(Component):
class Url:
public = True
...
```
Then, to get the URL for the component, use `get_component_url()`:
```py
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.
Read more on [Component views and URLs](https://django-components.github.io/django-components/0.135/concepts/fundamentals/component_views_urls/).
#### Deprecation
- Currently, view request handlers such as `get()` and `post()` methods can be defined
directly on the `Component` class:
```py
class MyComponent(Component):
def get(self, request):
return self.render_to_response()
```
Or, nested within the `Component.View` class:
```py
class MyComponent(Component):
class View:
def get(self, request):
return self.render_to_response()
```
In v1, these methods should be defined only on the `Component.View` class instead.
## 🚨📢 v0.136 ## 🚨📢 v0.136
#### 🚨📢 BREAKING CHANGES #### 🚨📢 BREAKING CHANGES
@ -77,6 +128,8 @@ where each class name or style property can be managed separately.
%} %}
``` ```
Read more on [HTML attributes](https://django-components.github.io/django-components/0.135/concepts/fundamentals/html_attributes/).
#### Fix #### Fix
- Fix compat with Windows when reading component files ([#1074](https://github.com/django-components/django-components/issues/1074)) - Fix compat with Windows when reading component files ([#1074](https://github.com/django-components/django-components/issues/1074))

View file

@ -259,13 +259,15 @@ Read more about [HTML attributes](https://django-components.github.io/django-com
class Calendar(Component): class Calendar(Component):
template_file = "calendar.html" template_file = "calendar.html"
def get(self, request, *args, **kwargs): class View:
page = request.GET.get("page", 1) def get(self, request, *args, **kwargs):
return self.render_to_response( page = request.GET.get("page", 1)
kwargs={ return Calendar.render_to_response(
"page": page, request=request,
} kwargs={
) "page": page,
},
)
def get_context_data(self, page): def get_context_data(self, page):
return { return {

View file

@ -65,10 +65,10 @@ to configure the extensions on a per-component basis.
### Example: Component as View ### Example: Component as View
The [Components as Views](../../fundamentals/components_as_views) feature is actually implemented as an extension The [Components as Views](../../fundamentals/component_views_urls) feature is actually implemented as an extension
that is configured by a `View` nested class. that is configured by a `View` nested class.
You can override the `get`, `post`, etc methods to customize the behavior of the component as a view: You can override the `get()`, `post()`, etc methods to customize the behavior of the component as a view:
```python ```python
class MyTable(Component): class MyTable(Component):

View file

@ -106,8 +106,9 @@ from django_components import Component, types
# HTML into which a fragment will be loaded using HTMX # HTML into which a fragment will be loaded using HTMX
class MyPage(Component): class MyPage(Component):
def get(self, request): Class View:
return self.render_to_response() def get(self, request):
return self.component.render_to_response(request=request)
template = """ template = """
{% load component_tags %} {% load component_tags %}
@ -138,11 +139,13 @@ class MyPage(Component):
```djc_py title="[root]/components/demo.py" ```djc_py title="[root]/components/demo.py"
class Frag(Component): class Frag(Component):
def get(self, request): class View:
return self.render_to_response( def get(self, request):
# IMPORTANT: Don't forget `type="fragment"` return self.component.render_to_response(
type="fragment", request=request,
) # IMPORTANT: Don't forget `type="fragment"`
type="fragment",
)
template = """ template = """
<div class="frag"> <div class="frag">
@ -184,8 +187,9 @@ from django_components import Component, types
# HTML into which a fragment will be loaded using AlpineJS # HTML into which a fragment will be loaded using AlpineJS
class MyPage(Component): class MyPage(Component):
def get(self, request): class View:
return self.render_to_response() def get(self, request):
return self.component.render_to_response(request=request)
template = """ template = """
{% load component_tags %} {% load component_tags %}
@ -222,11 +226,13 @@ class MyPage(Component):
```djc_py title="[root]/components/demo.py" ```djc_py title="[root]/components/demo.py"
class Frag(Component): class Frag(Component):
def get(self, request): class View:
# IMPORTANT: Don't forget `type="fragment"` def get(self, request):
return self.render_to_response( return self.component.render_to_response(
type="fragment", request=request,
) # IMPORTANT: Don't forget `type="fragment"`
type="fragment",
)
# NOTE: We wrap the actual fragment in a template tag with x-if="false" to prevent it # NOTE: We wrap the actual fragment in a template tag with x-if="false" to prevent it
# from being rendered until we have registered the component with AlpineJS. # from being rendered until we have registered the component with AlpineJS.
@ -281,8 +287,9 @@ from django_components import Component, types
# HTML into which a fragment will be loaded using JS # HTML into which a fragment will be loaded using JS
class MyPage(Component): class MyPage(Component):
def get(self, request): class View:
return self.render_to_response() def get(self, request):
return self.component.render_to_response(request=request)
template = """ template = """
{% load component_tags %} {% load component_tags %}
@ -318,11 +325,13 @@ class MyPage(Component):
```djc_py title="[root]/components/demo.py" ```djc_py title="[root]/components/demo.py"
class Frag(Component): class Frag(Component):
def get(self, request): class View:
return self.render_to_response( def get(self, request):
# IMPORTANT: Don't forget `type="fragment"` return self.component.render_to_response(
type="fragment", request=request,
) # IMPORTANT: Don't forget `type="fragment"`
type="fragment",
)
template = """ template = """
<div class="frag"> <div class="frag">

View file

@ -10,6 +10,6 @@ nav:
- HTML attributes: html_attributes.md - HTML attributes: html_attributes.md
- Defining HTML / JS / CSS files: defining_js_css_html_files.md - Defining HTML / JS / CSS files: defining_js_css_html_files.md
- Autodiscovery: autodiscovery.md - Autodiscovery: autodiscovery.md
- Components as views: components_as_views.md - Component views and URLs: component_views_urls.md
- HTTP Request: http_request.md - HTTP Request: http_request.md
- Subclassing components: subclassing_components.md - Subclassing components: subclassing_components.md

View file

@ -0,0 +1,128 @@
_New in version 0.34_
_Note: Since 0.92, `Component` is no longer a subclass of Django's `View`. Instead, the nested
[`Component.View`](../../../reference/api#django_components.Component.View) class is a subclass of Django's `View`._
---
For web applications, it's common to define endpoints that serve HTML content (AKA views).
django-components has a suite of features that help you write and manage views and their URLs:
- For each component, you can define methods for handling HTTP requests (GET, POST, etc.) - `get()`, `post()`, etc.
- 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.1/topics/http/urls/). This works the same way as [`View.as_view()`](https://docs.djangoproject.com/en/5.1/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.Url.public = True`](../../../reference/api#django_components.ComponentUrl.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).
- 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.
## Component as view example
### Define handlers
Here's an example of a calendar component defined as a view. Simply define a `View` class with your custom `get()` method to handle GET requests:
```djc_py title="[project root]/components/calendar.py"
from django_components import Component, ComponentView, register
@register("calendar")
class Calendar(Component):
template = """
<div class="calendar-component">
<div class="header">
{% slot "header" / %}
</div>
<div class="body">
Today's date is <span>{{ date }}</span>
</div>
</div>
"""
class View:
# Handle GET requests
def get(self, request, *args, **kwargs):
# Return HttpResponse with the rendered content
return Calendar.render_to_response(
request=request,
kwargs={
"date": request.GET.get("date", "2020-06-06"),
},
slots={
"header": "Calendar header",
},
)
```
!!! info
The View class supports all the same HTTP methods as Django's [`View`](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View) class. These are:
`get()`, `post()`, `put()`, `patch()`, `delete()`, `head()`, `options()`, `trace()`
Each of these receive the [`HttpRequest`](https://docs.djangoproject.com/en/5.1/ref/request-response/#django.http.HttpRequest) object as the first argument.
<!-- TODO_V1 REMOVE -->
!!! warning
**Deprecation warning:**
Previously, the handler methods such as `get()` and `post()` could be defined directly on the `Component` class:
```py
class Calendar(Component):
def get(self, request, *args, **kwargs):
return self.render_to_response(
kwargs={
"date": request.GET.get("date", "2020-06-06"),
}
)
```
This is deprecated from v0.137 onwards, and will be removed in v1.0.
### Register the URLs manually
To register the component as a route / endpoint in Django, add an entry to your
[`urlpatterns`](https://docs.djangoproject.com/en/5.1/topics/http/urls/).
In place of the view function, create a view object with [`Component.as_view()`](../../../reference/api#django_components.Component.as_view):
```python title="[project root]/urls.py"
from django.urls import path
from components.calendar.calendar import Calendar
urlpatterns = [
path("calendar/", Calendar.as_view()),
]
```
[`Component.as_view()`](../../../reference/api#django_components.Component.as_view)
internally calls [`View.as_view()`](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View.as_view), passing the component
instance as one of the arguments.
### Register the 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.Url.public`](../../../reference/api#django_components.ComponentUrl.public) attribute to `True`:
```py
class MyComponent(Component):
class Url:
public = True
class View:
def get(self, request):
return self.component.render_to_response(request=request)
...
```
Then, to get the URL for the component, use [`get_component_url()`](../../../reference/api#django_components.get_component_url):
```py
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.

View file

@ -1,148 +0,0 @@
_New in version 0.34_
_Note: Since 0.92, Component no longer subclasses View. To configure the View class, set the nested `Component.View` class_
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:
```djc_py
# In a file called [project root]/components/calendar.py
from django_components import Component, ComponentView, register
@register("calendar")
class Calendar(Component):
template = """
<div class="calendar-component">
<div class="header">
{% slot "header" / %}
</div>
<div class="body">
Today's date is <span>{{ date }}</span>
</div>
</div>
"""
# Handle GET requests
def get(self, request, *args, **kwargs):
context = {
"date": request.GET.get("date", "2020-06-06"),
}
slots = {
"header": "Calendar header",
}
# 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:
```python
# In a file called [project root]/components/urls.py
from django.urls import path
from components.calendar.calendar import Calendar
urlpatterns = [
path("calendar/", Calendar.as_view()),
]
```
`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:
```python
# In a file called [project root]/urls.py
from django.urls import include, path
urlpatterns = [
path("components/", include("components.urls")),
]
```
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 instance.
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)
```

View file

@ -663,3 +663,42 @@ class MyTable(Component):
</div> </div>
""" """
``` ```
### Escaping slots content
Slots content are automatically escaped by default to prevent XSS attacks.
In other words, it's as if you would be using Django's [`mark_safe()`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.safestring.mark_safe) function on the slot content:
```python
from django.utils.safestring import mark_safe
class Calendar(Component):
template = """
<div>
{% slot "date" default date=date / %}
</div>
"""
Calendar.render(
slots={
"date": mark_safe("<b>Hello</b>"),
}
)
```
To disable escaping, you can pass `escape_slots_content=False` to
[`Component.render()`](../../reference/api#django_components.Component.render)
or [`Component.render_to_response()`](../../reference/api#django_components.Component.render_to_response)
methods.
!!! warning
If you disable escaping, you should make sure that any content you pass to the slots is safe,
especially if it comes from user input!
!!! info
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).

View file

@ -25,7 +25,7 @@ class Calendar(Component):
} }
``` ```
### 1. Render the template that contains the `{% component %}` tag ### 1. Render the template
If you have embedded the component in a Django template using the If you have embedded the component in a Django template using the
[`{% component %}`](../../reference/template_tags#component) tag: [`{% component %}`](../../reference/template_tags#component) tag:
@ -74,7 +74,7 @@ template = Template("""
rendered_template = template.render() rendered_template = template.render()
``` ```
### 2. Render the component directly with [`Component.render()`](../../reference/api#django_components.Component.render) ### 2. Render the component
You can also render the component directly with [`Component.render()`](../../reference/api#django_components.Component.render), without wrapping the component in a template. You can also render the component directly with [`Component.render()`](../../reference/api#django_components.Component.render), without wrapping the component in a template.
@ -115,7 +115,7 @@ rendered_component = calendar.render(
The `request` object is required for some of the component's features, like using [Django's context processors](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.RequestContext). The `request` object is required for some of the component's features, like using [Django's context processors](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.RequestContext).
### 3. Render the component directly with [`Component.render_to_response()`](../../reference/api#django_components.Component.render_to_response) ### 3. Render the component to HttpResponse
A common pattern in Django is to render the component and then return the resulting HTML as a response to an HTTP request. A common pattern in Django is to render the component and then return the resulting HTML as a response to an HTTP request.
@ -159,3 +159,133 @@ def my_view(request):
class SimpleComponent(Component): class SimpleComponent(Component):
response_class = MyCustomResponse response_class = MyCustomResponse
``` ```
### Rendering slots
Slots content are automatically escaped by default to prevent XSS attacks.
In other words, it's as if you would be using Django's [`mark_safe()`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.safestring.mark_safe) function on the slot content:
```python
from django.utils.safestring import mark_safe
class Calendar(Component):
template = """
<div>
{% slot "date" default date=date / %}
</div>
"""
Calendar.render(
slots={
"date": mark_safe("<b>Hello</b>"),
}
)
```
To disable escaping, you can pass `escape_slots_content=False` to
[`Component.render()`](../../reference/api#django_components.Component.render)
or [`Component.render_to_response()`](../../reference/api#django_components.Component.render_to_response)
methods.
!!! warning
If you disable escaping, you should make sure that any content you pass to the slots is safe,
especially if it comes from user input!
!!! info
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).
### Component views and URLs
For web applications, it's common to define endpoints that serve HTML content (AKA views).
If this is your case, you can define the view request handlers directly on your component by using the nested[`Component.View`](../../reference/api#django_components.Component.View) class.
This is a great place for:
- Endpoints that render whole pages, if your component
is a page component.
- Endpoints that render the component as HTML fragments, to be used with HTMX or similar libraries.
Read more on [Component views and URLs](../../concepts/fundamentals/component_views_urls).
```djc_py title="[project root]/components/calendar.py"
from django_components import Component, ComponentView, register
@register("calendar")
class Calendar(Component):
template = """
<div class="calendar-component">
<div class="header">
{% slot "header" / %}
</div>
<div class="body">
Today's date is <span>{{ date }}</span>
</div>
</div>
"""
class View:
# Handle GET requests
def get(self, request, *args, **kwargs):
# Return HttpResponse with the rendered content
return Calendar.render_to_response(
request=request,
kwargs={
"date": request.GET.get("date", "2020-06-06"),
},
slots={
"header": "Calendar header",
},
)
```
!!! info
The View class supports all the same HTTP methods as Django's [`View`](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View) class. These are:
`get()`, `post()`, `put()`, `patch()`, `delete()`, `head()`, `options()`, `trace()`
Each of these receive the [`HttpRequest`](https://docs.djangoproject.com/en/5.1/ref/request-response/#django.http.HttpRequest) object as the first argument.
Next, you need to set the URL for the component.
You can either:
1. Automatically assign the URL by setting the [`Component.Url.public`](../../reference/api#django_components.ComponentUrl.public) attribute to `True`.
In this case, use [`get_component_url()`](../../reference/api#django_components.get_component_url) to get the URL for the component view.
```djc_py
from django_components import Component, get_component_url
class Calendar(Component):
class Url:
public = True
url = get_component_url(Calendar)
```
2. Manually assign the URL by setting [`Component.as_view()`](../../reference/api#django_components.Component.as_view) to your `urlpatterns`:
```djc_py
from django.urls import path
from components.calendar import Calendar
urlpatterns = [
path("calendar/", Calendar.as_view()),
]
```
And with that, you're all set! When you visit the URL, the component will be rendered and the content will be returned.
The `get()`, `post()`, etc methods will receive the [`HttpRequest`](https://docs.djangoproject.com/en/5.1/ref/request-response/#django.http.HttpRequest) object as the first argument. So you can parametrize how the component is rendered for example by passing extra query parameters to the URL:
```
http://localhost:8000/calendar/?date=2024-12-13
```

View file

@ -249,13 +249,15 @@ Read more about [HTML attributes](../../concepts/fundamentals/html_attributes/).
class Calendar(Component): class Calendar(Component):
template_file = "calendar.html" template_file = "calendar.html"
def get(self, request, *args, **kwargs): class View:
page = request.GET.get("page", 1) def get(self, request, *args, **kwargs):
return self.render_to_response( page = request.GET.get("page", 1)
kwargs={ return self.component.render_to_response(
"page": page, request=request,
} kwargs={
) "page": page,
}
)
def get_context_data(self, page): def get_context_data(self, page):
return { return {

View file

@ -35,6 +35,10 @@
options: options:
show_if_no_docstring: true show_if_no_docstring: true
::: django_components.ComponentUrl
options:
show_if_no_docstring: true
::: django_components.ComponentVars ::: django_components.ComponentVars
options: options:
show_if_no_docstring: true show_if_no_docstring: true
@ -123,6 +127,10 @@
options: options:
show_if_no_docstring: true show_if_no_docstring: true
::: django_components.get_component_url
options:
show_if_no_docstring: true
::: django_components.import_libraries ::: django_components.import_libraries
options: options:
show_if_no_docstring: true show_if_no_docstring: true

View file

@ -462,8 +462,8 @@ ProjectDashboardAction project.components.dashboard_action.ProjectDashboardAc
## `upgradecomponent` ## `upgradecomponent`
```txt ```txt
usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS] usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH]
[--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] [--skip-checks] [--traceback] [--no-color] [--force-color] [--skip-checks]
``` ```
@ -507,9 +507,9 @@ Deprecated. Use `components upgrade` instead.
## `startcomponent` ## `startcomponent`
```txt ```txt
usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose] usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose] [--dry-run]
[--dry-run] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback]
[--traceback] [--no-color] [--force-color] [--skip-checks] [--no-color] [--force-color] [--skip-checks]
name name
``` ```

View file

@ -107,6 +107,25 @@ name | type | description
`name` | `str` | The name the component was registered under `name` | `str` | The name the component was registered under
`registry` | [`ComponentRegistry`](../api#django_components.ComponentRegistry) | The registry the component was registered to `registry` | [`ComponentRegistry`](../api#django_components.ComponentRegistry) | The registry the component was registered to
::: django_components.extension.ComponentExtension.on_component_rendered
options:
show_root_heading: true
show_signature: true
separate_signature: true
show_symbol_type_heading: false
show_symbol_type_toc: false
show_if_no_docstring: true
show_labels: false
**Available data:**
name | type | description
--|--|--
`component` | [`Component`](../api#django_components.Component) | The Component instance that is being rendered
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class
`component_id` | `str` | The unique identifier for this component instance
`template` | `str` | The rendered template
::: django_components.extension.ComponentExtension.on_component_unregistered ::: django_components.extension.ComponentExtension.on_component_unregistered
options: options:
heading_level: 3 heading_level: 3

View file

@ -18,12 +18,14 @@ class Calendar(Component):
"date": date, "date": date,
} }
def get(self, request, *args, **kwargs): class View:
return self.render_to_response( def get(self, request, *args, **kwargs):
kwargs={ return Calendar.render_to_response(
"date": request.GET.get("date", ""), request=request,
}, kwargs={
) "date": request.GET.get("date", ""),
},
)
@register("calendar_relative") @register("calendar_relative")
@ -43,9 +45,11 @@ class CalendarRelative(Component):
"date": date, "date": date,
} }
def get(self, request, *args, **kwargs): class View:
return self.render_to_response( def get(self, request, *args, **kwargs):
kwargs={ return CalendarRelative.render_to_response(
"date": request.GET.get("date", ""), request=request,
}, kwargs={
) "date": request.GET.get("date", ""),
},
)

View file

@ -3,8 +3,9 @@ from django_components import Component, types
# HTML into which a fragment will be loaded using vanilla JS # HTML into which a fragment will be loaded using vanilla JS
class FragmentBaseJs(Component): class FragmentBaseJs(Component):
def get(self, request): class View:
return self.render_to_response() def get(self, request):
return FragmentBaseJs.render_to_response(request=request)
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
@ -39,8 +40,9 @@ class FragmentBaseJs(Component):
# HTML into which a fragment will be loaded using AlpineJs # HTML into which a fragment will be loaded using AlpineJs
class FragmentBaseAlpine(Component): class FragmentBaseAlpine(Component):
def get(self, request): class View:
return self.render_to_response() def get(self, request):
return FragmentBaseAlpine.render_to_response(request=request)
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
@ -76,8 +78,9 @@ class FragmentBaseAlpine(Component):
# HTML into which a fragment will be loaded using HTMX # HTML into which a fragment will be loaded using HTMX
class FragmentBaseHtmx(Component): class FragmentBaseHtmx(Component):
def get(self, request): class View:
return self.render_to_response() def get(self, request):
return FragmentBaseHtmx.render_to_response(request=request)
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
@ -102,8 +105,9 @@ class FragmentBaseHtmx(Component):
# Fragment where the JS and CSS are defined on the Component # Fragment where the JS and CSS are defined on the Component
class FragJs(Component): class FragJs(Component):
def get(self, request): class View:
return self.render_to_response(type="fragment") def get(self, request):
return FragJs.render_to_response(request=request, type="fragment")
template: types.django_html = """ template: types.django_html = """
<div class="frag"> <div class="frag">
@ -125,8 +129,9 @@ class FragJs(Component):
# Fragment that defines an AlpineJS component # Fragment that defines an AlpineJS component
class FragAlpine(Component): class FragAlpine(Component):
def get(self, request): class View:
return self.render_to_response(type="fragment") def get(self, request):
return FragAlpine.render_to_response(request=request, type="fragment")
# NOTE: We wrap the actual fragment in a template tag with x-if="false" to prevent it # NOTE: We wrap the actual fragment in a template tag with x-if="false" to prevent it
# from being rendered until we have registered the component with AlpineJS. # from being rendered until we have registered the component with AlpineJS.

View file

@ -5,14 +5,16 @@ from django_components import Component, register, types
@register("greeting") @register("greeting")
class Greeting(Component): class Greeting(Component):
def get(self, request, *args, **kwargs): class View:
slots = {"message": "Hello, world!"} def get(self, request, *args, **kwargs):
return self.render_to_response( slots = {"message": "Hello, world!"}
slots=slots, return Greeting.render_to_response(
kwargs={ request=request,
"name": request.GET.get("name", ""), slots=slots,
}, kwargs={
) "name": request.GET.get("name", ""),
},
)
def get_context_data(self, name, *args, **kwargs) -> Dict[str, Any]: def get_context_data(self, name, *args, **kwargs) -> Dict[str, Any]:
return {"name": name} return {"name": name}

View file

@ -18,9 +18,11 @@ class CalendarNested(Component):
"date": date, "date": date,
} }
def get(self, request, *args, **kwargs): class View:
return self.render_to_response( def get(self, request, *args, **kwargs):
kwargs={ return CalendarNested.render_to_response(
"date": request.GET.get("date", ""), request=request,
}, kwargs={
) "date": request.GET.get("date", ""),
},
)

View file

@ -1,3 +1,4 @@
import time
from typing import Any, Dict from typing import Any, Dict
from django_components import Component, register, types from django_components import Component, register, types
@ -5,17 +6,18 @@ from django_components import Component, register, types
@register("recursive") @register("recursive")
class Recursive(Component): class Recursive(Component):
def get(self, request): class View:
import time def get(self, request):
time_before = time.time() time_before = time.time()
output = self.render_to_response( output = Recursive.render_to_response(
kwargs={ request=request,
"depth": 0, kwargs={
}, "depth": 0,
) },
time_after = time.time() )
print("TIME: ", time_after - time_before) time_after = time.time()
return output print("TIME: ", time_after - time_before)
return output
def get_context_data(self, depth: int = 0) -> Dict[str, Any]: def get_context_data(self, depth: int = 0) -> Dict[str, Any]:
return {"depth": depth + 1} return {"depth": depth + 1}

View file

@ -42,6 +42,7 @@ from django_components.extension import (
) )
from django_components.extensions.defaults import Default from django_components.extensions.defaults import Default
from django_components.extensions.view import ComponentView from django_components.extensions.view import ComponentView
from django_components.extensions.url import ComponentUrl, get_component_url
from django_components.library import TagProtectedError from django_components.library import TagProtectedError
from django_components.node import BaseNode, template_tag from django_components.node import BaseNode, template_tag
from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult
@ -86,6 +87,7 @@ __all__ = [
"ComponentRegistry", "ComponentRegistry",
"ComponentVars", "ComponentVars",
"ComponentView", "ComponentView",
"ComponentUrl",
"component_formatter", "component_formatter",
"component_shorthand_formatter", "component_shorthand_formatter",
"ContextBehavior", "ContextBehavior",
@ -96,6 +98,7 @@ __all__ = [
"format_attributes", "format_attributes",
"get_component_dirs", "get_component_dirs",
"get_component_files", "get_component_files",
"get_component_url",
"import_libraries", "import_libraries",
"merge_attributes", "merge_attributes",
"NotRegistered", "NotRegistered",

View file

@ -751,9 +751,10 @@ class InternalSettings:
# Prepend built-in extensions # Prepend built-in extensions
from django_components.extensions.defaults import DefaultsExtension from django_components.extensions.defaults import DefaultsExtension
from django_components.extensions.url import UrlExtension
from django_components.extensions.view import ViewExtension from django_components.extensions.view import ViewExtension
extensions = [DefaultsExtension, ViewExtension] + list(extensions) extensions = [DefaultsExtension, ViewExtension, UrlExtension] + list(extensions)
# Extensions may be passed in either as classes or import strings. # Extensions may be passed in either as classes or import strings.
extension_instances: List["ComponentExtension"] = [] extension_instances: List["ComponentExtension"] = []

View file

@ -1,10 +1,10 @@
from argparse import ArgumentParser from argparse import ArgumentParser
from typing import Any, List, Optional, Type from typing import Any, Iterable, List, Optional, Type, Union
import django import django
import django.urls as django_urls import django.urls as django_urls
from django.core.management.base import BaseCommand as DjangoCommand from django.core.management.base import BaseCommand as DjangoCommand
from django.urls import URLPattern from django.urls import URLPattern, URLResolver
from django_components.util.command import ( from django_components.util.command import (
CommandArg, CommandArg,
@ -128,7 +128,7 @@ def load_as_django_command(command: Type[ComponentCommand]) -> Type[DjangoComman
################################################ ################################################
def routes_to_django(routes: List[URLRoute]) -> List[URLPattern]: def routes_to_django(routes: Iterable[URLRoute]) -> List[Union[URLPattern, URLResolver]]:
""" """
Convert a list of `URLRoute` objects to a list of `URLPattern` objects. Convert a list of `URLRoute` objects to a list of `URLPattern` objects.
@ -149,7 +149,7 @@ def routes_to_django(routes: List[URLRoute]) -> List[URLPattern]:
]) ])
``` ```
""" """
django_routes: List[URLPattern] = [] django_routes: List[Union[URLPattern, URLResolver]] = []
for route in routes: for route in routes:
# The handler is equivalent to `view` function in Django # The handler is equivalent to `view` function in Django
if route.handler is not None: if route.handler is not None:

View file

@ -60,7 +60,9 @@ from django_components.extension import (
OnComponentInputContext, OnComponentInputContext,
extensions, extensions,
) )
from django_components.extensions.view import ViewFn from django_components.extensions.defaults import ComponentDefaults
from django_components.extensions.url import ComponentUrl
from django_components.extensions.view import ComponentView, ViewFn
from django_components.node import BaseNode from django_components.node import BaseNode
from django_components.perfutil.component import ComponentRenderer, component_context_cache, component_post_render from django_components.perfutil.component import ComponentRenderer, component_context_cache, component_post_render
from django_components.perfutil.provide import register_provide_reference, unregister_provide_reference from django_components.perfutil.provide import register_provide_reference, unregister_provide_reference
@ -570,6 +572,19 @@ class Component(
""" """
pass pass
# #####################################
# EXTENSIONS
# #####################################
# NOTE: These are the classes and instances added by defaults extensions. These fields
# are actually set at runtime, and so here they are only marked for typing.
Defaults: Type[ComponentDefaults]
defaults: ComponentDefaults
View: Type[ComponentView]
view: ComponentView
Url: Type[ComponentUrl]
url: ComponentUrl
# ##################################### # #####################################
# MISC # MISC
# ##################################### # #####################################
@ -899,6 +914,8 @@ class Component(
def as_view(cls, **initkwargs: Any) -> ViewFn: def as_view(cls, **initkwargs: Any) -> ViewFn:
""" """
Shortcut for calling `Component.View.as_view` and passing component instance to it. Shortcut for calling `Component.View.as_view` and passing component instance to it.
Read more on [Component views and URLs](../../concepts/fundamentals/component_views_urls).
""" """
# NOTE: `Component.View` may not be available at the time that URLs are being # NOTE: `Component.View` may not be available at the time that URLs are being

View file

@ -1,9 +1,9 @@
from functools import wraps from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Tuple, Type, TypeVar from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Tuple, Type, TypeVar, Union
import django.urls import django.urls
from django.template import Context from django.template import Context
from django.urls import URLResolver, get_resolver, get_urlconf from django.urls import URLPattern, URLResolver, get_resolver, get_urlconf
from django_components.app_settings import app_settings from django_components.app_settings import app_settings
from django_components.compat.django import routes_to_django from django_components.compat.django import routes_to_django
@ -504,8 +504,12 @@ class ExtensionManager:
# Internal # Internal
########################### ###########################
_initialized = False def __init__(self) -> None:
_events: List[Tuple[str, Any]] = [] self._initialized = False
self._events: List[Tuple[str, Any]] = []
self._url_resolvers: Dict[str, URLResolver] = {}
# Keep track of which URLRoute (framework-agnostic) maps to which URLPattern (Django-specific)
self._route_to_url: Dict[URLRoute, Union[URLPattern, URLResolver]] = {}
@property @property
def extensions(self) -> List[ComponentExtension]: def extensions(self) -> List[ComponentExtension]:
@ -636,9 +640,14 @@ class ExtensionManager:
# TODO_V3 - Django-specific logic - replace with hook # TODO_V3 - Django-specific logic - replace with hook
urls: List[URLResolver] = [] urls: List[URLResolver] = []
for extension in self.extensions: for extension in self.extensions:
ext_urls = routes_to_django(extension.urls) # NOTE: The empty list is a placeholder for the URLs that will be added later
ext_url_path = django.urls.path(f"{extension.name}/", django.urls.include(ext_urls)) curr_ext_url_resolver = django.urls.path(f"{extension.name}/", django.urls.include([]))
urls.append(ext_url_path) urls.append(curr_ext_url_resolver)
# Remember which extension the URLResolver belongs to
self._url_resolvers[extension.name] = curr_ext_url_resolver
self.add_extension_urls(extension.name, extension.urls)
# NOTE: `urlconf_name` is the actual source of truth that holds either a list of URLPatterns # NOTE: `urlconf_name` is the actual source of truth that holds either a list of URLPatterns
# or an import string thereof. # or an import string thereof.
@ -647,8 +656,8 @@ class ExtensionManager:
# So we set both: # So we set both:
# - `urlconf_name` to update the source of truth # - `urlconf_name` to update the source of truth
# - `url_patterns` to override the caching # - `url_patterns` to override the caching
ext_url_resolver.urlconf_name = urls extensions_url_resolver.urlconf_name = urls
ext_url_resolver.url_patterns = urls extensions_url_resolver.url_patterns = urls
# Rebuild URL resolver cache to be able to resolve the new routes by their names. # Rebuild URL resolver cache to be able to resolve the new routes by their names.
urlconf = get_urlconf() urlconf = get_urlconf()
@ -668,6 +677,56 @@ class ExtensionManager:
return command return command
raise ValueError(f"Command {command_name} not found in extension {name}") raise ValueError(f"Command {command_name} not found in extension {name}")
def add_extension_urls(self, name: str, urls: List[URLRoute]) -> None:
if not self._initialized:
raise RuntimeError("Cannot add extension URLs before initialization")
url_resolver = self._url_resolvers[name]
all_urls = url_resolver.url_patterns
new_urls = routes_to_django(urls)
did_add_urls = False
# Allow to add only those routes that are not yet added
for route, urlpattern in zip(urls, new_urls):
if route in self._route_to_url:
raise ValueError(f"URLRoute {route} already exists")
self._route_to_url[route] = urlpattern
all_urls.append(urlpattern)
did_add_urls = True
# Force Django's URLResolver to update its lookups, so things like `reverse()` work
if did_add_urls:
# Django's root URLResolver
urlconf = get_urlconf()
root_resolver = get_resolver(urlconf)
root_resolver._populate()
def remove_extension_urls(self, name: str, urls: List[URLRoute]) -> None:
if not self._initialized:
raise RuntimeError("Cannot remove extension URLs before initialization")
url_resolver = self._url_resolvers[name]
urls_to_remove = routes_to_django(urls)
all_urls = url_resolver.url_patterns
# Remove the URLs in reverse order, so that we don't have to deal with index shifting
for index in reversed(range(len(all_urls))):
if not urls_to_remove:
break
# Instead of simply checking if the URL is in the `urls_to_remove` list, we search for
# the index of the URL within the `urls_to_remove` list, so we can remove it from there.
# That way, in theory, the iteration should be faster as the list gets smaller.
try:
found_index = urls_to_remove.index(all_urls[index])
except ValueError:
found_index = -1
if found_index != -1:
all_urls.pop(index)
urls_to_remove.pop(found_index)
############################# #############################
# Component lifecycle hooks # Component lifecycle hooks
############################# #############################
@ -738,4 +797,4 @@ urlpatterns = [
# #
# As such, we lazily set the extensions' routes to the `URLResolver` object. And we use the `include() # As such, we lazily set the extensions' routes to the `URLResolver` object. And we use the `include()
# and `path()` funtions above to ensure that the `URLResolver` object is created correctly. # and `path()` funtions above to ensure that the `URLResolver` object is created correctly.
ext_url_resolver: URLResolver = urlpatterns[0] extensions_url_resolver: URLResolver = urlpatterns[0]

View file

@ -119,6 +119,25 @@ def _apply_defaults(kwargs: Dict, defaults: List[ComponentDefaultField]) -> None
kwargs[default_field.key] = default_value kwargs[default_field.key] = default_value
class ComponentDefaults(ComponentExtension.ExtensionClass): # type: ignore[misc,valid-type]
"""
The interface for `Component.Defaults`.
The fields of this class are used to set default values for the component's kwargs.
**Example:**
```python
from django_components import Component, Default
class MyComponent(Component):
class Defaults:
position = "left"
selected_items = Default(lambda: [1, 2, 3])
"""
pass
class DefaultsExtension(ComponentExtension): class DefaultsExtension(ComponentExtension):
""" """
This extension adds a nested `Defaults` class to each `Component`. This extension adds a nested `Defaults` class to each `Component`.
@ -141,6 +160,7 @@ class DefaultsExtension(ComponentExtension):
""" """
name = "defaults" name = "defaults"
ExtensionClass = ComponentDefaults
# Preprocess the `Component.Defaults` class, if given, so we don't have to do it # Preprocess the `Component.Defaults` class, if given, so we don't have to do it
# each time a component is rendered. # each time a component is rendered.

View file

@ -0,0 +1,181 @@
import sys
from typing import TYPE_CHECKING, Optional, Type, Union
from weakref import WeakKeyDictionary
import django.urls
from django_components.extension import (
ComponentExtension,
OnComponentClassCreatedContext,
OnComponentClassDeletedContext,
URLRoute,
extensions,
)
if TYPE_CHECKING:
from django_components.component import Component
# NOTE: `WeakKeyDictionary` is NOT a generic pre-3.9
if sys.version_info >= (3, 9):
ComponentRouteCache = WeakKeyDictionary[Type["Component"], URLRoute]
else:
ComponentRouteCache = WeakKeyDictionary
def _get_component_route_name(component: Union[Type["Component"], "Component"]) -> str:
return f"__component_url__{component._class_hash}"
def get_component_url(component: Union[Type["Component"], "Component"]) -> str:
"""
Get the URL for a [`Component`](../api#django_components.Component).
Raises `RuntimeError` if the component is not public.
Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls).
**Example:**
```py
from django_components import Component, get_component_url
class MyComponent(Component):
class Url:
public = True
# Get the URL for the component
url = get_component_url(MyComponent)
```
"""
url_cls: Optional[Type[ComponentUrl]] = getattr(component, "Url", None)
if url_cls is None or not url_cls.public:
raise RuntimeError("Component URL is not available - Component is not public")
route_name = _get_component_route_name(component)
return django.urls.reverse(route_name)
class ComponentUrl(ComponentExtension.ExtensionClass): # type: ignore
"""
The interface for `Component.Url`.
This class is used to configure whether the component should be available via a URL.
Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls).
**Example:**
```python
from django_components import Component
class MyComponent(Component):
class Url:
public = True
# Get the URL for the component
url = get_component_url(MyComponent)
```
"""
public: bool = False
"""
Whether this [`Component`](../api#django_components.Component) should be available
via a URL. Defaults to `False`.
If `True`, the Component will have its own unique URL path.
You can use this to write components that will correspond to HTML fragments
for HTMX or similar libraries.
To obtain the component URL, either access the url from
[`Component.Url.url`](../api#django_components.ComponentUrl.url) or
use the [`get_component_url()`](../api#django_components.get_component_url) function.
**Example:**
```py
from django_components import Component, get_component_url
class MyComponent(Component):
class Url:
public = True
# Get the URL for the component
url = get_component_url(MyComponent)
```
"""
@property
def url(self) -> str:
"""
The URL for the component.
Raises `RuntimeError` if the component is not public.
"""
return get_component_url(self.component.__class__)
class UrlExtension(ComponentExtension):
"""
This extension adds a nested `Url` class to each [`Component`](../api#django_components.Component).
This nested `Url` class configures whether the component should be available via a URL.
Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls).
**Example:**
```py
from django_components import Component
class MyComponent(Component):
class Url:
public = True
```
Will create a URL route like `/components/ext/url/components/a1b2c3/`.
To get the URL for the component, use `get_component_url`:
```py
url = get_component_url(MyComponent)
```
This extension is automatically added to all [`Component`](../api#django_components.Component)
classes.
"""
name = "url"
ExtensionClass = ComponentUrl
def __init__(self) -> None:
# Remember which route belongs to which component
self.routes_by_component: ComponentRouteCache = WeakKeyDictionary()
# Create URL route on creation
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
url_cls: Optional[Type[ComponentUrl]] = getattr(ctx.component_cls, "Url", None)
if url_cls is None or not url_cls.public:
return
# Create a URL route like `components/MyTable_a1b2c3/`
# And since this is within the `url` extension, the full URL path will then be:
# `/components/ext/url/components/MyTable_a1b2c3/`
route_path = f"components/{ctx.component_cls._class_hash}/"
route_name = _get_component_route_name(ctx.component_cls)
route = URLRoute(
path=route_path,
handler=ctx.component_cls.as_view(),
name=route_name,
)
self.routes_by_component[ctx.component_cls] = route
extensions.add_extension_urls(self.name, [route])
# Remove URL route on deletion
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
route = self.routes_by_component.pop(ctx.component_cls, None)
if route is None:
return
extensions.remove_extension_urls(self.name, [route])

View file

@ -15,8 +15,19 @@ class ViewFn(Protocol):
class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
""" """
Subclass of `django.views.View` where the `Component` instance is available The interface for `Component.View`.
Override the methods of this class to define the behavior of the component.
This class is a subclass of `django.views.View`. The `Component` instance is available
via `self.component`. via `self.component`.
**Example:**
```python
class MyComponent(Component):
class View:
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return HttpResponse("Hello, world!")
""" """
# NOTE: This attribute must be declared on the class for `View.as_view()` to allow # NOTE: This attribute must be declared on the class for `View.as_view()` to allow
@ -33,42 +44,40 @@ class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
# Each method actually delegates to the component's method of the same name. # Each method actually delegates to the component's method of the same name.
# E.g. When `get()` is called, it delegates to `component.get()`. # 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
# `return self.component.render_to_response()` or similar.
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component return getattr(self.component, "get")(request, *args, **kwargs)
return getattr(component, "get")(request, *args, **kwargs)
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component return getattr(self.component, "post")(request, *args, **kwargs)
return getattr(component, "post")(request, *args, **kwargs)
def put(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def put(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component return getattr(self.component, "put")(request, *args, **kwargs)
return getattr(component, "put")(request, *args, **kwargs)
def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component return getattr(self.component, "patch")(request, *args, **kwargs)
return getattr(component, "patch")(request, *args, **kwargs)
def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component return getattr(self.component, "delete")(request, *args, **kwargs)
return getattr(component, "delete")(request, *args, **kwargs)
def head(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def head(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component return getattr(self.component, "head")(request, *args, **kwargs)
return getattr(component, "head")(request, *args, **kwargs)
def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component return getattr(self.component, "options")(request, *args, **kwargs)
return getattr(component, "options")(request, *args, **kwargs)
def trace(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def trace(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component return getattr(self.component, "trace")(request, *args, **kwargs)
return getattr(component, "trace")(request, *args, **kwargs)
class ViewExtension(ComponentExtension): class ViewExtension(ComponentExtension):
""" """
This extension adds a nested `View` class to each `Component`. This extension adds a nested `View` class to each `Component`.
This nested class is a subclass of `django.views.View`, and allows the component This nested class is a subclass of `django.views.View`, and allows the component
to be used as a view by calling `ComponentView.as_view()`. to be used as a view by calling `ComponentView.as_view()`.

View file

@ -112,10 +112,11 @@ def is_nonempty_str(txt: Optional[str]) -> bool:
return txt is not None and bool(txt.strip()) return txt is not None and bool(txt.strip())
# Convert Component class to something like `TableComp_a91d03`
def hash_comp_cls(comp_cls: Type["Component"]) -> str: def hash_comp_cls(comp_cls: Type["Component"]) -> str:
full_name = get_import_path(comp_cls) full_name = get_import_path(comp_cls)
comp_cls_hash = md5(full_name.encode()).hexdigest()[0:6] name_hash = md5(full_name.encode()).hexdigest()[0:6]
return comp_cls.__name__ + "_" + comp_cls_hash return comp_cls.__name__ + "_" + name_hash
# String is a glob if it contains at least one of `?`, `*`, or `[` # String is a glob if it contains at least one of `?`, `*`, or `[`

View file

@ -1,5 +1,5 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol from typing import Any, Dict, Iterable, Optional, Protocol
# Mark object as related to extension URLs so we can place these in # Mark object as related to extension URLs so we can place these in
@ -62,10 +62,14 @@ class URLRoute:
path: str path: str
handler: Optional[URLRouteHandler] = None handler: Optional[URLRouteHandler] = None
children: List["URLRoute"] = field(default_factory=list) children: Iterable["URLRoute"] = field(default_factory=list)
name: Optional[str] = None name: Optional[str] = None
extra: Dict[str, Any] = field(default_factory=dict) extra: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.handler is not None and self.children: if self.handler is not None and self.children:
raise ValueError("Cannot have both handler and children") raise ValueError("Cannot have both handler and children")
# Allow to use `URLRoute` objects in sets and dictionaries
def __hash__(self) -> int:
return hash(self.path)

View file

@ -523,6 +523,9 @@ def _clear_djc_global_state(
sys.modules.pop(mod, None) sys.modules.pop(mod, None)
LOADED_MODULES.clear() LOADED_MODULES.clear()
# Clear extensions caches
extensions._route_to_url.clear()
# Force garbage collection, so that any finalizers are run. # Force garbage collection, so that any finalizers are run.
# If garbage collection is skipped, then in some cases the finalizers # If garbage collection is skipped, then in some cases the finalizers
# are run too late, in the context of the next test, causing flaky tests. # are run too late, in the context of the next test, causing flaky tests.

View file

@ -3,10 +3,11 @@ import typing
from typing import Any, Tuple from typing import Any, Tuple
# See https://peps.python.org/pep-0655/#usage-in-python-3-11 # See https://peps.python.org/pep-0655/#usage-in-python-3-11
if sys.version_info >= (3, 11): # NOTE: Pydantic requires typing_extensions.TypedDict until (not incl) 3.12
if sys.version_info >= (3, 12):
from typing import TypedDict from typing import TypedDict
else: else:
from typing_extensions import TypedDict as TypedDict # for Python <3.11 with (Not)Required from typing_extensions import TypedDict as TypedDict
try: try:
from typing import Annotated # type: ignore from typing import Annotated # type: ignore

View file

@ -9,12 +9,19 @@ from django_components import Component, register
class MultFileComponent(Component): class MultFileComponent(Component):
template_file = "multi_file/multi_file.html" template_file = "multi_file/multi_file.html"
def post(self, request, *args, **kwargs) -> HttpResponse: class View:
variable = request.POST.get("variable") def post(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response({"variable": variable}) variable = request.POST.get("variable")
return MultFileComponent.render_to_response(
request=request,
kwargs={"variable": variable},
)
def get(self, request, *args, **kwargs) -> HttpResponse: def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response({"variable": "GET"}) return MultFileComponent.render_to_response(
request=request,
kwargs={"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}

View file

@ -13,12 +13,19 @@ class RelativeFileComponent(Component):
js = "relative_file.js" js = "relative_file.js"
css = "relative_file.css" css = "relative_file.css"
def post(self, request, *args, **kwargs) -> HttpResponse: class View:
variable = request.POST.get("variable") def post(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response({"variable": variable}) variable = request.POST.get("variable")
return RelativeFileComponent.render_to_response(
request=request,
kwargs={"variable": variable},
)
def get(self, request, *args, **kwargs) -> HttpResponse: def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response({"variable": "GET"}) return RelativeFileComponent.render_to_response(
request=request,
kwargs={"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}

View file

@ -15,12 +15,19 @@ class SingleFileComponent(Component):
</form> </form>
""" """
def post(self, request, *args, **kwargs) -> HttpResponse: class View:
variable = request.POST.get("variable") def post(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response({"variable": variable}) variable = request.POST.get("variable")
return SingleFileComponent.render_to_response(
request=request,
kwargs={"variable": variable},
)
def get(self, request, *args, **kwargs) -> HttpResponse: def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response({"variable": "GET"}) return SingleFileComponent.render_to_response(
request=request,
kwargs={"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}

View file

@ -97,7 +97,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list") call_command("components", "ext", "list")
output = out.getvalue() output = out.getvalue()
assert output.strip() == "name \n========\ndefaults\nview" assert output.strip() == "name \n========\ndefaults\nview \nurl"
@djc_test( @djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]}, components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -108,7 +108,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list") call_command("components", "ext", "list")
output = out.getvalue() output = out.getvalue()
assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy" assert output.strip() == "name \n========\ndefaults\nview \nurl \nempty \ndummy"
@djc_test( @djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]}, components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -119,7 +119,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--all") call_command("components", "ext", "list", "--all")
output = out.getvalue() output = out.getvalue()
assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy" assert output.strip() == "name \n========\ndefaults\nview \nurl \nempty \ndummy"
@djc_test( @djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]}, components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -130,7 +130,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--columns", "name") call_command("components", "ext", "list", "--columns", "name")
output = out.getvalue() output = out.getvalue()
assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy" assert output.strip() == "name \n========\ndefaults\nview \nurl \nempty \ndummy"
@djc_test( @djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]}, components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -141,7 +141,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--simple") call_command("components", "ext", "list", "--simple")
output = out.getvalue() output = out.getvalue()
assert output.strip() == "defaults\nview \nempty \ndummy" assert output.strip() == "defaults\nview \nurl \nempty \ndummy"
@djc_test @djc_test
@ -159,7 +159,7 @@ class TestExtensionsRunCommand:
output output
== dedent( == dedent(
f""" f"""
usage: components ext run [-h] {{defaults,view,empty,dummy}} ... usage: components ext run [-h] {{defaults,view,url,empty,dummy}} ...
Run a command added by an extension. Run a command added by an extension.
@ -167,9 +167,10 @@ class TestExtensionsRunCommand:
-h, --help show this help message and exit -h, --help show this help message and exit
subcommands: subcommands:
{{defaults,view,empty,dummy}} {{defaults,view,url,empty,dummy}}
defaults Run commands added by the 'defaults' extension. defaults Run commands added by the 'defaults' extension.
view Run commands added by the 'view' extension. view Run commands added by the 'view' extension.
url Run commands added by the 'url' extension.
empty Run commands added by the 'empty' extension. empty Run commands added by the 'empty' extension.
dummy Run commands added by the 'dummy' extension. dummy Run commands added by the 'dummy' extension.
""" """

View file

@ -739,8 +739,9 @@ class TestComponentRender:
CSRF token: {{ csrf_token|default:"<em>No CSRF token</em>" }} CSRF token: {{ csrf_token|default:"<em>No CSRF token</em>" }}
""" """
def get(self, request): class View:
return self.render_to_response(request=request) def get(self, request):
return Thing.render_to_response(request=request)
client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())]) client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())])
response = client.get("/test_thing/") response = client.get("/test_thing/")
@ -760,8 +761,9 @@ class TestComponentRender:
<p>Existing context: {{ existing_context|default:"<em>No existing context</em>" }}</p> <p>Existing context: {{ existing_context|default:"<em>No existing context</em>" }}</p>
""" """
def get(self, request): class View:
return self.render_to_response(request=request, context={"existing_context": "foo"}) def get(self, request):
return Thing.render_to_response(request=request, context={"existing_context": "foo"})
client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())]) client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())])
response = client.get("/test_thing/") response = client.get("/test_thing/")
@ -782,8 +784,12 @@ class TestComponentRender:
<p>Existing context: {{ existing_context|default:"<em>No existing context</em>" }}</p> <p>Existing context: {{ existing_context|default:"<em>No existing context</em>" }}</p>
""" """
def get(self, request): class View:
return self.render_to_response(request=request, context=Context({"existing_context": "foo"})) def get(self, request):
return Thing.render_to_response(
request=request,
context=Context({"existing_context": "foo"}),
)
client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())]) client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())])
response = client.get("/test_thing/") response = client.get("/test_thing/")

View file

@ -0,0 +1,87 @@
from typing import Any
import pytest
from django.http import HttpRequest
from django.test import Client
from django_components import Component, get_component_url
from django_components.testing import djc_test
from .testutils import setup_test_config
setup_test_config({"autodiscover": False})
@djc_test
class TestComponentUrl:
def test_public_url(self):
did_call_get = False
did_call_post = False
class TestComponent(Component):
template = "Hello"
class Url:
public = True
class View:
def get(self, request: HttpRequest, **kwargs: Any):
nonlocal did_call_get
did_call_get = True
component: Component = self.component # type: ignore[attr-defined]
return component.render_to_response()
def post(self, request: HttpRequest, **kwargs: Any):
nonlocal did_call_post
did_call_post = True
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/url/components/{TestComponent._class_hash}/"
client = Client()
response = client.get(component_url)
assert response.status_code == 200
assert response.content == b"Hello"
assert did_call_get
response = client.post(component_url)
assert response.status_code == 200
assert response.content == b"Hello"
assert did_call_get
def test_non_public_url(self):
did_call_get = False
class TestComponent(Component):
template = "Hi"
class Url:
public = False
class View:
def get(self, request: HttpRequest, **attrs: Any):
nonlocal did_call_get
did_call_get = True
component: Component = self.component # type: ignore[attr-defined]
return component.render_to_response()
# Attempt to get the URL should raise RuntimeError
with pytest.raises(
RuntimeError,
match="Component URL is not available - Component is not public",
):
get_component_url(TestComponent)
# Even calling the URL directly should raise an error
component_url = f"/components/ext/url/components/{TestComponent._class_hash}/"
client = Client()
response = client.get(component_url)
assert response.status_code == 404
assert not did_call_get

View file

@ -22,6 +22,7 @@ from django_components.extension import (
) )
from django_components.extensions.defaults import DefaultsExtension from django_components.extensions.defaults import DefaultsExtension
from django_components.extensions.view import ViewExtension from django_components.extensions.view import ViewExtension
from django_components.extensions.url import UrlExtension
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import setup_test_config from .testutils import setup_test_config
@ -126,10 +127,11 @@ def with_registry(on_created: Callable):
class TestExtension: class TestExtension:
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_extensions_setting(self): def test_extensions_setting(self):
assert len(app_settings.EXTENSIONS) == 3 assert len(app_settings.EXTENSIONS) == 4
assert isinstance(app_settings.EXTENSIONS[0], DefaultsExtension) assert isinstance(app_settings.EXTENSIONS[0], DefaultsExtension)
assert isinstance(app_settings.EXTENSIONS[1], ViewExtension) assert isinstance(app_settings.EXTENSIONS[1], ViewExtension)
assert isinstance(app_settings.EXTENSIONS[2], DummyExtension) assert isinstance(app_settings.EXTENSIONS[2], UrlExtension)
assert isinstance(app_settings.EXTENSIONS[3], DummyExtension)
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_access_component_from_extension(self): def test_access_component_from_extension(self):
@ -152,7 +154,7 @@ class TestExtension:
class TestExtensionHooks: class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_component_class_lifecycle_hooks(self): def test_component_class_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[2]) extension = cast(DummyExtension, app_settings.EXTENSIONS[3])
assert len(extension.calls["on_component_class_created"]) == 0 assert len(extension.calls["on_component_class_created"]) == 0
assert len(extension.calls["on_component_class_deleted"]) == 0 assert len(extension.calls["on_component_class_deleted"]) == 0
@ -184,7 +186,7 @@ class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_registry_lifecycle_hooks(self): def test_registry_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[2]) extension = cast(DummyExtension, app_settings.EXTENSIONS[3])
assert len(extension.calls["on_registry_created"]) == 0 assert len(extension.calls["on_registry_created"]) == 0
assert len(extension.calls["on_registry_deleted"]) == 0 assert len(extension.calls["on_registry_deleted"]) == 0
@ -221,7 +223,7 @@ class TestExtensionHooks:
return {"name": name} return {"name": name}
registry.register("test_comp", TestComponent) registry.register("test_comp", TestComponent)
extension = cast(DummyExtension, app_settings.EXTENSIONS[2]) extension = cast(DummyExtension, app_settings.EXTENSIONS[3])
# Verify on_component_registered was called # Verify on_component_registered was called
assert len(extension.calls["on_component_registered"]) == 1 assert len(extension.calls["on_component_registered"]) == 1
@ -259,7 +261,7 @@ class TestExtensionHooks:
test_slots = {"content": "Some content"} test_slots = {"content": "Some content"}
TestComponent.render(context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots) TestComponent.render(context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots)
extension = cast(DummyExtension, app_settings.EXTENSIONS[2]) extension = cast(DummyExtension, app_settings.EXTENSIONS[3])
# Verify on_component_input was called with correct args # Verify on_component_input was called with correct args
assert len(extension.calls["on_component_input"]) == 1 assert len(extension.calls["on_component_input"]) == 1