Skip to content

Commit f579e5a

Browse files
Juan Pablo Mansonclaude
andcommitted
Fix textarea and hidden field HTML export
TextareaField was exported as `<input type="textarea">`. That input type does not exist, so browsers degrade it to `type="text"`: the field rendered as a single line, could not hold line breaks, and Enter triggered the browser's implicit form submission. - Render TextareaField as a real `<textarea>`, with the value as element content (HTML-escaped, so a value containing `</textarea>` can no longer break the markup) instead of a `value` attribute. - Emit the `rows`, `cols`, `minlength` and `maxlength` attributes, which were declared on the model but never reached the HTML. - Honour `readonly` on textarea and regular inputs. - Render hidden fields as a bare `<input type="hidden">`, without the `form-group` wrapper and `form-control` class that reserved visible vertical space for every hidden field in a form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 66d5f93 commit f579e5a

3 files changed

Lines changed: 212 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "codeforms"
3-
version = "0.2.2"
3+
version = "0.2.3"
44
description = "Python library for creating, validating, and rendering web forms using Pydantic"
55
readme = "README.md"
66
requires-python = ">=3.9"

src/codeforms/export.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from enum import Enum
2+
from html import escape as html_escape
23
from typing import Any, Dict
34

45
from codeforms.fields import (
@@ -322,6 +323,24 @@ def field_to_html(field: FormFieldBase, **kwargs) -> str:
322323
form_group_class = "form-field"
323324
help_text_class = "help-text"
324325

326+
# Un campo oculto no lleva label, clases de presentacion ni wrapper: el
327+
# <div class="form-group"> reservaba espacio vertical visible por cada
328+
# hidden del formulario.
329+
if field.field_type_value == "hidden":
330+
hidden_attrs = {
331+
"id": str(field.id),
332+
"name": field.name,
333+
"type": "hidden",
334+
}
335+
hidden_value = field.default_value
336+
if hidden_value is None:
337+
hidden_value = getattr(field, "value", None)
338+
if hidden_value is not None:
339+
hidden_attrs["value"] = str(hidden_value)
340+
hidden_attrs.update(field.attributes)
341+
attrs_str = " ".join(f'{k}="{v}"' for k, v in hidden_attrs.items() if v)
342+
return f"<input {attrs_str}>"
343+
325344
skip_label = ["hidden"]
326345

327346
label_html = ""
@@ -422,6 +441,43 @@ def field_to_html(field: FormFieldBase, **kwargs) -> str:
422441
'<div class="checkbox-group">' + "".join(checkbox_html_parts) + "</div>"
423442
)
424443

444+
# Manejar campos TEXTAREA de manera especial
445+
# Un textarea no es un <input>: necesita etiqueta propia y el valor va como
446+
# contenido, no como atributo. Renderizarlo como <input type="textarea">
447+
# produce un campo de una sola linea (el navegador lo degrada a type="text"),
448+
# perdiendo el salto de linea y disparando el submit implicito con Enter.
449+
elif field.field_type_value == "textarea":
450+
textarea_attrs = {
451+
"id": str(field.id),
452+
"name": field.name,
453+
"class": f"{base_input_class} {field.css_classes or ''}".strip(),
454+
"placeholder": field.placeholder or "",
455+
}
456+
457+
for attr_name in ("rows", "cols", "minlength", "maxlength"):
458+
attr_value = getattr(field, attr_name, None)
459+
if attr_value is not None:
460+
textarea_attrs[attr_name] = str(attr_value)
461+
462+
if field.required:
463+
textarea_attrs["required"] = "required"
464+
465+
if field.readonly:
466+
textarea_attrs["readonly"] = "readonly"
467+
468+
# Agregar atributos personalizados
469+
textarea_attrs.update(field.attributes)
470+
471+
attrs_str = " ".join(f'{k}="{v}"' for k, v in textarea_attrs.items() if v)
472+
473+
# El contenido se escapa: un valor con "</textarea>" romperia el markup
474+
content = field.default_value
475+
if content is None:
476+
content = getattr(field, "value", None)
477+
content_html = html_escape(str(content), quote=False) if content else ""
478+
479+
input_html = f"<textarea {attrs_str}>{content_html}</textarea>"
480+
425481
# Manejar campos normales (input)
426482
else:
427483
attributes = {
@@ -438,6 +494,9 @@ def field_to_html(field: FormFieldBase, **kwargs) -> str:
438494
if field.required:
439495
attributes["required"] = "required"
440496

497+
if field.readonly:
498+
attributes["readonly"] = "readonly"
499+
441500
if field.default_value is not None:
442501
attributes["value"] = str(field.default_value)
443502

tests/test_textarea_export.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from codeforms import Form
6+
from codeforms.fields import TextareaField
7+
8+
9+
def _export(field: TextareaField, output_format: str = "html_bootstrap4") -> str:
10+
form = Form(name="comment_form", title="Comentario", content=[field])
11+
return form.export(output_format=output_format, id="form1")["output"]
12+
13+
14+
@pytest.mark.parametrize("output_format", ["html", "html_bootstrap4", "html_bootstrap5"])
15+
def test_textarea_renders_as_textarea_tag(output_format):
16+
"""Un textarea debe emitirse como <textarea>, nunca como <input>."""
17+
html = _export(TextareaField(name="comentario", label="Comentario"), output_format)
18+
19+
assert "<textarea " in html
20+
assert "</textarea>" in html
21+
# El bug original: <input type="textarea"> el navegador lo degrada a
22+
# type="text", quedando un campo de una sola linea.
23+
assert 'type="textarea"' not in html
24+
25+
26+
def test_textarea_keeps_name_and_id():
27+
field = TextareaField(name="comentario", label="Comentario")
28+
html = _export(field)
29+
30+
assert 'name="comentario"' in html
31+
assert f'id="{field.id}"' in html
32+
33+
34+
def test_textarea_emits_size_and_length_attributes():
35+
html = _export(
36+
TextareaField(
37+
name="comentario",
38+
label="Comentario",
39+
rows=8,
40+
cols=40,
41+
minlength=10,
42+
maxlength=500,
43+
)
44+
)
45+
46+
assert 'rows="8"' in html
47+
assert 'cols="40"' in html
48+
assert 'minlength="10"' in html
49+
assert 'maxlength="500"' in html
50+
51+
52+
def test_textarea_default_rows_is_emitted():
53+
"""rows tiene default 3 en el modelo y debe llegar al HTML."""
54+
html = _export(TextareaField(name="comentario", label="Comentario"))
55+
56+
assert 'rows="3"' in html
57+
58+
59+
def test_textarea_required_and_readonly():
60+
html = _export(
61+
TextareaField(
62+
name="comentario", label="Comentario", required=True, readonly=True
63+
)
64+
)
65+
66+
assert 'required="required"' in html
67+
assert 'readonly="readonly"' in html
68+
69+
70+
def test_textarea_value_goes_in_content_not_attribute():
71+
html = _export(
72+
TextareaField(
73+
name="comentario", label="Comentario", default_value="Primera linea"
74+
)
75+
)
76+
77+
assert ">Primera linea</textarea>" in html
78+
assert 'value="Primera linea"' not in html
79+
80+
81+
def test_textarea_content_is_escaped():
82+
"""Un valor con markup no debe poder cerrar el textarea."""
83+
html = _export(
84+
TextareaField(
85+
name="comentario",
86+
label="Comentario",
87+
default_value="</textarea><script>alert(1)</script>",
88+
)
89+
)
90+
91+
assert "<script>" not in html
92+
assert html.count("</textarea>") == 1
93+
94+
95+
def test_textarea_placeholder_and_custom_attributes():
96+
html = _export(
97+
TextareaField(
98+
name="comentario",
99+
label="Comentario",
100+
placeholder="Escribi un comentario",
101+
attributes={"data-test": "comentario"},
102+
)
103+
)
104+
105+
assert 'placeholder="Escribi un comentario"' in html
106+
assert 'data-test="comentario"' in html
107+
108+
109+
def test_textarea_gets_bootstrap_class():
110+
html = _export(TextareaField(name="comentario", label="Comentario"))
111+
112+
assert 'class="form-control"' in html
113+
114+
115+
def test_textarea_has_label_bound_to_field():
116+
field = TextareaField(name="comentario", label="Comentario")
117+
html = _export(field)
118+
119+
assert f'for="{field.id}"' in html
120+
assert ">Comentario</label>" in html
121+
122+
123+
def test_readonly_input_emits_readonly():
124+
from codeforms.fields import TextField
125+
126+
form = Form(
127+
name="f",
128+
title="t",
129+
content=[TextField(name="codigo", label="Codigo", readonly=True)],
130+
)
131+
html = form.export(output_format="html_bootstrap4", id="form1")["output"]
132+
133+
assert 'readonly="readonly"' in html
134+
135+
136+
def test_hidden_field_has_no_wrapper_or_presentation_classes():
137+
"""Los hidden no deben ocupar espacio ni arrastrar clases de presentacion."""
138+
from codeforms.fields import HiddenField
139+
140+
form = Form(
141+
name="f",
142+
title="t",
143+
content=[HiddenField(name="_action", value="approve", label=None)],
144+
)
145+
html = form.export(output_format="html_bootstrap4", id="form1")["output"]
146+
147+
assert 'name="_action"' in html
148+
assert 'value="approve"' in html
149+
assert 'type="hidden"' in html
150+
assert "form-group" not in html
151+
assert "form-control" not in html
152+
assert "<label" not in html

0 commit comments

Comments
 (0)