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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
83
docs/examples/ab_testing/README.md
Normal 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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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"),
|
||||||
|
]
|
||||||
|
```
|
||||||
64
docs/examples/ab_testing/component.py
Normal 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)
|
||||||
BIN
docs/examples/ab_testing/images/ab_testing.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
63
docs/examples/ab_testing/page.py
Normal 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)
|
||||||
58
docs/examples/ab_testing/test_example_ab_testing.py
Normal 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)
|
||||||
84
docs/examples/analytics/README.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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"),
|
||||||
|
]
|
||||||
|
```
|
||||||
64
docs/examples/analytics/component.py
Normal 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" / %}
|
||||||
|
"""
|
||||||
BIN
docs/examples/analytics/images/analytics.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
117
docs/examples/analytics/page.py
Normal 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>
|
||||||
|
"""
|
||||||
71
docs/examples/analytics/test_example_analytics.py
Normal 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
|
||||||
55
docs/examples/error_fallback/README.md
Normal 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 %}
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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"),
|
||||||
|
]
|
||||||
|
```
|
||||||
40
docs/examples/error_fallback/component.py
Normal 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>
|
||||||
|
"""
|
||||||
BIN
docs/examples/error_fallback/images/error_fallback.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
68
docs/examples/error_fallback/page.py
Normal 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)
|
||||||
56
docs/examples/error_fallback/test_example_error_fallback.py
Normal 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
|
||||||
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
|
@ -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.
|
||||||
|
|
||||||

|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
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"),
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
@ -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 }}
|
||||||
BIN
docs/examples/form_grid/images/form.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
docs/examples/form_grid/images/form_structure.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
|
|
@ -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)
|
||||||
|
|
@ -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 %}
|
||||||
41
docs/examples/form_submission/README.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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"),
|
||||||
|
]
|
||||||
|
```
|
||||||
66
docs/examples/form_submission/component.py
Normal 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})
|
||||||
BIN
docs/examples/form_submission/images/form_submission.gif
Normal file
|
After Width: | Height: | Size: 354 KiB |
36
docs/examples/form_submission/page.py
Normal 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)
|
||||||
|
|
@ -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
|
||||||
44
docs/examples/fragments/README.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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"),
|
||||||
|
]
|
||||||
|
```
|
||||||
79
docs/examples/fragments/component.py
Normal 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;
|
||||||
|
}
|
||||||
|
"""
|
||||||
BIN
docs/examples/fragments/images/fragments.gif
Normal file
|
After Width: | Height: | Size: 382 KiB |
141
docs/examples/fragments/page.py
Normal 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",
|
||||||
|
)
|
||||||
51
docs/examples/fragments/test_example_fragments.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
41
docs/examples/recursion/README.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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"),
|
||||||
|
]
|
||||||
|
```
|
||||||
34
docs/examples/recursion/component.py
Normal 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>
|
||||||
|
"""
|
||||||
BIN
docs/examples/recursion/images/recursion.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
35
docs/examples/recursion/page.py
Normal 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)
|
||||||
29
docs/examples/recursion/test_example_recursive.py
Normal 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
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()),
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||