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

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

View file

@ -0,0 +1,125 @@
# FormGrid
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`.
```django
{% component "form_grid" %}
{% fill "field:project" %}
<input name="project" required>
{% endfill %}
{% fill "field:option" %}
<select name="option" required>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
<option value="3">Option 3</option>
</select>
{% endfill %}
{% endcomponent %}
```
This will render a `<form>` where fields are defined using `field:<field_name>` slots:
![Form example](images/form.png)
Labels are automatically generated from the field name. If you want to define a custom label for a field,
you can use the `label:<field_name>` slot.
```django
{% component "form_grid" %}
{# Custom label for "description" field #}
{% fill "label:description" %}
{% component "form_grid_label"
field_name="description"
title="Marvelous description"
/ %}
{% endfill %}
{% fill "field:description" %}
<textarea name="description" required></textarea>
{% endfill %}
{% endcomponent %}
```
Whether you define custom labels or not, the form will have the following structure:
![Form structure](images/form_structure.png)
## API
### `FormGrid` component
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.
- **`method`** (optional, default `"post"`): The HTTP method for the form submission.
- **`form_content_attrs`** (optional): A dictionary of HTML attributes to be added to the form's content container.
- **`attrs`** (optional): A dictionary of HTML attributes to be added to the `<form>` element itself.
To define the fields, you define a slot for each field.
**Slots:**
- **`field:<field_name>`**: Use this slot to define a form field. The component will automatically generate a label for it based on `<field_name>`.
- **`label:<field_name>`**: If you need a custom label for a field, you can define it using this slot.
- **`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.
### `FormGridLabel` 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 `FormGridLabel` component explicitly in `label:<field_name>` slots.
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.
- **`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.
**Example:**
```django
{% component "form_grid_label"
field_name="user_name"
title="Your Name"
/ %}
```
This will render:
```html
<label for="user_name" class="font-semibold text-gray-700"> Your Name </label>
```
If `title` is not provided, `field_name="user_name"` would automatically generate the title "User Name",
converting snake_case to "Title Case".
## Definition
```djc_py
--8<-- "docs/examples/form_grid/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_grid/page.py"
```
### `urls.py`
```python
from django.urls import path
from examples.pages.form_grid import FormGridPage
urlpatterns = [
path("examples/form_grid", FormGridPage.as_view(), name="form_grid"),
]
```

View file

@ -0,0 +1,150 @@
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."""
class Kwargs(NamedTuple):
editable: bool = True
method: str = "post"
form_content_attrs: Optional[dict] = None
attrs: Optional[dict] = None
def get_template_data(self, args, kwargs: Kwargs, slots: Dict[str, Slot], context):
fields = prepare_form_grid(slots)
return {
"form_content_attrs": kwargs.form_content_attrs,
"method": kwargs.method,
"editable": kwargs.editable,
"attrs": kwargs.attrs,
"fields": fields,
}
template: types.django_html = """
<form
{% if submit_href and editable %} action="{{ submit_href }}" {% endif %}
method="{{ method }}"
{% html_attrs attrs %}
>
{% slot "prepend" / %}
<div {% html_attrs form_content_attrs %}>
{# Generate a grid of fields and labels out of given slots #}
<div class="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 items-center">
{% for field_name, label in fields %}
{{ label }}
{% slot name=field_name / %}
{% endfor %}
</div>
</div>
{% slot "append" / %}
</form>
"""
# Users of this component can define form fields as slots.
#
# For example:
# ```django
# {% component "form" %}
# {% fill "field:field_1" / %}
# <textarea name="field_1" />
# {% endfill %}
# {% fill "field:field_2" / %}
# <select name="field_2">
# <option value="1">Option 1</option>
# <option value="2">Option 2</option>
# </select>
# {% endfill %}
# {% endcomponent %}
# ```
#
# The above will automatically generate labels for the fields,
# and the form will be aligned with a grid.
#
# To explicitly define a label, use `label:<field_name>` slot name.
#
# For example:
# ```django
# {% component "form" %}
# {% fill "label:field_1" / %}
# <label for="field_1">Label 1</label>
# {% endfill %}
# {% fill "field:field_1" / %}
# <textarea name="field_1" />
# {% endfill %}
# {% endcomponent %}
# ```
def prepare_form_grid(slots: Dict[str, Slot]):
used_labels: Set[str] = set()
unused_labels: Set[str] = set()
fields: List[Tuple[str, str]] = []
for slot_name in slots:
# Case: Label slot
is_label = slot_name.startswith("label:")
if is_label and slot_name not in used_labels:
unused_labels.add(slot_name)
continue
# Case: non-field, non-label slot
is_field = slot_name.startswith("field:")
if not is_field:
continue
# Case: Field slot
field_name = slot_name.split(":", 1)[1]
label_slot_name = f"label:{field_name}"
label = None
if label_slot_name in slots:
# Case: Component user explicitly defined how to render the label
label_slot: Slot[Any] = slots[label_slot_name]
label = label_slot()
unused_labels.discard(label_slot_name)
used_labels.add(slot_name)
else:
# Case: Component user didn't explicitly define how to render the label
# We will create the label for the field automatically
label = FormGridLabel.render(
kwargs=FormGridLabel.Kwargs(field_name=field_name),
deps_strategy="ignore",
)
fields.append((slot_name, label))
if unused_labels:
raise ValueError(f"Unused labels: {unused_labels}")
return fields
@register("form_grid_label")
class FormGridLabel(Component):
template: types.django_html = """
<label for="{{ field_name }}" class="font-semibold text-gray-700">
{{ title }}
</label>
"""
class Kwargs(NamedTuple):
field_name: str
title: Optional[str] = None
def get_template_data(self, args, kwargs: Kwargs, slots, context):
if kwargs.title:
title = kwargs.title
else:
title = kwargs.field_name.replace("_", " ").replace("-", " ").title()
return {
"field_name": kwargs.field_name,
"title": title,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View file

@ -0,0 +1,88 @@
from django.http import HttpRequest
from django.utils.safestring import mark_safe
from django_components import Component, types
class FormGridPage(Component):
class Media:
js = (
# AlpineJS
mark_safe('<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>'),
# TailwindCSS
"https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4",
)
template: types.django_html = """
<html>
<head>
<title>FormGrid</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp,container-queries"></script>
</head>
<body>
<div x-data="{
onSubmit: () => {
alert('Submitted!');
}
}">
<div class="prose-xl p-6">
<h3>Submit form</h3>
</div>
{% 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"
%}
{% fill "field:project" %}
<input
name="project"
required
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
>
{% endfill %}
{% fill "field:option" %}
<select
name="option"
required
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:max-w-xs sm:text-sm sm:leading-6"
>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
<option value="3">Option 3</option>
</select>
{% endfill %}
{# Defined both label and field because label name is different from field name #}
{% fill "label:description" %}
{% component "form_grid_label" field_name="description" title="Marvelous description" / %}
{% endfill %}
{% fill "field:description" %}
<textarea
name="description"
id="description"
rows="5"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
></textarea>
{% endfill %}
{% fill "append" %}
<div class="flex justify-end items-center gap-x-6 border-t border-gray-900/10 py-4">
<button type="submit" class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Submit
</button>
<button type="button" class="text-sm font-semibold leading-6 text-gray-900">
Cancel
</button>
</div>
{% endfill %}
{% endcomponent %}
</div>
</body>
</html>
""" # noqa: E501
class View:
def get(self, request: HttpRequest):
return FormGridPage.render_to_response(request=request)

View file

@ -0,0 +1,96 @@
import pytest
from django.template import Context, Template
from pytest_django.asserts import assertHTMLEqual
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_form_components():
from docs.examples.form_grid.component import FormGrid, FormGridLabel # noqa: PLC0415
registry.register("form_grid", FormGrid)
registry.register("form_grid_label", FormGridLabel)
@pytest.mark.django_db
@djc_test
class TestExampleForm:
def test_render_simple_form(self):
_create_form_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "form_grid" %}
{% fill "field:project" %}<input name="project">{% endfill %}
{% fill "field:option" %}<select name="option"></select>{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"""
<form method="post" data-djc-id-ca1bc41>
<div>
<div class="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 items-center">
<label for="project" class="font-semibold text-gray-700" data-djc-id-ca1bc42>
Project
</label>
<input name="project">
<label for="option" class="font-semibold text-gray-700" data-djc-id-ca1bc43>
Option
</label>
<select name="option"></select>
</div>
</div>
</form>
""",
)
def test_custom_label(self):
_create_form_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "form_grid" %}
{% fill "label:project" %}<strong>Custom Project Label</strong>{% endfill %}
{% fill "field:project" %}<input name="project">{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert "<strong>Custom Project Label</strong>" in rendered
assert '<label for="project"' not in rendered
def test_unused_label_raises_error(self):
_create_form_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "form_grid" %}
{% fill "label:project" %}Custom Project Label{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
with pytest.raises(ValueError, match=r"Unused labels: {'label:project'}"):
template.render(Context({}))
def test_prepend_append_slots(self):
_create_form_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "form_grid" %}
{% fill "prepend" %}<div>Prepended content</div>{% endfill %}
{% fill "field:project" %}<input name="project">{% endfill %}
{% fill "append" %}<div>Appended content</div>{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert "<div>Prepended content</div>" in rendered
assert "<div>Appended content</div>" in rendered
assert rendered.find("Prepended content") < rendered.find("project")
assert rendered.find("Appended content") > rendered.find("project")