docs: Add "scenarios" code examples (#1445)
Some checks are pending
Docs - build & deploy / docs (push) Waiting to run
Run tests / build (ubuntu-latest, 3.10) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.11) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.12) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.13) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.8) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.9) (push) Waiting to run
Run tests / build (windows-latest, 3.10) (push) Waiting to run
Run tests / build (windows-latest, 3.11) (push) Waiting to run
Run tests / build (windows-latest, 3.12) (push) Waiting to run
Run tests / build (windows-latest, 3.13) (push) Waiting to run
Run tests / build (windows-latest, 3.8) (push) Waiting to run
Run tests / build (windows-latest, 3.9) (push) Waiting to run
Run tests / test_docs (3.13) (push) Waiting to run
Run tests / test_sampleproject (3.13) (push) Waiting to run

This commit is contained in:
Juro Oravec 2025-10-08 00:17:31 +02:00 committed by GitHub
parent 71c489df8e
commit 49afdb49d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1550 additions and 260 deletions

View file

@ -242,6 +242,8 @@ This allows us to keep the examples in one place, and define, test, and document
To see all available examples, go to `http://localhost:8000/examples/`. To see all available examples, go to `http://localhost:8000/examples/`.
The examples index page displays a short description for each example. These values are taken from a top-level `DESCRIPTION` string variable in the example's `component.py` file.
**Tests** - Use the file format `test_example_<example_name>.py` to define tests for the example. These tests are picked up when you run pytest. **Tests** - Use the file format `test_example_<example_name>.py` to define tests for the example. These tests are picked up when you run pytest.
#### Adding examples #### Adding examples

View file

@ -1,6 +1,13 @@
# `.nav.yml` is provided by https://lukasgeiter.github.io/mkdocs-awesome-nav # `.nav.yml` is provided by https://lukasgeiter.github.io/mkdocs-awesome-nav
nav: nav:
- index.md - index.md
- Scenarios:
- Form Submission: ./form_submission
- HTML fragments: ./fragments
- Error handling: ./error_fallback
- Recursion: ./recursion
- A/B testing: ./ab_testing
- Analytics: ./analytics
- Components: - Components:
- Form: ./form - FormGrid: ./form_grid
- Tabs (AlpineJS): ./tabs - Tabs (AlpineJS): ./tabs

View file

@ -0,0 +1,83 @@
# A/B Testing
A/B testing, phased rollouts, or other advanced use cases can be made easy by dynamically rendering different versions of a component.
Use the [`Component.on_render()`](../../reference/api/#django_components.Component.on_render) hook, to decide which version to render based on a component parameter (or a random choice).
![A/B Testing](./images/ab_testing.png)
## How it works
[`Component.on_render()`](../../reference/api/#django_components.Component.on_render) is called when the component is being rendered. This method can completely override the rendering process, so we can use it to render another component in its place.
```py
class OfferCard(Component):
...
def on_render(self, context, template):
# Pass all kwargs to the child component
kwargs_for_child = self.kwargs._asdict()
use_new = kwargs_for_child.pop("use_new_version")
# If version not specified, choose randomly
if use_new is None:
use_new = random.choice([True, False])
if use_new:
return OfferCardNew.render(context=context, kwargs=kwargs_for_child)
else:
return OfferCardOld.render(context=context, kwargs=kwargs_for_child)
```
In the example we render 3 versions of the `OfferCard` component:
- Variant that always shows an "old" version with `use_new_version=False`
- Variant that always shows a "new" version with `use_new_version=True`.
- Variant that randomly shows one or the other, omitting the `use_new_version` flag.
All extra parameters are passed through to the underlying components.
**Variant A (Old)**
```django
{% component "offer_card" use_new_version=False savings_percent=10 / %}
```
**Variant B (New)**
```django
{% component "offer_card" use_new_version=True savings_percent=25 / %}
```
**Variant C (Random)**
```django
{% component "offer_card" savings_percent=15 / %}
```
## Definition
```djc_py
--8<-- "docs/examples/ab_testing/component.py"
```
## Example
To see the component in action, you can set up a view and a URL pattern as shown below.
### `views.py`
```djc_py
--8<-- "docs/examples/ab_testing/page.py"
```
### `urls.py`
```python
from django.urls import path
from examples.pages.ab_testing import ABTestingPage
urlpatterns = [
path("examples/ab_testing", ABTestingPage.as_view(), name="ab_testing"),
]
```

View file

@ -0,0 +1,64 @@
# ruff: noqa: S311
import random
from typing import NamedTuple, Optional
from django_components import Component, register, types
DESCRIPTION = "Dynamically render different component versions. Use for A/B testing, phased rollouts, etc."
@register("offer_card_old")
class OfferCardOld(Component):
class Kwargs(NamedTuple):
savings_percent: int
def get_template_data(self, args, kwargs, slots, context):
return {
"savings_percent": kwargs.savings_percent,
}
template: types.django_html = """
<div class="p-4 border rounded-lg bg-gray-100">
<h3 class="text-lg font-bold text-gray-800">
Special Offer!
</h3>
<p class="text-gray-600">
Get {{ savings_percent }}% off on your next purchase.
</p>
</div>
"""
@register("offer_card_new")
class OfferCardNew(OfferCardOld):
template: types.django_html = """
<div class="p-6 border-2 border-dashed border-blue-500 rounded-lg bg-blue-50 text-center">
<h3 class="text-xl font-extrabold text-blue-800 animate-pulse">
FLASH SALE!
</h3>
<p class="text-blue-600">
Exclusive Offer: {{ savings_percent }}% off everything!
</p>
</div>
"""
@register("offer_card")
class OfferCard(Component):
class Kwargs(NamedTuple):
savings_percent: int
use_new_version: Optional[bool] = None
def on_render(self, context, template):
# Pass all kwargs to the child component
kwargs_for_child = self.kwargs._asdict()
use_new = kwargs_for_child.pop("use_new_version")
# If version not specified, choose randomly
if use_new is None:
use_new = random.choice([True, False])
if use_new:
return OfferCardNew.render(context=context, kwargs=kwargs_for_child)
else:
return OfferCardOld.render(context=context, kwargs=kwargs_for_child)

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

View file

@ -0,0 +1,63 @@
from django.http import HttpRequest, HttpResponse
from django_components import Component, types
class ABTestingPage(Component):
class Media:
js = ("https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,container-queries",)
template: types.django_html = """
{% load component_tags %}
<html>
<head>
<title>A/B Testing Example</title>
</head>
<body class="bg-gray-100 p-8">
<div class="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">
A/B Testing Components
</h1>
<p class="text-gray-600 mb-6">
This example shows how a single component can render different versions
based on a parameter (or a random choice), perfect for A/B testing.
</p>
<div class="mb-8">
<h2 class="text-xl font-semibold mb-2">
Variant A (Old Offer)
</h2>
<p class="text-sm text-gray-500 mb-2">
Rendered with <code>use_new_version=False</code>
</p>
{% component "offer_card" use_new_version=False savings_percent=10 / %}
</div>
<div>
<h2 class="text-xl font-semibold mb-2">
Variant B (New Offer)
</h2>
<p class="text-sm text-gray-500 mb-2">
Rendered with <code>use_new_version=True</code>
</p>
{% component "offer_card" use_new_version=True savings_percent=25 / %}
</div>
<div class="mt-8">
<h2 class="text-xl font-semibold mb-2">
Variant C (Random)
</h2>
<p class="text-sm text-gray-500 mb-2">
Rendered without <code>use_new_version</code>.
Reload the page to see a different version.
</p>
{% component "offer_card" savings_percent=15 / %}
</div>
</div>
</body>
</html>
"""
class View:
def get(self, request: HttpRequest) -> HttpResponse:
return ABTestingPage.render_to_response(request=request)

View file

@ -0,0 +1,58 @@
import pytest
from django.template import Context, Template
from django_components import registry, types
from django_components.testing import djc_test
def _import_components():
from docs.examples.ab_testing.component import OfferCard, OfferCardNew, OfferCardOld # noqa: PLC0415
registry.register("offer_card", OfferCard)
registry.register("offer_card_old", OfferCardOld)
registry.register("offer_card_new", OfferCardNew)
@pytest.mark.django_db
@djc_test
class TestABTesting:
def test_renders_old_version(self):
_import_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "offer_card" use_new_version=False savings_percent=10 / %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert "Special Offer!" in rendered
assert "10% off" in rendered
assert "FLASH SALE!" not in rendered
def test_renders_new_version(self):
_import_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "offer_card" use_new_version=True savings_percent=25 / %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert "FLASH SALE!" in rendered
assert "25% off" in rendered
assert "Special Offer!" not in rendered
def test_renders_random_version(self):
_import_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "offer_card" savings_percent=15 / %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
is_new = "FLASH SALE!" in rendered and "15% off" in rendered
is_old = "Special Offer!" in rendered and "15% off" in rendered
# Check that one and only one of the versions is rendered
assert (is_new and not is_old) or (is_old and not is_new)

View file

@ -0,0 +1,84 @@
# Component Analytics
Use the [`Component.on_render_after()`](../../reference/api#django_components.Component.on_render_after) hook to track component analytics, such as capturing errors for a service like Sentry or other monitoring.
![Analytics example](./images/analytics.png)
## Error tracking components
You can create a wrapper component that uses the [`Component.on_render_after()`](../../reference/api#django_components.Component.on_render_after) hook to inspect the `error` object. If an error occurred during the rendering of its children, you can capture and send it to your monitoring service.
```django
{% component "sentry_error_tracker" %}
{% component "api_widget" simulate_error=True / %}
{% endcomponent %}
```
The same hook can be used to track both successes and failures, allowing you to monitor the reliability of a component.
```django
{% component "success_rate_tracker" %}
{% component "api_widget" simulate_error=False / %}
{% endcomponent %}
```
## Error tracking extension
Capturing analytics through components is simple, but limiting:
- You can't access metadata nor state of the component that errored
- Component will capture at most one error
- You must remember to call the component that captures the analytics
Instead, you can define the analytics logic as an [extension](../../concepts/advanced/extensions.md). This will allow us to capture all errors, without polluting the UI.
To do that, we can use the [`on_component_rendered()`](../../reference/extension_hooks/#django_components.extension.ComponentExtension.on_component_rendered) hook to capture all errors.
```python
from django_components.extension import ComponentExtension, OnComponentRenderedContext
class ErrorTrackingExtension(ComponentExtension):
name = "sentry_error_tracker"
def on_component_rendered(self, ctx: OnComponentRenderedContext):
if ctx.error:
print(f"SENTRY: Captured error in component {ctx.component.name}: {ctx.error}")
```
Don't forget to register the extension:
```python
COMPONENTS = {
"extensions": [
ErrorTrackingExtension,
],
}
```
## Definition
```djc_py
--8<-- "docs/examples/analytics/component.py"
```
## Example
To see the component in action, you can set up a view and a URL pattern as shown below.
### `views.py`
```djc_py
--8<-- "docs/examples/analytics/page.py"
```
### `urls.py`
```python
from django.urls import path
from examples.pages.analytics import AnalyticsPage
urlpatterns = [
path("examples/analytics", AnalyticsPage.as_view(), name="analytics"),
]
```

View file

@ -0,0 +1,64 @@
from typing import Dict, List, NamedTuple
from django_components import Component, register, types
DESCRIPTION = "Track component errors or success rates to send them to Sentry or other services."
# A mock analytics service
analytics_events: List[Dict] = []
error_rate = {
"error": 0,
"success": 0,
}
@register("api_widget")
class ApiWidget(Component):
class Kwargs(NamedTuple):
simulate_error: bool = False
def get_template_data(self, args, kwargs: Kwargs, slots, context):
if kwargs.simulate_error:
raise ConnectionError("API call failed")
return {"data": "Mock API response data"}
template: types.django_html = """
<div class="p-4 border rounded-lg bg-gray-50">
<h4 class="font-bold text-gray-800">API Widget</h4>
<p class="text-gray-600">Data: {{ data }}</p>
</div>
"""
@register("sentry_error_tracker")
class SentryErrorTracker(Component):
def on_render_after(self, context, template, result, error):
if error:
event = {
"type": "error",
"component": self.registered_name,
"error": error,
}
analytics_events.append(event)
print(f"SENTRY: Captured error in component {self.registered_name}: {error}")
template: types.django_html = """
{% load component_tags %}
{% slot "default" / %}
"""
@register("success_rate_tracker")
class SuccessRateTracker(Component):
def on_render_after(self, context, template, result, error):
# Track error
if error:
error_rate["error"] += 1
# Track success
else:
error_rate["success"] += 1
template: types.django_html = """
{% load component_tags %}
{% slot "default" / %}
"""

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

View file

@ -0,0 +1,117 @@
from django.http import HttpRequest, HttpResponse
from django_components import Component, register, types
from .component import analytics_events, error_rate
class AnalyticsPage(Component):
class Media:
js = ("https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,container-queries",)
template: types.django_html = """
{% load component_tags %}
<html>
<head>
<title>Analytics Example</title>
</head>
<body class="bg-gray-100 p-8">
<div class="max-w-4xl mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">
Component Analytics
</h1>
<p class="text-gray-600 mb-6">
Track component errors or success rates to send them
to Sentry or other services.
</p>
{# NOTE: Intentionally hidden so we focus on the events tracking #}
<div style="display: none;">
{% component "template_with_errors" / %}
</div>
{% component "captured_events" / %}
</div>
</body>
</html>
"""
class View:
def get(self, request: HttpRequest) -> HttpResponse:
# Clear events on each page load
analytics_events.clear()
error_rate["error"] = 0
error_rate["success"] = 0
return AnalyticsPage.render_to_response(request=request)
@register("template_with_errors")
class TemplateWithErrors(Component):
template: types.django_html = """
<div class="mb-8">
<h2 class="text-xl font-semibold mb-2">
Sentry Error Tracking
</h2>
<p class="text-sm text-gray-500 mb-2">
This component only logs events when an error occurs.
</p>
{% component "error_fallback" %}
{% component "sentry_error_tracker" %}
{% component "api_widget" simulate_error=True / %}
{% endcomponent %}
{% endcomponent %}
{% component "sentry_error_tracker" %}
{% component "api_widget" simulate_error=False / %}
{% endcomponent %}
</div>
<div>
<h2 class="text-xl font-semibold mb-2">
Success Rate Analytics
</h2>
<p class="text-sm text-gray-500 mb-2">
This component logs both successful and failed renders.
</p>
{% component "error_fallback" %}
{% component "success_rate_tracker" %}
{% component "api_widget" simulate_error=True / %}
{% endcomponent %}
{% endcomponent %}
{% component "success_rate_tracker" %}
{% component "api_widget" simulate_error=False / %}
{% endcomponent %}
</div>
"""
# NOTE: Since this runs after `template_with_errors`,
# the `analytics_events` will be populated.
@register("captured_events")
class CapturedEvents(Component):
def get_template_data(self, args, kwargs, slots, context):
return {"events": analytics_events, "error_rate": error_rate}
template: types.django_html = """
<div class="mt-8 p-4 border rounded-lg bg-gray-50">
<h3 class="text-lg font-semibold mb-2">
Captured Analytics Events
</h3>
<pre class="text-sm text-gray-700 whitespace-pre-wrap">
{% for event in events %}
{{ event }}
{% endfor %}
</pre>
</div>
<div class="mt-8 p-4 border rounded-lg bg-gray-50">
<h3 class="text-lg font-semibold mb-2">
Error Rate
</h3>
<pre class="text-sm text-gray-700 whitespace-pre-wrap">
{{ error_rate }}
</pre>
<p class="text-sm text-gray-500">
{{ error_rate.error }} errors out of {{ error_rate.success }} calls.
</p>
</div>
"""

View file

@ -0,0 +1,71 @@
import pytest
from django.template import Context, Template
from django_components import registry, types
from django_components.testing import djc_test
# Imported lazily, so we import components only once settings are set
def _create_components():
from docs.examples.analytics.component import ( # noqa: PLC0415
ApiWidget,
SentryErrorTracker,
SuccessRateTracker,
analytics_events,
error_rate,
)
registry.register("api_widget", ApiWidget)
registry.register("sentry_error_tracker", SentryErrorTracker)
registry.register("success_rate_tracker", SuccessRateTracker)
analytics_events.clear()
error_rate["error"] = 0
error_rate["success"] = 0
return analytics_events, error_rate
@pytest.mark.django_db
@djc_test
class TestAnalytics:
def test_sentry_tracker_logs_only_errors(self):
analytics_events, error_rate = _create_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "error_fallback" %}
{% component "sentry_error_tracker" %}
{% component "api_widget" simulate_error=True / %}
{% endcomponent %}
{% endcomponent %}
{% component "sentry_error_tracker" %}
{% component "api_widget" simulate_error=False / %}
{% endcomponent %}
"""
template = Template(template_str)
template.render(Context({}))
assert error_rate["error"] == 0
assert error_rate["success"] == 0
assert len(analytics_events) == 1
assert analytics_events[0]["type"] == "error"
assert analytics_events[0]["component"] == "sentry_error_tracker"
assert analytics_events[0]["error"] is not None
def test_success_rate_tracker_logs_all(self):
analytics_events, error_rate = _create_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "error_fallback" %}
{% component "success_rate_tracker" %}
{% component "api_widget" simulate_error=True / %}
{% endcomponent %}
{% endcomponent %}
{% component "success_rate_tracker" %}
{% component "api_widget" simulate_error=False / %}
{% endcomponent %}
"""
template = Template(template_str)
template.render(Context({}))
assert len(analytics_events) == 0
assert error_rate["error"] == 1
assert error_rate["success"] == 1

View file

@ -0,0 +1,55 @@
# Error handling
The built-in [`ErrorFallback`](../../reference/components/#django_components.components.error_fallback.ErrorFallback) component catches errors during component rendering and displays fallback content instead. This is similar to React's [`ErrorBoundary`](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) component.
In this scenario, we have a `WeatherWidget` component that simulates fetching data from a weather API,
which we wrap in the built-in [`ErrorFallback`](../../reference/components/#django_components.components.error_fallback.ErrorFallback) component.
We have two cases:
1. API call succeeds. The `WeatherWidget` component renders the weather information as expected.
2. API call fails. The `ErrorFallback` component catches the error and display a user-friendly message instead of breaking the page.
```django
{% component "error_fallback" %}
{% fill "content" %}
{% component "weather_widget" location="Atlantis" / %}
{% endfill %}
{% fill "fallback" %}
<p style="color: red;">
Could not load weather data for <strong>Atlantis</strong>.
The location may not be supported or the service is temporarily down.
</p>
{% endfill %}
{% endcomponent %}
```
![ErrorFallback example](./images/error_fallback.png)
## Definition
```djc_py
--8<-- "docs/examples/error_fallback/component.py"
```
## Example
To see the component in action, you can set up a view and URL pattern as shown below.
### `views.py`
```djc_py
--8<-- "docs/examples/error_fallback/page.py"
```
### `urls.py`
```python
from django.urls import path
from examples.pages.error_fallback import ErrorFallbackPage
urlpatterns = [
path("examples/error_fallback", ErrorFallbackPage.as_view(), name="error_fallback"),
]
```

View file

@ -0,0 +1,40 @@
# ruff: noqa: S311
import random
from typing import NamedTuple
from django_components import Component, register, types
DESCRIPTION = "A component that catches errors and displays fallback content, similar to React's ErrorBoundary."
@register("weather_widget")
class WeatherWidget(Component):
class Kwargs(NamedTuple):
location: str
simulate_error: bool = False
def get_template_data(self, args, kwargs: Kwargs, slots, context):
if kwargs.simulate_error:
raise OSError(f"Failed to connect to weather service for '{kwargs.location}'.")
return {
"location": kwargs.location,
"temperature": f"{random.randint(10, 30)}°C",
"condition": random.choice(["Sunny", "Cloudy", "Rainy"]),
}
template: types.django_html = """
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-2">
Weather in {{ location }}
</h3>
<p class="text-gray-600">
<strong class="font-medium text-gray-700">Temperature:</strong>
{{ temperature }}
</p>
<p class="text-gray-600">
<strong class="font-medium text-gray-700">Condition:</strong>
{{ condition }}
</p>
</div>
"""

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

View file

@ -0,0 +1,68 @@
from django.http import HttpRequest, HttpResponse
from django.utils.safestring import mark_safe
from django_components import Component, types
class ErrorFallbackPage(Component):
class Media:
js = (
mark_safe(
'<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp,container-queries"></script>'
),
)
template: types.django_html = """
{% load component_tags %}
<html>
<head>
<title>ErrorFallback Example</title>
</head>
<body class="bg-gray-100 p-8">
<div class="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">Weather API Widget Example</h1>
<p class="text-gray-600 mb-6">
This example demonstrates using ErrorFallback to handle potential API failures gracefully.
</p>
<div class="mb-8">
<h2 class="text-xl font-semibold mb-2">Case 1: API call is successful</h2>
{% component "error_fallback" %}
{% fill "content" %}
{% component "weather_widget" location="New York" / %}
{% endfill %}
{% fill "fallback" %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<strong class="font-bold">Error:</strong>
<span class="block sm:inline">Could not load weather data.</span>
</div>
{% endfill %}
{% endcomponent %}
</div>
<div>
<h2 class="text-xl font-semibold mb-2">Case 2: API call fails</h2>
{% component "error_fallback" %}
{% fill "content" %}
{% component "weather_widget" location="Atlantis" simulate_error=True / %}
{% endfill %}
{% fill "fallback" %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<strong class="font-bold">Error:</strong>
<span class="block sm:inline">
Could not load weather data for
<strong>Atlantis</strong>.
The location may not be supported or the service is temporarily down.
</span>
</div>
{% endfill %}
{% endcomponent %}
</div>
</div>
</body>
</html>
""" # noqa: E501
class View:
def get(self, request: HttpRequest) -> HttpResponse:
return ErrorFallbackPage.render_to_response(request=request)

View file

@ -0,0 +1,56 @@
import pytest
from django.template import Context, Template
from django_components import registry, types
from django_components.testing import djc_test
# Imported lazily, so we import components only once settings are set
def _create_components():
from docs.examples.error_fallback.component import WeatherWidget # noqa: PLC0415
registry.register("weather_widget", WeatherWidget)
@pytest.mark.django_db
@djc_test
class TestExampleWeatherWidget:
def test_renders_successfully(self):
_create_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "error_fallback" %}
{% fill "content" %}
{% component "weather_widget" location="New York" / %}
{% endfill %}
{% fill "fallback" %}
Error!
{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert "Weather in New York" in rendered
assert "Error!" not in rendered
def test_renders_fallback_on_error(self):
_create_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "error_fallback" %}
{% fill "content" %}
{% component "weather_widget" location="Atlantis" simulate_error=True / %}
{% endfill %}
{% fill "fallback" %}
<p>Weather service unavailable.</p>
{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert "Weather in Atlantis" not in rendered
assert "Weather service unavailable." in rendered

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

View file

@ -1,13 +1,11 @@
# Form # FormGrid
A `Form` component that automatically generates labels and arranges fields in a grid. It simplifies form creation by handling the layout for you. A `FormGrid` component that automatically generates labels and arranges fields in a grid. It simplifies form creation by handling the layout for you.
![Form example](images/form.png) To get started, use the following example to create a simple form with 2 fields - `project` and `option`.
To get started, use the following example to create a simple form with 2 fields - `project` and `option`:
```django ```django
{% component "form" %} {% component "form_grid" %}
{% fill "field:project" %} {% fill "field:project" %}
<input name="project" required> <input name="project" required>
{% endfill %} {% endfill %}
@ -22,16 +20,18 @@ To get started, use the following example to create a simple form with 2 fields
{% endcomponent %} {% endcomponent %}
``` ```
This will render a `<form>` where fields are defined using `field:<field_name>` slots. This will render a `<form>` where fields are defined using `field:<field_name>` slots:
![Form example](images/form.png)
Labels are automatically generated from the field name. If you want to define a custom label for a field, Labels are automatically generated from the field name. If you want to define a custom label for a field,
you can use the `label:<field_name>` slot. you can use the `label:<field_name>` slot.
```django ```django
{% component "form" %} {% component "form_grid" %}
{# Custom label for "description" field #} {# Custom label for "description" field #}
{% fill "label:description" %} {% fill "label:description" %}
{% component "form_label" {% component "form_grid_label"
field_name="description" field_name="description"
title="Marvelous description" title="Marvelous description"
/ %} / %}
@ -49,9 +49,9 @@ Whether you define custom labels or not, the form will have the following struct
## API ## API
### `Form` component ### `FormGrid` component
The `Form` component is the main container for your form fields. It accepts the following arguments: The `FormGrid` component is the main container for your form fields. It accepts the following arguments:
- **`editable`** (optional, default `True`): A boolean that determines if the form is editable. - **`editable`** (optional, default `True`): A boolean that determines if the form is editable.
- **`method`** (optional, default `"post"`): The HTTP method for the form submission. - **`method`** (optional, default `"post"`): The HTTP method for the form submission.
@ -67,13 +67,13 @@ To define the fields, you define a slot for each field.
- **`prepend`**: Content in this slot will be placed at the beginning of the form, before the main fields. - **`prepend`**: Content in this slot will be placed at the beginning of the form, before the main fields.
- **`append`**: Content in this slot will be placed at the end of the form, after the main fields. This is a good place for submit buttons. - **`append`**: Content in this slot will be placed at the end of the form, after the main fields. This is a good place for submit buttons.
### `FormLabel` component ### `FormGridLabel` component
When `Form` component automatically generates labels for fields, it uses the `FormLabel` component. When `FormGrid` component automatically generates labels for fields, it uses the `FormGridLabel` component.
When you need a custom label for a field, you can use the `FormLabel` component explicitly in `label:<field_name>` slots. When you need a custom label for a field, you can use the `FormGridLabel` component explicitly in `label:<field_name>` slots.
The `FormLabel` component accepts the following arguments: The `FormGridLabel` component accepts the following arguments:
- **`field_name`** (required): The name of the field that this label is for. This will be used as the `for` attribute of the label. - **`field_name`** (required): The name of the field that this label is for. This will be used as the `for` attribute of the label.
- **`title`** (optional): Custom text for the label. If not provided, the component will automatically generate a title from the `field_name` by replacing underscores and hyphens with spaces and applying title case. - **`title`** (optional): Custom text for the label. If not provided, the component will automatically generate a title from the `field_name` by replacing underscores and hyphens with spaces and applying title case.
@ -81,7 +81,7 @@ The `FormLabel` component accepts the following arguments:
**Example:** **Example:**
```django ```django
{% component "form_label" {% component "form_grid_label"
field_name="user_name" field_name="user_name"
title="Your Name" title="Your Name"
/ %} / %}
@ -99,7 +99,7 @@ converting snake_case to "Title Case".
## Definition ## Definition
```djc_py ```djc_py
--8<-- "docs/examples/form/component.py" --8<-- "docs/examples/form_grid/component.py"
``` ```
## Example ## Example
@ -109,7 +109,7 @@ To see the component in action, you can set up a view and a URL pattern as shown
### `views.py` ### `views.py`
```djc_py ```djc_py
--8<-- "docs/examples/form/page.py" --8<-- "docs/examples/form_grid/page.py"
``` ```
### `urls.py` ### `urls.py`
@ -117,9 +117,9 @@ To see the component in action, you can set up a view and a URL pattern as shown
```python ```python
from django.urls import path from django.urls import path
from examples.pages.form import FormPage from examples.pages.form_grid import FormGridPage
urlpatterns = [ urlpatterns = [
path("examples/form", FormPage.as_view(), name="form"), path("examples/form_grid", FormGridPage.as_view(), name="form_grid"),
] ]
``` ```

View file

@ -2,9 +2,13 @@ from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple
from django_components import Component, Slot, register, types from django_components import Component, Slot, register, types
DESCRIPTION = "Form that automatically arranges fields in a grid and generates labels."
@register("form_grid")
class FormGrid(Component):
"""Form that automatically arranges fields in a grid and generates labels."""
@register("form")
class Form(Component):
class Kwargs(NamedTuple): class Kwargs(NamedTuple):
editable: bool = True editable: bool = True
method: str = "post" method: str = "post"
@ -109,8 +113,8 @@ def prepare_form_grid(slots: Dict[str, Slot]):
else: else:
# Case: Component user didn't explicitly define how to render the label # Case: Component user didn't explicitly define how to render the label
# We will create the label for the field automatically # We will create the label for the field automatically
label = FormLabel.render( label = FormGridLabel.render(
kwargs=FormLabel.Kwargs(field_name=field_name), kwargs=FormGridLabel.Kwargs(field_name=field_name),
deps_strategy="ignore", deps_strategy="ignore",
) )
@ -122,8 +126,8 @@ def prepare_form_grid(slots: Dict[str, Slot]):
return fields return fields
@register("form_label") @register("form_grid_label")
class FormLabel(Component): class FormGridLabel(Component):
template: types.django_html = """ template: types.django_html = """
<label for="{{ field_name }}" class="font-semibold text-gray-700"> <label for="{{ field_name }}" class="font-semibold text-gray-700">
{{ title }} {{ title }}

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View file

@ -4,7 +4,7 @@ from django.utils.safestring import mark_safe
from django_components import Component, types from django_components import Component, types
class FormPage(Component): class FormGridPage(Component):
class Media: class Media:
js = ( js = (
# AlpineJS # AlpineJS
@ -16,7 +16,7 @@ class FormPage(Component):
template: types.django_html = """ template: types.django_html = """
<html> <html>
<head> <head>
<title>Form</title> <title>FormGrid</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp,container-queries"></script> <script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp,container-queries"></script>
</head> </head>
<body> <body>
@ -29,7 +29,7 @@ class FormPage(Component):
<h3>Submit form</h3> <h3>Submit form</h3>
</div> </div>
{% component "form" {% component "form_grid"
attrs:class="pb-4 px-4 pt-6 sm:px-6 lg:px-8 flex-auto flex flex-col" attrs:class="pb-4 px-4 pt-6 sm:px-6 lg:px-8 flex-auto flex flex-col"
attrs:style="max-width: 600px;" attrs:style="max-width: 600px;"
attrs:@submit.prevent="onSubmit" attrs:@submit.prevent="onSubmit"
@ -56,7 +56,7 @@ class FormPage(Component):
{# Defined both label and field because label name is different from field name #} {# Defined both label and field because label name is different from field name #}
{% fill "label:description" %} {% fill "label:description" %}
{% component "form_label" field_name="description" title="Marvelous description" / %} {% component "form_grid_label" field_name="description" title="Marvelous description" / %}
{% endfill %} {% endfill %}
{% fill "field:description" %} {% fill "field:description" %}
<textarea <textarea
@ -85,4 +85,4 @@ class FormPage(Component):
class View: class View:
def get(self, request: HttpRequest): def get(self, request: HttpRequest):
return FormPage.render_to_response(request=request) return FormGridPage.render_to_response(request=request)

View file

@ -8,10 +8,10 @@ from django_components.testing import djc_test
# Imported lazily, so we import components only once settings are set # Imported lazily, so we import components only once settings are set
def _create_form_components(): def _create_form_components():
from docs.examples.form.component import Form, FormLabel # noqa: PLC0415 from docs.examples.form_grid.component import FormGrid, FormGridLabel # noqa: PLC0415
registry.register("form", Form) registry.register("form_grid", FormGrid)
registry.register("form_label", FormLabel) registry.register("form_grid_label", FormGridLabel)
@pytest.mark.django_db @pytest.mark.django_db
@ -21,7 +21,7 @@ class TestExampleForm:
_create_form_components() _create_form_components()
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% component "form" %} {% component "form_grid" %}
{% fill "field:project" %}<input name="project">{% endfill %} {% fill "field:project" %}<input name="project">{% endfill %}
{% fill "field:option" %}<select name="option"></select>{% endfill %} {% fill "field:option" %}<select name="option"></select>{% endfill %}
{% endcomponent %} {% endcomponent %}
@ -53,7 +53,7 @@ class TestExampleForm:
_create_form_components() _create_form_components()
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% component "form" %} {% component "form_grid" %}
{% fill "label:project" %}<strong>Custom Project Label</strong>{% endfill %} {% fill "label:project" %}<strong>Custom Project Label</strong>{% endfill %}
{% fill "field:project" %}<input name="project">{% endfill %} {% fill "field:project" %}<input name="project">{% endfill %}
{% endcomponent %} {% endcomponent %}
@ -68,7 +68,7 @@ class TestExampleForm:
_create_form_components() _create_form_components()
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% component "form" %} {% component "form_grid" %}
{% fill "label:project" %}Custom Project Label{% endfill %} {% fill "label:project" %}Custom Project Label{% endfill %}
{% endcomponent %} {% endcomponent %}
""" """
@ -81,7 +81,7 @@ class TestExampleForm:
_create_form_components() _create_form_components()
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% component "form" %} {% component "form_grid" %}
{% fill "prepend" %}<div>Prepended content</div>{% endfill %} {% fill "prepend" %}<div>Prepended content</div>{% endfill %}
{% fill "field:project" %}<input name="project">{% endfill %} {% fill "field:project" %}<input name="project">{% endfill %}
{% fill "append" %}<div>Appended content</div>{% endfill %} {% fill "append" %}<div>Appended content</div>{% endfill %}

View file

@ -0,0 +1,41 @@
# Form Submission
Handle the entire form submission flow in a single file. From UI definition to server-side handler, without Django's `Form` class and without modifying `urlpatterns`.
1. Define the form to submit in the HTML as a `<form>`.
2. Add a [`View.post()`](../../reference/api#django_components.ComponentView.post) method on the same component that defines the `<form>`, to define how to process the form data and return a partial HTML response.
3. Obtain the URL to submit the form to and set it as the `action` attribute of the `<form>`. You don't need to go to your `urlpatterns`. The submission URL is dynamically generated using [`get_component_url()`](../../reference/api#django_components.get_component_url).
The `ContactFormComponent` renders a simple form. After submission, it receives a partial HTML response and appends a "thank you" message below the form.
![Form Submission example](./images/form_submission.gif)
## Definition
```djc_py
--8<-- "docs/examples/form_submission/component.py"
```
## Example
To see the component in action, you can set up a view and a URL pattern as shown below.
### `views.py`
```djc_py
--8<-- "docs/examples/form_submission/page.py"
```
### `urls.py`
```python
from django.urls import path
from examples.pages.form_submission import FormSubmissionPage
urlpatterns = [
path("examples/form_submission", FormSubmissionPage.as_view(), name="form_submission"),
]
```

View file

@ -0,0 +1,66 @@
from typing import NamedTuple
from django.http import HttpRequest, HttpResponse
from django_components import Component, get_component_url, register, types
DESCRIPTION = "Handle the entire form submission flow in a single file and without Django's Form class."
@register("thank_you_message")
class ThankYouMessage(Component):
class Kwargs(NamedTuple):
name: str
def get_template_data(self, args, kwargs: Kwargs, slots, context):
return {"name": kwargs.name}
template: types.django_html = """
<div class="p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg mt-4">
<p>Thank you for your submission, {{ name }}!</p>
</div>
"""
@register("contact_form")
class ContactFormComponent(Component):
def get_template_data(self, args, kwargs: NamedTuple, slots, context):
# Send the form data to the HTTP handlers of this component
submit_url = get_component_url(ContactFormComponent)
return {
"submit_url": submit_url,
}
template: types.django_html = """
<form hx-post="{{ submit_url }}" hx-target="#thank-you-container" hx-swap="innerHTML" class="space-y-4">
{% csrf_token %}
<div>
<label for="name" class="block text-sm font-medium text-gray-700">
Name
</label>
<input
type="text"
name="name"
id="name"
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
>
</div>
<div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Submit
</button>
</div>
</form>
<div id="thank-you-container"></div>
""" # noqa: E501
class View:
public = True
# Submit handler
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
# Access the submitted data
name = request.POST.get("name", "stranger")
# Respond with the "thank you" message
return ThankYouMessage.render_to_response(kwargs={"name": name})

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

View file

@ -0,0 +1,36 @@
from django.http import HttpRequest, HttpResponse
from django_components import Component, types
class FormSubmissionPage(Component):
class Media:
js = (
"https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,container-queries",
"https://unpkg.com/htmx.org@2.0.7",
)
template: types.django_html = """
{% load component_tags %}
<html>
<head>
<title>Form Submission Example</title>
</head>
<body class="bg-gray-100 p-8" hx-boost="true">
<div class="max-w-md mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">
Self-Contained Form Component
</h1>
<p class="text-gray-600 mb-6">
This form's HTML and submission logic are all
handled within a single component file.
</p>
{% component "contact_form" / %}
</div>
</body>
</html>
"""
class View:
def get(self, request: HttpRequest) -> HttpResponse:
return FormSubmissionPage.render_to_response(request=request)

View file

@ -0,0 +1,39 @@
import pytest
from django.template import Context, Template
from django_components import registry, types
from django_components.testing import djc_test
def _import_components():
from docs.examples.form_submission.component import ContactFormComponent, ThankYouMessage # noqa: PLC0415
registry.register("contact_form", ContactFormComponent)
registry.register("thank_you_message", ThankYouMessage)
@pytest.mark.django_db
@djc_test
class TestFormSubmission:
def test_form_renders(self):
_import_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "contact_form" / %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert 'hx-post="' in rendered
assert '<div id="thank-you-container" data-djc-id-ca1bc3f=""></div>' in rendered
assert "Thank you" not in rendered
def test_form_submission(self):
_import_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "thank_you_message" name="John Doe" / %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert "Thank you for your submission, John Doe!" in rendered
assert '<div id="thank-you-container"></div>' not in rendered

View file

@ -0,0 +1,44 @@
# HTML fragments
Fragments are pieces of HTML that are inserted into the page without a full page reload.
Fragments are also known as partials or HTML-over-the-wire.
The usual flow is to:
1. Make a server request
2. Server responds with new HTML
3. Insert the new HTML into the page
This example loads HTML fragments using different client-side techniques: vanilla JavaScript, AlpineJS, and HTMX.
In each of the 3 cases, when the fragment is loaded, this also runs the fragment's JS and CSS code.
![Fragments example](./images/fragments.gif)
## Definition
```djc_py
--8<-- "docs/examples/fragments/component.py"
```
## Example
To see the component in action, you can set up a view and a URL pattern as shown below.
### `views.py`
```djc_py
--8<-- "docs/examples/fragments/page.py"
```
### `urls.py`
```python
from django.urls import path
from examples.pages.fragments import FragmentsPage
urlpatterns = [
path("examples/fragments", FragmentsPage.as_view(), name="fragments"),
]
```

View file

@ -0,0 +1,79 @@
from typing import NamedTuple
from django_components import Component, register, types
DESCRIPTION = "Use HTML fragments (partials) with HTMX, AlpineJS, or plain JS."
@register("simple_fragment")
class SimpleFragment(Component):
"""A simple fragment with JS and CSS."""
class Kwargs(NamedTuple):
type: str
template: types.django_html = """
<div class="frag_simple">
Fragment with JS and CSS (plain).
<span id="frag-text"></span>
</div>
"""
js: types.js = """
document.querySelector('#frag-text').textContent = ' JavaScript has run.';
"""
css: types.css = """
.frag_simple {
background: #f0f8ff;
border: 1px solid #add8e6;
padding: 1rem;
border-radius: 5px;
}
"""
@register("alpine_fragment")
class AlpineFragment(Component):
"""A fragment that defines an AlpineJS component."""
class Kwargs(NamedTuple):
type: str
# The fragment is wrapped in `<template x-if="false">` so that we prevent
# AlpineJS from inserting the HTML right away. Instead, we want to load it
# only once this component's JS has been loaded.
template: types.django_html = """
<template x-if="false" data-name="frag">
<div
class="frag_alpine"
x-data="frag"
x-text="message"
x-init="() => {
document.querySelectorAll('#loader-alpine').forEach((el) => {
el.innerHTML = 'Fragment loaded!';
el.disabled = true;
});
}"
></div>
</template>
"""
js: types.js = """
Alpine.data('frag', () => ({
message: 'Fragment with JS and CSS (AlpineJS).',
}));
document.querySelectorAll('[data-name="frag"]').forEach((el) => {
el.setAttribute('x-if', 'true');
});
"""
css: types.css = """
.frag_alpine {
background: #f0fff0;
border: 1px solid #98fb98;
padding: 1rem;
border-radius: 5px;
}
"""

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

View file

@ -0,0 +1,141 @@
from django.http import HttpRequest, HttpResponse
from django.utils.safestring import mark_safe
from django_components import Component, get_component_url, types
from .component import AlpineFragment, SimpleFragment
class FragmentsPage(Component):
class Media:
js = (
"https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,container-queries",
mark_safe('<script defer src="https://unpkg.com/alpinejs"></script>'),
)
def get_template_data(self, args, kwargs, slots, context):
# Get URLs that points to the FragmentsPageView.get() method
alpine_url = get_component_url(FragmentsPage, query={"type": "alpine"})
js_url = get_component_url(FragmentsPage, query={"type": "js"})
htmx_url = get_component_url(FragmentsPage, query={"type": "htmx"})
return {
"alpine_url": alpine_url,
"js_url": js_url,
"htmx_url": htmx_url,
}
template: types.django_html = """
{% load component_tags %}
<html>
<head>
<title>HTML Fragments Example</title>
<script src="https://unpkg.com/htmx.org@2.0.7/dist/htmx.js"></script>
</head>
<body
class="bg-gray-100 p-8"
data-alpine-url="{{ alpine_url }}"
data-js-url="{{ js_url }}"
hx-boost="true"
>
<div class="max-w-4xl mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">
HTML Fragments
</h1>
<p class="text-gray-600 mb-6">
This example shows how to load HTML fragments
using different client-side techniques.
</p>
<!-- Vanilla JS -->
<div class="mb-8 p-4 border rounded-lg">
<h2 class="text-xl font-semibold mb-2">
Vanilla JS
</h2>
<div id="target-js">Initial content</div>
<button
id="loader-js"
class="mt-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Load Fragment
</button>
</div>
<!-- AlpineJS -->
<div
class="mb-8 p-4 border rounded-lg"
x-data="{
htmlVar: '<div id=\\'target-alpine\\'>Initial content</div>',
}"
>
<h2 class="text-xl font-semibold mb-2">
AlpineJS
</h2>
<div x-html="htmlVar"></div>
<button
id="loader-alpine"
@click="() => {
const alpineUrl = document.body.dataset.alpineUrl;
fetch(alpineUrl)
.then(r => r.text())
.then(html => {
htmlVar = html;
})
}"
class="mt-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
>
Load Fragment
</button>
</div>
<!-- HTMX -->
<div class="p-4 border rounded-lg">
<h2 class="text-xl font-semibold mb-2">
HTMX
</h2>
<div id="target-htmx">Initial content</div>
<button
id="loader-htmx"
hx-get="{{ htmx_url }}"
hx-swap="outerHTML"
hx-target="#target-htmx"
class="mt-2 px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700"
>
Load Fragment
</button>
</div>
</div>
<script>
document.querySelector('#loader-js').addEventListener('click', function () {
const jsUrl = document.body.dataset.jsUrl;
fetch(jsUrl)
.then(response => response.text())
.then(html => {
document.querySelector('#target-js').outerHTML = html;
});
});
</script>
</body>
</html>
"""
class View:
public = True
# The same GET endpoint handles rendering either the whole page or a fragment.
# We use the `type` query parameter to determine which one to render.
def get(self, request: HttpRequest) -> HttpResponse:
fragment_type = request.GET.get("type")
if fragment_type:
fragment_cls = AlpineFragment if fragment_type == "alpine" else SimpleFragment
return fragment_cls.render_to_response(
request=request,
deps_strategy="fragment",
kwargs={"type": fragment_type},
)
else:
return FragmentsPage.render_to_response(
request=request,
deps_strategy="fragment",
)

View file

@ -0,0 +1,51 @@
import pytest
from django.template import Context, Template
from django_components import registry, types
from django_components.testing import djc_test
def _import_components():
from docs.examples.fragments.component import AlpineFragment, SimpleFragment # noqa: PLC0415
from docs.examples.fragments.page import FragmentsPage # noqa: PLC0415
registry.register("alpine_fragment", AlpineFragment)
registry.register("simple_fragment", SimpleFragment)
registry.register("fragments_page", FragmentsPage)
@pytest.mark.django_db
@djc_test
class TestFragments:
def test_page_renders(self):
_import_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "fragments_page" / %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert "HTML Fragments" in rendered
assert "Vanilla JS" in rendered
assert "AlpineJS" in rendered
assert "HTMX" in rendered
def test_alpine_fragment_view(self):
_import_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "alpine_fragment" type="alpine" / %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert 'class="frag_alpine"' in rendered
def test_simple_fragment_view(self):
_import_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "simple_fragment" type="plain" / %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert "Fragment with JS and CSS (plain)" in rendered

View file

@ -7,9 +7,18 @@ Here you will find public examples of components and component libraries.
If you have components that would be useful to others, open a [pull request](https://github.com/django-components/django-components/pulls) to add them to this collection. If you have components that would be useful to others, open a [pull request](https://github.com/django-components/django-components/pulls) to add them to this collection.
## Scenarios
- [Form Submission](./form_submission) - Handle the entire form submission flow in a single file and without Django's Form class.
- [HTML fragments](./fragments) - Load HTML fragments using different client-side techniques: vanilla JavaScript, AlpineJS, and HTMX.
- [Error handling](./error_fallback) - A component that catches errors and displays fallback content, similar to React's ErrorBoundary.
- [Recursion](./recursion) - 100 nested components? Not a problem! Handle recursive rendering out of the box.
- [A/B Testing](./ab_testing) - Dynamically render different component versions. Use for A/B testing, phased rollouts, etc.
- [Analytics](./analytics) - Track component errors or success rates to send them to Sentry or other services.
## Components ## Components
- [Form](./form) - A form component that automatically generates labels and arranges fields in a grid. - [FormGrid](./form_grid) - A form component that automatically generates labels and arranges fields in a grid.
- [Tabs (AlpineJS)](./tabs) - Dynamic tabs with [AlpineJS](https://alpinejs.dev/). - [Tabs (AlpineJS)](./tabs) - Dynamic tabs with [AlpineJS](https://alpinejs.dev/).
## Packages ## Packages

View file

@ -0,0 +1,41 @@
# Recursion
Unlike other frameworks, `django-components` handles templates of any depth. 100 nested components? Not a problem!
In this example, the `Recursion` will recursively render itself 100 times.
```django
{% component "recursion" / %}
```
This will produce a deeply nested structure of divs, with each level indicating its depth in the recursion.
![Recursion](./images/recursion.png)
## Definition
```djc_py
--8<-- "docs/examples/recursion/component.py"
```
## Example
To see the component in action, you can set up a view and a URL pattern as shown below.
### `views.py`
```djc_py
--8<-- "docs/examples/recursion/page.py"
```
### `urls.py`
```python
from django.urls import path
from examples.pages.recursion import RecursionPage
urlpatterns = [
path("examples/recursion", RecursionPage.as_view(), name="recursion"),
]
```

View file

@ -0,0 +1,34 @@
from typing import NamedTuple
from django_components import Component, register, types
DESCRIPTION = "100 nested components? Not a problem! Handle recursive rendering out of the box."
@register("recursion")
class Recursion(Component):
class Kwargs(NamedTuple):
current_depth: int = 0
def get_template_data(self, args, kwargs: Kwargs, slots, context):
current_depth = kwargs.current_depth
return {
"current_depth": current_depth,
"next_depth": current_depth + 1,
}
template: types.django_html = """
{% load component_tags %}
<div class="py-4 border-l-2 border-gray-300 ml-1">
{% if current_depth < 100 %}
<p class="text-sm text-gray-600">
Recursion depth: {{ current_depth }}
</p>
{% component "recursion" current_depth=next_depth / %}
{% else %}
<p class="text-sm font-semibold text-green-600">
Reached maximum recursion depth!
</p>
{% endif %}
</div>
"""

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View file

@ -0,0 +1,35 @@
from django.http import HttpRequest, HttpResponse
from django.utils.safestring import mark_safe
from django_components import Component, types
class RecursionPage(Component):
class Media:
js = (
mark_safe(
'<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp,container-queries"></script>'
),
)
template: types.django_html = """
{% load component_tags %}
<html>
<head>
<title>Recursion Example</title>
</head>
<body class="bg-gray-100 p-8">
<div class="max-w-4xl mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">Recursion</h1>
<p class="text-gray-600 mb-6">
Django components easily handles even deeply nested components.
</p>
{% component "recursion" / %}
</div>
</body>
</html>
"""
class View:
def get(self, request: HttpRequest) -> HttpResponse:
return RecursionPage.render_to_response(request=request)

View file

@ -0,0 +1,29 @@
import pytest
from django.template import Context, Template
from django_components import registry, types
from django_components.testing import djc_test
def _import_components():
from docs.examples.recursion.component import Recursion # noqa: PLC0415
registry.register("recursion", Recursion)
@pytest.mark.django_db
@djc_test
class TestRecursion:
def test_renders_recursively(self):
_import_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "recursion" / %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
# Expect 101 levels of depth (0 to 100)
assert rendered.count("Recursion depth:") == 100
assert "Reached maximum recursion depth!" in rendered

View file

@ -12,6 +12,8 @@ from django.utils.text import slugify
from django_components import Component, register from django_components import Component, register
from django_components import types as t from django_components import types as t
DESCRIPTION = "Dynamic tabs with AlpineJS."
class TabDatum(NamedTuple): class TabDatum(NamedTuple):
"""Datum for an individual tab.""" """Datum for an individual tab."""
@ -230,7 +232,7 @@ class _TablistImpl(Component):
@register("Tablist") @register("Tablist")
class Tablist(Component): class Tablist(Component):
""" """
Tablist role component comprised of nested tab components. Dynamic tabs with [AlpineJS](https://alpinejs.dev/).
After the input is processed, this component delegates to an internal implementation After the input is processed, this component delegates to an internal implementation
component that renders the content. component that renders the content.

View file

@ -1,164 +0,0 @@
from django_components import Component, types
# HTML into which a fragment will be loaded using vanilla JS
class FragmentBaseJs(Component):
class View:
def get(self, request):
return FragmentBaseJs.render_to_response(request=request)
template: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head>
{% component_css_dependencies %}
</head>
<body>
<div id="target">OLD</div>
<button id="loader">
Click me!
</button>
<script>
const url = `/fragment/frag/js`;
document.querySelector('#loader').addEventListener('click', function () {
fetch(url)
.then(response => response.text())
.then(html => {
console.log({ fragment: html })
document.querySelector('#target').outerHTML = html;
});
});
</script>
{% component_js_dependencies %}
</body>
</html>
"""
# HTML into which a fragment will be loaded using AlpineJs
class FragmentBaseAlpine(Component):
class View:
def get(self, request):
return FragmentBaseAlpine.render_to_response(request=request)
template: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head>
{% component_css_dependencies %}
<script defer src="https://unpkg.com/alpinejs"></script>
</head>
<body x-data="{
htmlVar: 'OLD',
loadFragment: function () {
const url = '/fragment/frag/alpine';
fetch(url)
.then(response => response.text())
.then(html => {
console.log({ fragment: html });
this.htmlVar = html;
});
}
}">
<div id="target" x-html="htmlVar">OLD</div>
<button id="loader" @click="loadFragment">
Click me!
</button>
{% component_js_dependencies %}
</body>
</html>
"""
# HTML into which a fragment will be loaded using HTMX
class FragmentBaseHtmx(Component):
class View:
def get(self, request):
return FragmentBaseHtmx.render_to_response(request=request)
template: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head>
{% component_css_dependencies %}
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<div id="target">OLD</div>
<button id="loader" hx-get="/fragment/frag/js" hx-swap="outerHTML" hx-target="#target">
Click me!
</button>
{% component_js_dependencies %}
</body>
</html>
"""
# Fragment where the JS and CSS are defined on the Component
class FragJs(Component):
class View:
def get(self, request):
return FragJs.render_to_response(request=request, deps_strategy="fragment")
template: types.django_html = """
<div class="frag">
123
<span id="frag-text"></span>
</div>
"""
js: types.js = """
document.querySelector('#frag-text').textContent = 'xxx';
"""
css: types.css = """
.frag {
background: blue;
}
"""
# Fragment that defines an AlpineJS component
class FragAlpine(Component):
class View:
def get(self, request):
return FragAlpine.render_to_response(request=request, deps_strategy="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.
template: types.django_html = """
<template x-if="false" data-name="frag">
<div class="frag">
123
<span x-data="frag" x-text="fragVal">
</span>
</div>
</template>
"""
js: types.js = """
Alpine.data('frag', () => ({
fragVal: 'xxx',
}));
// Now that the component has been defined in AlpineJS, we can "activate" all instances
// where we use the `x-data="frag"` directive.
document.querySelectorAll('[data-name="frag"]').forEach((el) => {
el.setAttribute('x-if', 'true');
});
"""
css: types.css = """
.frag {
background: blue;
}
"""

View file

@ -1,41 +0,0 @@
import time
from typing import NamedTuple
from django.http import HttpRequest, HttpResponse
from django_components import Component, register, types
@register("recursive")
class Recursive(Component):
template: types.django_html = """
<div id="recursive">
depth: {{ depth }}
<hr/>
{% if depth <= 100 %}
{% component "recursive" depth=depth / %}
{% endif %}
</div>
"""
class Kwargs(NamedTuple):
depth: int
class Defaults:
depth = 0
def get_template_data(self, args, kwargs: Kwargs, slots, context):
return {"depth": kwargs.depth + 1}
class View:
def get(self, request: HttpRequest) -> HttpResponse:
time_before = time.time()
output = Recursive.render_to_response(
request=request,
kwargs=Recursive.Kwargs(
depth=0,
),
)
time_after = time.time()
print("TIME: ", time_after - time_before)
return output

View file

@ -1,20 +1,12 @@
from django.urls import path from django.urls import path
from components.calendar.calendar import Calendar, CalendarRelative from components.calendar.calendar import Calendar, CalendarRelative
from components.fragment import FragAlpine, FragJs, FragmentBaseAlpine, FragmentBaseHtmx, FragmentBaseJs
from components.greeting import Greeting from components.greeting import Greeting
from components.nested.calendar.calendar import CalendarNested from components.nested.calendar.calendar import CalendarNested
from components.recursive import Recursive
urlpatterns = [ urlpatterns = [
path("greeting/", Greeting.as_view(), name="greeting"), path("greeting/", Greeting.as_view(), name="greeting"),
path("calendar/", Calendar.as_view(), name="calendar"), path("calendar/", Calendar.as_view(), name="calendar"),
path("calendar-relative/", CalendarRelative.as_view(), name="calendar-relative"), path("calendar-relative/", CalendarRelative.as_view(), name="calendar-relative"),
path("calendar-nested/", CalendarNested.as_view(), name="calendar-nested"), path("calendar-nested/", CalendarNested.as_view(), name="calendar-nested"),
path("recursive/", Recursive.as_view(), name="recursive"),
path("fragment/base/alpine", FragmentBaseAlpine.as_view()),
path("fragment/base/htmx", FragmentBaseHtmx.as_view()),
path("fragment/base/js", FragmentBaseJs.as_view()),
path("fragment/frag/alpine", FragAlpine.as_view()),
path("fragment/frag/js", FragJs.as_view()),
] ]

View file

@ -37,7 +37,7 @@ All examples are available as live demos:
- **Index page**: [http://localhost:8000/examples/](http://localhost:8000/examples/) - Lists all available examples - **Index page**: [http://localhost:8000/examples/](http://localhost:8000/examples/) - Lists all available examples
- **Individual examples**: `http://localhost:8000/examples/<example_name>` - **Individual examples**: `http://localhost:8000/examples/<example_name>`
- [http://localhost:8000/examples/form](http://localhost:8000/examples/form) - [http://localhost:8000/examples/form_grid](http://localhost:8000/examples/form_grid)
- [http://localhost:8000/examples/tabs](http://localhost:8000/examples/tabs) - [http://localhost:8000/examples/tabs](http://localhost:8000/examples/tabs)
## Adding new examples ## Adding new examples

View file

@ -34,7 +34,7 @@ def get_example_urls():
view_class = None view_class = None
for attr_name in dir(module): for attr_name in dir(module):
attr = getattr(module, attr_name) attr = getattr(module, attr_name)
if issubclass(attr, Component) and attr_name != "Component": if issubclass(attr, Component) and attr_name != "Component" and attr_name.endswith("Page"):
view_class = attr view_class = attr
break break

View file

@ -1,3 +1,5 @@
from importlib import import_module
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -25,10 +27,17 @@ class ExamplesIndexPage(Component):
for name in sorted(example_names): for name in sorted(example_names):
# Convert snake_case to PascalCase (e.g. error_fallback -> ErrorFallback) # Convert snake_case to PascalCase (e.g. error_fallback -> ErrorFallback)
display_name = "".join(word.capitalize() for word in name.split("_")) display_name = "".join(word.capitalize() for word in name.split("_"))
# For the short description, we use the DESCRIPTION variable from the component's module
module_name = f"examples.dynamic.{name}.component"
module = import_module(module_name)
description = getattr(module, "DESCRIPTION", "")
examples.append( examples.append(
{ {
"name": name, # Original name for URLs "name": name, # Original name for URLs
"display_name": display_name, # PascalCase for display "display_name": display_name, # PascalCase for display
"description": description,
} }
) )
@ -65,7 +74,7 @@ class ExamplesIndexPage(Component):
{{ example.display_name }} {{ example.display_name }}
</h2> </h2>
<p class="text-gray-600 mb-4 flex-grow"> <p class="text-gray-600 mb-4 flex-grow">
{{ example.display_name }} component example {{ example.description }}
</p> </p>
<a <a
href="/examples/{{ example.name }}" href="/examples/{{ example.name }}"

View file

@ -8,12 +8,23 @@ from django_components import Component, OnRenderGenerator, SlotInput, types
class ErrorFallback(Component): class ErrorFallback(Component):
""" """
Use `ErrorFallback` to catch errors and display a fallback content instead. A component that catches errors and displays fallback content, similar to React's ErrorBoundary.
This is similar to React's See React's [`ErrorBoundary`](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)
[`ErrorBoundary`](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)
component. component.
**Parameters**:
- **fallback** (str, optional): A string to display when an error occurs.
Cannot be used together with the `fallback` slot.
**Slots**:
- **content** or **default**: The main content that might raise an error.
- **fallback**: Custom fallback content to display when an error occurs. When using the `fallback` slot,
you can access the `error` object through slot data (`{% fill "fallback" data="data" %}`).
Cannot be used together with the `fallback` kwarg.
**Example:** **Example:**
Given this template: Given this template: