mirror of
https://github.com/django-components/django-components.git
synced 2025-08-04 14:28:18 +00:00
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:
parent
3555411f1e
commit
a49f5e51dd
37 changed files with 987 additions and 314 deletions
53
CHANGELOG.md
53
CHANGELOG.md
|
@ -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))
|
||||
|
|
16
README.md
16
README.md
|
@ -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 {
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
128
docs/concepts/fundamentals/component_views_urls.md
Normal file
128
docs/concepts/fundamentals/component_views_urls.md
Normal 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.
|
|
@ -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)
|
||||
```
|
|
@ -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).
|
||||
|
|
|
@ -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
|
||||
```
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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", ""),
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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", ""),
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"] = []
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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.
|
||||
|
|
181
src/django_components/extensions/url.py
Normal file
181
src/django_components/extensions/url.py
Normal 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])
|
|
@ -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()`.
|
||||
|
||||
|
|
|
@ -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 `[`
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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/")
|
||||
|
|
87
tests/test_component_url.py
Normal file
87
tests/test_component_url.py
Normal 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
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue