Deployed 49afdb49 to dev with MkDocs 1.6.1 and mike 2.1.3
BIN
dev/assets/images/social/examples/ab_testing/README.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
dev/assets/images/social/examples/analytics/README.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dev/assets/images/social/examples/error_fallback/README.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 26 KiB |
BIN
dev/assets/images/social/examples/form_grid/README.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
dev/assets/images/social/examples/form_submission/README.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
dev/assets/images/social/examples/fragments/README.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
dev/assets/images/social/examples/recursion/README.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
64
dev/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
dev/examples/ab_testing/images/ab_testing.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
267
dev/examples/ab_testing/index.html
Normal file
63
dev/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
dev/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)
|
||||
64
dev/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
dev/examples/analytics/images/analytics.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
322
dev/examples/analytics/index.html
Normal file
117
dev/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
dev/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
|
||||
40
dev/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
dev/examples/error_fallback/images/error_fallback.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
241
dev/examples/error_fallback/index.html
Normal file
68
dev/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
dev/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 |
|
|
@ -2,9 +2,13 @@ from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple
|
|||
|
||||
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):
|
||||
editable: bool = True
|
||||
method: str = "post"
|
||||
|
|
@ -109,8 +113,8 @@ def prepare_form_grid(slots: Dict[str, Slot]):
|
|||
else:
|
||||
# Case: Component user didn't explicitly define how to render the label
|
||||
# We will create the label for the field automatically
|
||||
label = FormLabel.render(
|
||||
kwargs=FormLabel.Kwargs(field_name=field_name),
|
||||
label = FormGridLabel.render(
|
||||
kwargs=FormGridLabel.Kwargs(field_name=field_name),
|
||||
deps_strategy="ignore",
|
||||
)
|
||||
|
||||
|
|
@ -122,8 +126,8 @@ def prepare_form_grid(slots: Dict[str, Slot]):
|
|||
return fields
|
||||
|
||||
|
||||
@register("form_label")
|
||||
class FormLabel(Component):
|
||||
@register("form_grid_label")
|
||||
class FormGridLabel(Component):
|
||||
template: types.django_html = """
|
||||
<label for="{{ field_name }}" class="font-semibold text-gray-700">
|
||||
{{ title }}
|
||||
BIN
dev/examples/form_grid/images/form.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
dev/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
|
||||
|
||||
|
||||
class FormPage(Component):
|
||||
class FormGridPage(Component):
|
||||
class Media:
|
||||
js = (
|
||||
# AlpineJS
|
||||
|
|
@ -16,7 +16,7 @@ class FormPage(Component):
|
|||
template: types.django_html = """
|
||||
<html>
|
||||
<head>
|
||||
<title>Form</title>
|
||||
<title>FormGrid</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp,container-queries"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -29,7 +29,7 @@ class FormPage(Component):
|
|||
<h3>Submit form</h3>
|
||||
</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:style="max-width: 600px;"
|
||||
attrs:@submit.prevent="onSubmit"
|
||||
|
|
@ -56,7 +56,7 @@ class FormPage(Component):
|
|||
|
||||
{# Defined both label and field because label name is different from field name #}
|
||||
{% fill "label:description" %}
|
||||
{% component "form_label" field_name="description" title="Marvelous description" / %}
|
||||
{% component "form_grid_label" field_name="description" title="Marvelous description" / %}
|
||||
{% endfill %}
|
||||
{% fill "field:description" %}
|
||||
<textarea
|
||||
|
|
@ -85,4 +85,4 @@ class FormPage(Component):
|
|||
|
||||
class View:
|
||||
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
|
||||
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_label", FormLabel)
|
||||
registry.register("form_grid", FormGrid)
|
||||
registry.register("form_grid_label", FormGridLabel)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -21,7 +21,7 @@ class TestExampleForm:
|
|||
_create_form_components()
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "form" %}
|
||||
{% component "form_grid" %}
|
||||
{% fill "field:project" %}<input name="project">{% endfill %}
|
||||
{% fill "field:option" %}<select name="option"></select>{% endfill %}
|
||||
{% endcomponent %}
|
||||
|
|
@ -53,7 +53,7 @@ class TestExampleForm:
|
|||
_create_form_components()
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "form" %}
|
||||
{% component "form_grid" %}
|
||||
{% fill "label:project" %}<strong>Custom Project Label</strong>{% endfill %}
|
||||
{% fill "field:project" %}<input name="project">{% endfill %}
|
||||
{% endcomponent %}
|
||||
|
|
@ -68,7 +68,7 @@ class TestExampleForm:
|
|||
_create_form_components()
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "form" %}
|
||||
{% component "form_grid" %}
|
||||
{% fill "label:project" %}Custom Project Label{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
|
|
@ -81,7 +81,7 @@ class TestExampleForm:
|
|||
_create_form_components()
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "form" %}
|
||||
{% component "form_grid" %}
|
||||
{% fill "prepend" %}<div>Prepended content</div>{% endfill %}
|
||||
{% fill "field:project" %}<input name="project">{% endfill %}
|
||||
{% fill "append" %}<div>Appended content</div>{% endfill %}
|
||||
66
dev/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
dev/examples/form_submission/images/form_submission.gif
Normal file
|
After Width: | Height: | Size: 354 KiB |
224
dev/examples/form_submission/index.html
Normal file
36
dev/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)
|
||||
39
dev/examples/form_submission/test_example_form_submission.py
Normal 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
|
||||
79
dev/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
dev/examples/fragments/images/fragments.gif
Normal file
|
After Width: | Height: | Size: 382 KiB |
342
dev/examples/fragments/index.html
Normal file
141
dev/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
dev/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
|
||||
34
dev/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
dev/examples/recursion/images/recursion.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
192
dev/examples/recursion/index.html
Normal file
35
dev/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
dev/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 types as t
|
||||
|
||||
DESCRIPTION = "Dynamic tabs with AlpineJS."
|
||||
|
||||
|
||||
class TabDatum(NamedTuple):
|
||||
"""Datum for an individual tab."""
|
||||
|
|
@ -230,7 +232,7 @@ class _TablistImpl(Component):
|
|||
@register("Tablist")
|
||||
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
|
||||
component that renders the content.
|
||||
|
|
|
|||