Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,54 @@

## [Unreleased]

### Fixed — `Notification` silently discarded its body content (#65)

- **Every theme, both engines, rendered the scalar `message` prop and nothing
else.** A caller writing the natural container form —
`<c-cf.notification type="danger">{{ error }}</c-cf.notification>` — got a
correctly styled, correctly coloured, **empty** box. In cotton this failed
silently: `<c-vars message ...>` carried no default, so `message` resolved to
the empty string and the box rendered anyway. In JinjaX it failed loudly but
wrongly — `message` was a required `{#def}` parameter, so a body-only call
raised `MissingRequiredArgument`. Both paths now render the body when one is
present and fall back to `message` when it is not. `message=` callers are
untouched; the JinjaX signature only loosens.
- `Notification` was the single outlier among the container-shaped components.
`card`, `modal`, `panel`, `prose` and `box` have always accepted a body
alongside their scalar props; the bodiless components (`breadcrumb`,
`pagination`, `table`, `progress`) are all data-driven, where children would
be meaningless. `Notification` is a container that happened to expose only a
string.
- **A body that renders to nothing is not a body.** django-cotton hands the
partial `nodelist.render(context)` verbatim, so a paired tag whose body
renders empty still supplies `"\n "` — truthy. Without a guard, a `message=`
caller writing a conditional body would get an empty box on the false branch,
which is this same bug with a new trigger. Both engines now treat a
whitespace-only body as absent and fall back to `message`.
- **The two operands want opposite escaping and now get it, under test.** JinjaX
wraps slot content in `Markup`, so a body passes through the template's
`{% autoescape true %}` block untouched; `message` is caller-supplied text and
is still escaped. Both halves are asserted per theme rather than left to
autoescape semantics.
- **Foundation's `<p>` now wraps only `message`, not the body.** A body is
arbitrary markup, and the HTML parser closes an open `<p>` on encountering
block content: `<p><ul>…</ul></p>` parses to `<p></p><ul>…</ul><p></p>`, which
reparents the body onto `.callout` and leaves two empty paragraphs for
Foundation's own `.callout > :first-child` / `> :last-child` margin rules to
match. Broadening the content channel is what made that reachable — before
this, the `<p>` only ever held a string. `message=` callers render
byte-identically to before. Fomantic's `<div class="content">` and
bootstrap/daisy's `<span>` hold block content without being restructured, and
are unchanged.
- **Covered at the tier that could have caught it.** The bug survived three
months because no tier that ran the django-cotton compiler looked at this
component's content channel: the unit tier injects `slot` as raw context
(`render_to_string` bypasses the compiler), and the integration tier does not
install `django_cotton` at all, so its `<c-cf.*>` tags reach the response as
literal text. The three cotton call forms — body, `message=`, and a
conditional body on its false branch — are now asserted in the E2E tier,
where `slot` is built by cotton itself.

## [0.3.0] — 2026-07-31

The primitives layer. 0.2.0 shipped 14 *structural* components — card, modal,
Expand Down
6 changes: 5 additions & 1 deletion docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,15 @@ Focus trapping, focus restoration, and `Escape`-to-close live in

| Prop | Default | Notes |
|---|---|---|
| `message` | *required* | |
| `content` | `""` | Body — the slot in template position; wins over `message` |
| `message` | `""` | Scalar alternative to the body, for a plain string |
| `type` | `"info"` | `info`, `success`, `warning`, `danger` |
| `dismissible` | `true` | Renders a close button |
| `extra_class` | `""` | |

Supply one or the other. A body passed as slot content is already-rendered
markup and is not escaped again; `message` is caller-supplied text and is.

### `Cf:Progress` / `<c-cf.progress>`

| Prop | Default | Notes |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
x-data="{ visible: true }"
x-show="visible"
>
<span>{{ message }}</span>
{% comment %} Body content wins over the scalar `message` — a caller who wrote children
meant them. `message` stays for the existing call form (#65). {% endcomment %}
<span>{% if slot.strip %}{{ slot }}{% else %}{{ message }}{% endif %}</span>
{% if dismissible == "true" %}
<button
type="button"
Expand Down
4 changes: 3 additions & 1 deletion src/cf_ui/templates/cotton/_themes/bulma/notification.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@
{% if dismissible == "true" %}
<button class="delete" @click="visible = false"></button>
{% endif %}
{{ message }}
{% comment %} Body content wins over the scalar `message` — a caller who wrote children
meant them. `message` stays for the existing call form (#65). {% endcomment %}
{% if slot.strip %}{{ slot }}{% else %}{{ message }}{% endif %}
</div>
4 changes: 3 additions & 1 deletion src/cf_ui/templates/cotton/_themes/daisy/notification.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
x-data="{ visible: true }"
x-show="visible"
>
<span>{{ message }}</span>
{% comment %} Body content wins over the scalar `message` — a caller who wrote children
meant them. `message` stays for the existing call form (#65). {% endcomment %}
<span>{% if slot.strip %}{{ slot }}{% else %}{{ message }}{% endif %}</span>
{% if dismissible == "true" %}
<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,7 @@
@click="visible = false"
>&times;</button>
{% endif %}
<div class="content">{{ message }}</div>
{% comment %} Body content wins over the scalar `message` — a caller who wrote children
meant them. `message` stays for the existing call form (#65). {% endcomment %}
<div class="content">{% if slot.strip %}{{ slot }}{% else %}{{ message }}{% endif %}</div>
</div>
28 changes: 27 additions & 1 deletion src/cf_ui/templates/cotton/_themes/foundation/notification.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,33 @@
x-data="{ visible: true }"
x-show="visible"
>
<p>{{ message }}</p>
{% comment %} Body content wins over the scalar `message` — a caller who wrote children
meant them. `message` stays for the existing call form (#65).

The `
<p>
` wraps only `message`. A body is arbitrary markup, and the HTML
parser closes an open `
<p>
` on encountering block content: `
<p>
<ul>
</ul>
</p>
`
parses to `
<p>
</p>
<ul>
</ul>
<p>
</p>
`, reparenting the body onto `.callout` and
leaving two empty paragraphs behind for `.callout > :last-child` to match.
`message` is text, so its wrapper renders byte-identically to before. {% endcomment %}
{% if slot.strip %}{{ slot }}{% else %}<p>{{ message }}</p>{% endif %}
{% if dismissible == "true" %}
<button
type="button"
Expand Down
5 changes: 4 additions & 1 deletion src/cf_ui/templates/cotton/cf/notification.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<c-vars message type="info" dismissible="true" class="" />
{% comment %} `message` now carries a default. Without one, django-cotton resolved a
body-form call's `message` to the empty string and rendered a correctly
styled, correctly coloured, empty box — silently (#65). {% endcomment %}
<c-vars message="" type="info" dismissible="true" class="" />
{% load cf_ui %}
{% cf_ui_theme_path "notification" as cf_ui_partial %}
{% include cf_ui_partial %}
10 changes: 8 additions & 2 deletions src/cf_ui/templates/jinja/bootstrap/Notification.jinja
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{#def message, type="info", dismissible=true, extra_class="" #}
{#def content="", message="", type="info", dismissible=true, extra_class="" #}
{% autoescape true %}
{% set content = content if content is defined else "" %}
{% set message = message if message is defined else "" %}
{% set type = type if type is defined else "info" %}
{% set dismissible = dismissible if dismissible is defined else true %}
{% set extra_class = extra_class if extra_class is defined else "" %}
Expand All @@ -9,7 +11,11 @@
x-data="{ visible: true }"
x-show="visible"
>
<span>{{ message }}</span>
{# Body content wins over the scalar `message` — a caller who wrote children
meant them. The two operands want opposite escaping and get it: JinjaX
wraps slot content in `Markup`, so it passes through this block
untouched, while `message` is caller-supplied text and is escaped. #}
<span>{{ content if content|trim else message }}</span>
{% if dismissible %}
<button
type="button"
Expand Down
10 changes: 8 additions & 2 deletions src/cf_ui/templates/jinja/bulma/Notification.jinja
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{#def message, type="info", dismissible=true, extra_class="" #}
{#def content="", message="", type="info", dismissible=true, extra_class="" #}
{% autoescape true %}
{% set content = content if content is defined else "" %}
{% set message = message if message is defined else "" %}
<div
class="notification is-{{ type }} {{ extra_class }}"
x-data="{ visible: true }"
Expand All @@ -8,6 +10,10 @@
{% if dismissible %}
<button class="delete" @click="visible = false"></button>
{% endif %}
{{ message }}
{# Body content wins over the scalar `message` — a caller who wrote children
meant them. The two operands want opposite escaping and get it: JinjaX
wraps slot content in `Markup`, so it passes through this block
untouched, while `message` is caller-supplied text and is escaped. #}
{{ content if content|trim else message }}
</div>
{% endautoescape %}
10 changes: 8 additions & 2 deletions src/cf_ui/templates/jinja/daisy/Notification.jinja
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{#def message, type="info", dismissible=true, extra_class="" #}
{#def content="", message="", type="info", dismissible=true, extra_class="" #}
{% autoescape true %}
{% set content = content if content is defined else "" %}
{% set message = message if message is defined else "" %}
{% set type = type if type is defined else "info" %}
{% set dismissible = dismissible if dismissible is defined else true %}
{% set extra_class = extra_class if extra_class is defined else "" %}
Expand All @@ -9,7 +11,11 @@
x-data="{ visible: true }"
x-show="visible"
>
<span>{{ message }}</span>
{# Body content wins over the scalar `message` — a caller who wrote children
meant them. The two operands want opposite escaping and get it: JinjaX
wraps slot content in `Markup`, so it passes through this block
untouched, while `message` is caller-supplied text and is escaped. #}
<span>{{ content if content|trim else message }}</span>
{% if dismissible %}
<button
type="button"
Expand Down
10 changes: 8 additions & 2 deletions src/cf_ui/templates/jinja/fomantic/Notification.jinja
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{#def message, type="info", dismissible=true, extra_class="" #}
{#def content="", message="", type="info", dismissible=true, extra_class="" #}
{% autoescape true %}
{% set content = content if content is defined else "" %}
{% set message = message if message is defined else "" %}
{% set type = type if type is defined else "info" %}
{% set dismissible = dismissible if dismissible is defined else true %}
{% set extra_class = extra_class if extra_class is defined else "" %}
Expand All @@ -20,6 +22,10 @@
@click="visible = false"
>&times;</button>
{% endif %}
<div class="content">{{ message }}</div>
{# Body content wins over the scalar `message` — a caller who wrote children
meant them. The two operands want opposite escaping and get it: JinjaX
wraps slot content in `Markup`, so it passes through this block
untouched, while `message` is caller-supplied text and is escaped. #}
<div class="content">{{ content if content|trim else message }}</div>
</div>
{% endautoescape %}
17 changes: 15 additions & 2 deletions src/cf_ui/templates/jinja/foundation/Notification.jinja
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{#def message, type="info", dismissible=true, extra_class="" #}
{#def content="", message="", type="info", dismissible=true, extra_class="" #}
{% autoescape true %}
{% set content = content if content is defined else "" %}
{% set message = message if message is defined else "" %}
{% set type = type if type is defined else "info" %}
{% set dismissible = dismissible if dismissible is defined else true %}
{% set extra_class = extra_class if extra_class is defined else "" %}
Expand All @@ -15,7 +17,18 @@
x-data="{ visible: true }"
x-show="visible"
>
<p>{{ message }}</p>
{# Body content wins over the scalar `message` — a caller who wrote children
meant them. The two operands want opposite escaping and get it: JinjaX
wraps slot content in `Markup`, so it passes through this block
untouched, while `message` is caller-supplied text and is escaped.

The `<p>` wraps only `message`. A body is arbitrary markup, and the HTML
parser closes an open `<p>` on encountering block content:
`<p><ul>…</ul></p>` parses to `<p></p><ul>…</ul><p></p>`, reparenting the
body onto `.callout` and leaving two empty paragraphs behind for
`.callout > :last-child` to match. `message` is text, so its wrapper
renders byte-identically to before. #}
{% if content|trim %}{{ content }}{% else %}<p>{{ message }}</p>{% endif %}
{% if dismissible %}
<button
type="button"
Expand Down
33 changes: 33 additions & 0 deletions tests/e2e/test_bulma_cotton.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,39 @@ def test_tabs_cotton_render_the_active_tab(cotton_page, cotton_server_url):
expect(page.locator("[role='tab'][tabindex='0']")).to_have_count(1)


# --- Body content through the real cotton compiler (#65) -------------------
#
# #65 survived three months because no tier that ran the compiler looked at
# notification's content channel: the unit tier injects `slot` as raw context
# (render_to_string bypasses the compiler), and the integration tier does not
# install django_cotton at all, so its `<c-cf.*>` tags reach the browser as
# literal text. Only here is `slot` built by cotton itself.


def test_notification_cotton_renders_its_body(cotton_page, cotton_server_url):
page, _ = cotton_page
page.goto(f"{cotton_server_url}/notification/")
expect(page.locator(".notification.is-danger strong")).to_have_text("Body form")


def test_notification_cotton_still_renders_the_message_form(cotton_page, cotton_server_url):
page, _ = cotton_page
page.goto(f"{cotton_server_url}/notification/")
expect(page.locator(".notification.is-info")).to_contain_text("Message form")


def test_notification_cotton_falls_back_when_the_body_is_whitespace(cotton_page, cotton_server_url):
"""A conditional body whose branch is false is not a body.

The compiler hands the partial the surrounding newlines regardless, so a
plain truthiness test on `slot` suppresses `message` and emits a correctly
styled, empty box — #65's own failure, one layer down.
"""
page, _ = cotton_page
page.goto(f"{cotton_server_url}/notification/")
expect(page.locator(".notification.is-warning")).to_contain_text("Fallback wins")


def test_panel_cotton_honors_its_open_prop(cotton_page, cotton_server_url):
page, _ = cotton_page
page.goto(f"{cotton_server_url}/panel/")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% comment %} Three call forms on one page, because the unit tier cannot tell them
apart: render_to_string bypasses the cotton compiler, so `slot` is whatever
the test injected rather than whatever the compiler built (#65). Each form
gets its own `type` so the E2E test can select it — the partial renders no
`{{ attrs }}`, so an `id` here would be silently dropped. {% endcomment %}
<c-cf.notification type="danger" dismissible="false">
<strong>Body form</strong>
</c-cf.notification>

<c-cf.notification message="Message form" type="info" dismissible="false" />

{% comment %} The false branch of a conditional body. The compiler still hands the
partial the surrounding whitespace, so a naive truthiness test on `slot`
would drop `message` here and render an empty box. {% endcomment %}
<c-cf.notification message="Fallback wins" type="warning" dismissible="false">
{% if error %}{{ error }}{% endif %}
</c-cf.notification>
1 change: 1 addition & 0 deletions tests/integration/cotton_app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@
path("card/", views.card_view),
path("tabs/", views.tabs_view),
path("panel/", views.panel_view),
path("notification/", views.notification_view),
]
6 changes: 6 additions & 0 deletions tests/integration/cotton_app/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,9 @@ def tabs_view(request):

def panel_view(request):
return render(request, "cotton_gallery/panel.html", {})


def notification_view(request):
# `error` deliberately absent, so the third component's conditional body
# renders to whitespace — the case that fooled a naive `{% if slot %}`.
return render(request, "cotton_gallery/notification.html", {})
Loading
Loading