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
## 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
#### 🚨📢 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 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):
template_file = "calendar.html"
def get(self, request, *args, **kwargs):
page = request.GET.get("page", 1)
return self.render_to_response(
kwargs={
"page": page,
}
)
class View:
def get(self, request, *args, **kwargs):
page = request.GET.get("page", 1)
return Calendar.render_to_response(
request=request,
kwargs={
"page": page,
},
)
def get_context_data(self, page):
return {

View file

@ -65,10 +65,10 @@ to configure the extensions on a per-component basis.
### 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.
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
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
class MyPage(Component):
def get(self, request):
return self.render_to_response()
Class View:
def get(self, request):
return self.component.render_to_response(request=request)
template = """
{% load component_tags %}
@ -138,11 +139,13 @@ class MyPage(Component):
```djc_py title="[root]/components/demo.py"
class Frag(Component):
def get(self, request):
return self.render_to_response(
# IMPORTANT: Don't forget `type="fragment"`
type="fragment",
)
class View:
def get(self, request):
return self.component.render_to_response(
request=request,
# IMPORTANT: Don't forget `type="fragment"`
type="fragment",
)
template = """
<div class="frag">
@ -184,8 +187,9 @@ from django_components import Component, types
# HTML into which a fragment will be loaded using AlpineJS
class MyPage(Component):
def get(self, request):
return self.render_to_response()
class View:
def get(self, request):
return self.component.render_to_response(request=request)
template = """
{% load component_tags %}
@ -222,11 +226,13 @@ class MyPage(Component):
```djc_py title="[root]/components/demo.py"
class Frag(Component):
def get(self, request):
# IMPORTANT: Don't forget `type="fragment"`
return self.render_to_response(
type="fragment",
)
class View:
def get(self, request):
return self.component.render_to_response(
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
# 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
class MyPage(Component):
def get(self, request):
return self.render_to_response()
class View:
def get(self, request):
return self.component.render_to_response(request=request)
template = """
{% load component_tags %}
@ -318,11 +325,13 @@ class MyPage(Component):
```djc_py title="[root]/components/demo.py"
class Frag(Component):
def get(self, request):
return self.render_to_response(
# IMPORTANT: Don't forget `type="fragment"`
type="fragment",
)
class View:
def get(self, request):
return self.component.render_to_response(
request=request,
# IMPORTANT: Don't forget `type="fragment"`
type="fragment",
)
template = """
<div class="frag">

View file

@ -10,6 +10,6 @@ nav:
- HTML attributes: html_attributes.md
- Defining HTML / JS / CSS files: defining_js_css_html_files.md
- Autodiscovery: autodiscovery.md
- Components as views: components_as_views.md
- Component views and URLs: component_views_urls.md
- HTTP Request: http_request.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>
"""
```
### 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
[`{% component %}`](../../reference/template_tags#component) tag:
@ -74,7 +74,7 @@ template = Template("""
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.
@ -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).
### 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.
@ -159,3 +159,133 @@ def my_view(request):
class SimpleComponent(Component):
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):
template_file = "calendar.html"
def get(self, request, *args, **kwargs):
page = request.GET.get("page", 1)
return self.render_to_response(
kwargs={
"page": page,
}
)
class View:
def get(self, request, *args, **kwargs):
page = request.GET.get("page", 1)
return self.component.render_to_response(
request=request,
kwargs={
"page": page,
}
)
def get_context_data(self, page):
return {

View file

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

View file

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

View file

@ -107,6 +107,25 @@ name | type | description
`name` | `str` | The name the component was registered under
`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
options:
heading_level: 3

View file

@ -18,12 +18,14 @@ class Calendar(Component):
"date": date,
}
def get(self, request, *args, **kwargs):
return self.render_to_response(
kwargs={
"date": request.GET.get("date", ""),
},
)
class View:
def get(self, request, *args, **kwargs):
return Calendar.render_to_response(
request=request,
kwargs={
"date": request.GET.get("date", ""),
},
)
@register("calendar_relative")
@ -43,9 +45,11 @@ class CalendarRelative(Component):
"date": date,
}
def get(self, request, *args, **kwargs):
return self.render_to_response(
kwargs={
"date": request.GET.get("date", ""),
},
)
class View:
def get(self, request, *args, **kwargs):
return CalendarRelative.render_to_response(
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
class FragmentBaseJs(Component):
def get(self, request):
return self.render_to_response()
class View:
def get(self, request):
return FragmentBaseJs.render_to_response(request=request)
template: types.django_html = """
{% load component_tags %}
@ -39,8 +40,9 @@ class FragmentBaseJs(Component):
# HTML into which a fragment will be loaded using AlpineJs
class FragmentBaseAlpine(Component):
def get(self, request):
return self.render_to_response()
class View:
def get(self, request):
return FragmentBaseAlpine.render_to_response(request=request)
template: types.django_html = """
{% load component_tags %}
@ -76,8 +78,9 @@ class FragmentBaseAlpine(Component):
# HTML into which a fragment will be loaded using HTMX
class FragmentBaseHtmx(Component):
def get(self, request):
return self.render_to_response()
class View:
def get(self, request):
return FragmentBaseHtmx.render_to_response(request=request)
template: types.django_html = """
{% load component_tags %}
@ -102,8 +105,9 @@ class FragmentBaseHtmx(Component):
# Fragment where the JS and CSS are defined on the Component
class FragJs(Component):
def get(self, request):
return self.render_to_response(type="fragment")
class View:
def get(self, request):
return FragJs.render_to_response(request=request, type="fragment")
template: types.django_html = """
<div class="frag">
@ -125,8 +129,9 @@ class FragJs(Component):
# Fragment that defines an AlpineJS component
class FragAlpine(Component):
def get(self, request):
return self.render_to_response(type="fragment")
class View:
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
# 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")
class Greeting(Component):
def get(self, request, *args, **kwargs):
slots = {"message": "Hello, world!"}
return self.render_to_response(
slots=slots,
kwargs={
"name": request.GET.get("name", ""),
},
)
class View:
def get(self, request, *args, **kwargs):
slots = {"message": "Hello, world!"}
return Greeting.render_to_response(
request=request,
slots=slots,
kwargs={
"name": request.GET.get("name", ""),
},
)
def get_context_data(self, name, *args, **kwargs) -> Dict[str, Any]:
return {"name": name}

View file

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

View file

@ -1,3 +1,4 @@
import time
from typing import Any, Dict
from django_components import Component, register, types
@ -5,17 +6,18 @@ from django_components import Component, register, types
@register("recursive")
class Recursive(Component):
def get(self, request):
import time
time_before = time.time()
output = self.render_to_response(
kwargs={
"depth": 0,
},
)
time_after = time.time()
print("TIME: ", time_after - time_before)
return output
class View:
def get(self, request):
time_before = time.time()
output = Recursive.render_to_response(
request=request,
kwargs={
"depth": 0,
},
)
time_after = time.time()
print("TIME: ", time_after - time_before)
return output
def get_context_data(self, depth: int = 0) -> Dict[str, Any]:
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.view import ComponentView
from django_components.extensions.url import ComponentUrl, get_component_url
from django_components.library import TagProtectedError
from django_components.node import BaseNode, template_tag
from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult
@ -86,6 +87,7 @@ __all__ = [
"ComponentRegistry",
"ComponentVars",
"ComponentView",
"ComponentUrl",
"component_formatter",
"component_shorthand_formatter",
"ContextBehavior",
@ -96,6 +98,7 @@ __all__ = [
"format_attributes",
"get_component_dirs",
"get_component_files",
"get_component_url",
"import_libraries",
"merge_attributes",
"NotRegistered",

View file

@ -751,9 +751,10 @@ class InternalSettings:
# Prepend built-in extensions
from django_components.extensions.defaults import DefaultsExtension
from django_components.extensions.url import UrlExtension
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.
extension_instances: List["ComponentExtension"] = []

View file

@ -1,10 +1,10 @@
from argparse import ArgumentParser
from typing import Any, List, Optional, Type
from typing import Any, Iterable, List, Optional, Type, Union
import django
import django.urls as django_urls
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 (
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.
@ -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:
# The handler is equivalent to `view` function in Django
if route.handler is not None:

View file

@ -60,7 +60,9 @@ from django_components.extension import (
OnComponentInputContext,
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.perfutil.component import ComponentRenderer, component_context_cache, component_post_render
from django_components.perfutil.provide import register_provide_reference, unregister_provide_reference
@ -570,6 +572,19 @@ class Component(
"""
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
# #####################################
@ -899,6 +914,8 @@ class Component(
def as_view(cls, **initkwargs: Any) -> ViewFn:
"""
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

View file

@ -1,9 +1,9 @@
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
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.compat.django import routes_to_django
@ -504,8 +504,12 @@ class ExtensionManager:
# Internal
###########################
_initialized = False
_events: List[Tuple[str, Any]] = []
def __init__(self) -> None:
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
def extensions(self) -> List[ComponentExtension]:
@ -636,9 +640,14 @@ class ExtensionManager:
# TODO_V3 - Django-specific logic - replace with hook
urls: List[URLResolver] = []
for extension in self.extensions:
ext_urls = routes_to_django(extension.urls)
ext_url_path = django.urls.path(f"{extension.name}/", django.urls.include(ext_urls))
urls.append(ext_url_path)
# NOTE: The empty list is a placeholder for the URLs that will be added later
curr_ext_url_resolver = django.urls.path(f"{extension.name}/", django.urls.include([]))
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
# or an import string thereof.
@ -647,8 +656,8 @@ class ExtensionManager:
# So we set both:
# - `urlconf_name` to update the source of truth
# - `url_patterns` to override the caching
ext_url_resolver.urlconf_name = urls
ext_url_resolver.url_patterns = urls
extensions_url_resolver.urlconf_name = urls
extensions_url_resolver.url_patterns = urls
# Rebuild URL resolver cache to be able to resolve the new routes by their names.
urlconf = get_urlconf()
@ -668,6 +677,56 @@ class ExtensionManager:
return command
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
#############################
@ -738,4 +797,4 @@ urlpatterns = [
#
# 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.
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
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):
"""
This extension adds a nested `Defaults` class to each `Component`.
@ -141,6 +160,7 @@ class DefaultsExtension(ComponentExtension):
"""
name = "defaults"
ExtensionClass = ComponentDefaults
# Preprocess the `Component.Defaults` class, if given, so we don't have to do it
# 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
"""
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`.
**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
@ -33,42 +44,40 @@ class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
# 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
# `return self.component.render_to_response()` or similar.
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "get")(request, *args, **kwargs)
return getattr(self.component, "get")(request, *args, **kwargs)
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "post")(request, *args, **kwargs)
return getattr(self.component, "post")(request, *args, **kwargs)
def put(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "put")(request, *args, **kwargs)
return getattr(self.component, "put")(request, *args, **kwargs)
def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "patch")(request, *args, **kwargs)
return getattr(self.component, "patch")(request, *args, **kwargs)
def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "delete")(request, *args, **kwargs)
return getattr(self.component, "delete")(request, *args, **kwargs)
def head(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "head")(request, *args, **kwargs)
return getattr(self.component, "head")(request, *args, **kwargs)
def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "options")(request, *args, **kwargs)
return getattr(self.component, "options")(request, *args, **kwargs)
def trace(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "trace")(request, *args, **kwargs)
return getattr(self.component, "trace")(request, *args, **kwargs)
class ViewExtension(ComponentExtension):
"""
This extension adds a nested `View` class to each `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()`.

View file

@ -112,10 +112,11 @@ def is_nonempty_str(txt: Optional[str]) -> bool:
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:
full_name = get_import_path(comp_cls)
comp_cls_hash = md5(full_name.encode()).hexdigest()[0:6]
return comp_cls.__name__ + "_" + comp_cls_hash
name_hash = md5(full_name.encode()).hexdigest()[0:6]
return comp_cls.__name__ + "_" + name_hash
# String is a glob if it contains at least one of `?`, `*`, or `[`

View file

@ -1,5 +1,5 @@
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
@ -62,10 +62,14 @@ class URLRoute:
path: str
handler: Optional[URLRouteHandler] = None
children: List["URLRoute"] = field(default_factory=list)
children: Iterable["URLRoute"] = field(default_factory=list)
name: Optional[str] = None
extra: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.handler is not None and self.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)
LOADED_MODULES.clear()
# Clear extensions caches
extensions._route_to_url.clear()
# Force garbage collection, so that any finalizers are run.
# 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.

View file

@ -3,10 +3,11 @@ import typing
from typing import Any, Tuple
# 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
else:
from typing_extensions import TypedDict as TypedDict # for Python <3.11 with (Not)Required
from typing_extensions import TypedDict as TypedDict
try:
from typing import Annotated # type: ignore

View file

@ -9,12 +9,19 @@ from django_components import Component, register
class MultFileComponent(Component):
template_file = "multi_file/multi_file.html"
def post(self, request, *args, **kwargs) -> HttpResponse:
variable = request.POST.get("variable")
return self.render_to_response({"variable": variable})
class View:
def post(self, request, *args, **kwargs) -> HttpResponse:
variable = request.POST.get("variable")
return MultFileComponent.render_to_response(
request=request,
kwargs={"variable": variable},
)
def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response({"variable": "GET"})
def get(self, request, *args, **kwargs) -> HttpResponse:
return MultFileComponent.render_to_response(
request=request,
kwargs={"variable": "GET"},
)
def get_context_data(self, variable, *args, **kwargs) -> Dict[str, Any]:
return {"variable": variable}

View file

@ -13,12 +13,19 @@ class RelativeFileComponent(Component):
js = "relative_file.js"
css = "relative_file.css"
def post(self, request, *args, **kwargs) -> HttpResponse:
variable = request.POST.get("variable")
return self.render_to_response({"variable": variable})
class View:
def post(self, request, *args, **kwargs) -> HttpResponse:
variable = request.POST.get("variable")
return RelativeFileComponent.render_to_response(
request=request,
kwargs={"variable": variable},
)
def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response({"variable": "GET"})
def get(self, request, *args, **kwargs) -> HttpResponse:
return RelativeFileComponent.render_to_response(
request=request,
kwargs={"variable": "GET"},
)
def get_context_data(self, variable, *args, **kwargs) -> Dict[str, Any]:
return {"variable": variable}

View file

@ -15,12 +15,19 @@ class SingleFileComponent(Component):
</form>
"""
def post(self, request, *args, **kwargs) -> HttpResponse:
variable = request.POST.get("variable")
return self.render_to_response({"variable": variable})
class View:
def post(self, request, *args, **kwargs) -> HttpResponse:
variable = request.POST.get("variable")
return SingleFileComponent.render_to_response(
request=request,
kwargs={"variable": variable},
)
def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response({"variable": "GET"})
def get(self, request, *args, **kwargs) -> HttpResponse:
return SingleFileComponent.render_to_response(
request=request,
kwargs={"variable": "GET"},
)
def get_context_data(self, variable, *args, **kwargs) -> Dict[str, Any]:
return {"variable": variable}

View file

@ -97,7 +97,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list")
output = out.getvalue()
assert output.strip() == "name \n========\ndefaults\nview"
assert output.strip() == "name \n========\ndefaults\nview \nurl"
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -108,7 +108,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list")
output = out.getvalue()
assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy"
assert output.strip() == "name \n========\ndefaults\nview \nurl \nempty \ndummy"
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -119,7 +119,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--all")
output = out.getvalue()
assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy"
assert output.strip() == "name \n========\ndefaults\nview \nurl \nempty \ndummy"
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -130,7 +130,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--columns", "name")
output = out.getvalue()
assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy"
assert output.strip() == "name \n========\ndefaults\nview \nurl \nempty \ndummy"
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -141,7 +141,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--simple")
output = out.getvalue()
assert output.strip() == "defaults\nview \nempty \ndummy"
assert output.strip() == "defaults\nview \nurl \nempty \ndummy"
@djc_test
@ -159,7 +159,7 @@ class TestExtensionsRunCommand:
output
== dedent(
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.
@ -167,9 +167,10 @@ class TestExtensionsRunCommand:
-h, --help show this help message and exit
subcommands:
{{defaults,view,empty,dummy}}
{{defaults,view,url,empty,dummy}}
defaults Run commands added by the 'defaults' 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.
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>" }}
"""
def get(self, request):
return self.render_to_response(request=request)
class View:
def get(self, request):
return Thing.render_to_response(request=request)
client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())])
response = client.get("/test_thing/")
@ -760,8 +761,9 @@ class TestComponentRender:
<p>Existing context: {{ existing_context|default:"<em>No existing context</em>" }}</p>
"""
def get(self, request):
return self.render_to_response(request=request, context={"existing_context": "foo"})
class View:
def get(self, request):
return Thing.render_to_response(request=request, context={"existing_context": "foo"})
client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())])
response = client.get("/test_thing/")
@ -782,8 +784,12 @@ class TestComponentRender:
<p>Existing context: {{ existing_context|default:"<em>No existing context</em>" }}</p>
"""
def get(self, request):
return self.render_to_response(request=request, context=Context({"existing_context": "foo"}))
class View:
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())])
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.view import ViewExtension
from django_components.extensions.url import UrlExtension
from django_components.testing import djc_test
from .testutils import setup_test_config
@ -126,10 +127,11 @@ def with_registry(on_created: Callable):
class TestExtension:
@djc_test(components_settings={"extensions": [DummyExtension]})
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[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]})
def test_access_component_from_extension(self):
@ -152,7 +154,7 @@ class TestExtension:
class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]})
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_deleted"]) == 0
@ -184,7 +186,7 @@ class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]})
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_deleted"]) == 0
@ -221,7 +223,7 @@ class TestExtensionHooks:
return {"name": name}
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
assert len(extension.calls["on_component_registered"]) == 1
@ -259,7 +261,7 @@ class TestExtensionHooks:
test_slots = {"content": "Some content"}
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
assert len(extension.calls["on_component_input"]) == 1