Skip to content

Commit c056208

Browse files
Juan Pablo Mansonclaude
andcommitted
Phase 2: Dynamic Form Logic — conditional visibility, dependent options, multi-step wizard
- Add VisibilityRule for declarative field visibility (visible_when) - Add DependentOptionsConfig for dynamic option dependencies - Add FormStep model for multi-step wizard forms with per-step validation - Add evaluate_visibility() and validate_form_data_dynamic() opt-in APIs - Add Form.get_steps(), validate_step(), validate_all_steps(), get_visible_fields() - Replace duck-typing with isinstance() checks in forms.py, export.py - Update resolve_content_item with explicit type discriminator priority (RISK-1) - Add step_to_html() rendering with <section> (distinct from FieldGroup <fieldset>) - Add schema_version to Form for forward compatibility (RISK-5) - Define __all__ in __init__.py for controlled exports (RISK-6) - Add i18n messages for wizard/visibility (en + es) - All legacy APIs unchanged — 100% backward compatible - Bump version to 0.2.0 - 151 tests passing (88 original + 63 new) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b619636 commit c056208

15 files changed

Lines changed: 1912 additions & 24 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ wheels/
88
# Virtual environments
99
.venv
1010

11+
# Private
1112
todo
13+
info
1214

1315
# Agents
1416
CLAUDE.md

examples/conditional_visibility.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""
2+
Ejemplo de visibilidad condicional con visible_when.
3+
4+
Demuestra cómo ocultar/mostrar campos según el valor de otros campos,
5+
y la diferencia entre validación legacy y dinámica.
6+
"""
7+
8+
from codeforms import (
9+
Form,
10+
TextField,
11+
SelectField,
12+
SelectOption,
13+
VisibilityRule,
14+
validate_form_data,
15+
validate_form_data_dynamic,
16+
)
17+
18+
19+
def create_address_form() -> Form:
20+
"""Crea un formulario de dirección con campos condicionales."""
21+
return Form(
22+
name="address_form",
23+
fields=[
24+
SelectField(
25+
name="country",
26+
label="Country",
27+
required=True,
28+
options=[
29+
SelectOption(value="US", label="United States"),
30+
SelectOption(value="AR", label="Argentina"),
31+
SelectOption(value="UK", label="United Kingdom"),
32+
],
33+
),
34+
# Solo visible cuando country == "US"
35+
TextField(
36+
name="state",
37+
label="State",
38+
required=True,
39+
visible_when=[
40+
VisibilityRule(field="country", operator="equals", value="US"),
41+
],
42+
),
43+
# Solo visible cuando country == "AR"
44+
TextField(
45+
name="province",
46+
label="Province",
47+
required=True,
48+
visible_when=[
49+
VisibilityRule(field="country", operator="equals", value="AR"),
50+
],
51+
),
52+
# Solo visible cuando country == "UK"
53+
TextField(
54+
name="county",
55+
label="County",
56+
required=True,
57+
visible_when=[
58+
VisibilityRule(field="country", operator="equals", value="UK"),
59+
],
60+
),
61+
# Siempre visible
62+
TextField(
63+
name="city",
64+
label="City",
65+
required=True,
66+
),
67+
],
68+
)
69+
70+
71+
if __name__ == "__main__":
72+
form = create_address_form()
73+
data_us = {"country": "US", "state": "California", "city": "Los Angeles"}
74+
data_ar = {"country": "AR", "province": "Buenos Aires", "city": "CABA"}
75+
76+
# --- Legacy validation: ignores visible_when ---
77+
print("=== Legacy validate_form_data (ignores visible_when) ===")
78+
result = validate_form_data(form, data_us)
79+
print(f"US data: success={result['success']}")
80+
if not result["success"]:
81+
print(f" Errors: {result['errors']}")
82+
print(" (Province and County are required but missing — legacy doesn't know they're hidden)")
83+
84+
# --- Dynamic validation: respects visible_when ---
85+
print("\n=== Dynamic validate_form_data_dynamic (respects visible_when) ===")
86+
result = validate_form_data_dynamic(form, data_us, respect_visibility=True)
87+
print(f"US data: success={result['success']}")
88+
if result["success"]:
89+
print(f" Validated data: {result['data']}")
90+
print(" (Province and County are hidden, so not validated)")
91+
92+
result = validate_form_data_dynamic(form, data_ar, respect_visibility=True)
93+
print(f"AR data: success={result['success']}")
94+
if result["success"]:
95+
print(f" Validated data: {result['data']}")
96+
97+
# --- Visible fields helper ---
98+
print("\n=== get_visible_fields ===")
99+
visible = form.get_visible_fields(data_us)
100+
print(f"Visible fields for US: {[f.name for f in visible]}")
101+
102+
visible = form.get_visible_fields(data_ar)
103+
print(f"Visible fields for AR: {[f.name for f in visible]}")

examples/dependent_options.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""
2+
Ejemplo de opciones dependientes con DependentOptionsConfig.
3+
4+
Demuestra cómo definir campos cuyos opciones cambian según el valor
5+
de otro campo (por ejemplo, país → ciudades).
6+
"""
7+
8+
from codeforms import (
9+
Form,
10+
SelectField,
11+
SelectOption,
12+
DependentOptionsConfig,
13+
)
14+
15+
16+
def create_location_form() -> Form:
17+
"""Crea un formulario con ciudades dependientes del país seleccionado."""
18+
return Form(
19+
name="location_form",
20+
fields=[
21+
SelectField(
22+
name="country",
23+
label="Country",
24+
required=True,
25+
options=[
26+
SelectOption(value="US", label="United States"),
27+
SelectOption(value="AR", label="Argentina"),
28+
],
29+
),
30+
SelectField(
31+
name="city",
32+
label="City",
33+
required=True,
34+
# Opciones estáticas (todas las ciudades posibles para HTML rendering)
35+
options=[
36+
SelectOption(value="nyc", label="New York City"),
37+
SelectOption(value="la", label="Los Angeles"),
38+
SelectOption(value="bsas", label="Buenos Aires"),
39+
SelectOption(value="cor", label="Córdoba"),
40+
],
41+
# Metadata de dependencia (para lógica dinámica en frontend o backend)
42+
dependent_options=DependentOptionsConfig(
43+
depends_on="country",
44+
options_map={
45+
"US": [
46+
SelectOption(value="nyc", label="New York City"),
47+
SelectOption(value="la", label="Los Angeles"),
48+
],
49+
"AR": [
50+
SelectOption(value="bsas", label="Buenos Aires"),
51+
SelectOption(value="cor", label="Córdoba"),
52+
],
53+
},
54+
),
55+
),
56+
],
57+
)
58+
59+
60+
if __name__ == "__main__":
61+
form = create_location_form()
62+
63+
# La metadata de dependencia se serializa a JSON
64+
import json
65+
data = json.loads(form.model_dump_json(exclude_none=True))
66+
city_field = data["content"][1]
67+
print("City field dependent_options:")
68+
print(json.dumps(city_field["dependent_options"], indent=2))
69+
70+
# Las opciones específicas para cada país
71+
dep = form.fields[1].dependent_options
72+
print(f"\nOptions for US: {[o.label for o in dep.options_map['US']]}")
73+
print(f"Options for AR: {[o.label for o in dep.options_map['AR']]}")

examples/wizard_form.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""
2+
Ejemplo de formulario multi-paso (wizard) con FormStep.
3+
4+
Demuestra cómo crear un formulario wizard con validación por paso
5+
y validación global.
6+
"""
7+
8+
from codeforms import (
9+
Form,
10+
FormStep,
11+
TextField,
12+
EmailField,
13+
NumberField,
14+
SelectField,
15+
SelectOption,
16+
CheckboxField,
17+
FieldGroup,
18+
validate_form_data_dynamic,
19+
)
20+
21+
22+
def create_registration_wizard() -> Form:
23+
"""Crea un formulario wizard de registro de usuario en 3 pasos."""
24+
return Form(
25+
name="registration_wizard",
26+
content=[
27+
# Paso 1: Información personal
28+
FormStep(
29+
title="Personal Information",
30+
description="Tell us about yourself",
31+
content=[
32+
TextField(name="first_name", label="First Name", required=True),
33+
TextField(name="last_name", label="Last Name", required=True),
34+
EmailField(name="email", label="Email", required=True),
35+
],
36+
),
37+
# Paso 2: Preferencias
38+
FormStep(
39+
title="Preferences",
40+
description="Choose your plan and preferences",
41+
content=[
42+
SelectField(
43+
name="plan",
44+
label="Plan",
45+
required=True,
46+
options=[
47+
SelectOption(value="free", label="Free"),
48+
SelectOption(value="pro", label="Professional"),
49+
SelectOption(value="enterprise", label="Enterprise"),
50+
],
51+
),
52+
NumberField(
53+
name="team_size",
54+
label="Team Size",
55+
min_value=1,
56+
max_value=1000,
57+
),
58+
],
59+
),
60+
# Paso 3: Confirmación
61+
FormStep(
62+
title="Confirmation",
63+
description="Review and accept the terms",
64+
content=[
65+
CheckboxField(
66+
name="terms",
67+
label="I accept the terms and conditions",
68+
required=True,
69+
),
70+
],
71+
validation_mode="on_submit",
72+
),
73+
],
74+
)
75+
76+
77+
if __name__ == "__main__":
78+
form = create_registration_wizard()
79+
80+
# Verificar estructura
81+
print(f"Form: {form.name}")
82+
print(f"Steps: {len(form.get_steps())}")
83+
print(f"Total fields: {len(form.fields)}")
84+
85+
for i, step in enumerate(form.get_steps()):
86+
print(f"\n Step {i + 1}: {step.title}")
87+
for field in step.fields:
88+
print(f" - {field.name} ({'required' if field.required else 'optional'})")
89+
90+
# Validar paso 1
91+
print("\n--- Validating Step 1 ---")
92+
result = form.validate_step(0, {
93+
"first_name": "John",
94+
"last_name": "Doe",
95+
"email": "john@example.com",
96+
})
97+
print(f"Step 1 valid: {result['success']}")
98+
99+
# Validar paso 2
100+
print("\n--- Validating Step 2 ---")
101+
result = form.validate_step(1, {
102+
"plan": "pro",
103+
"team_size": 5,
104+
})
105+
print(f"Step 2 valid: {result['success']}")
106+
107+
# Validar todos los pasos
108+
print("\n--- Validating All Steps ---")
109+
result = form.validate_all_steps({
110+
"first_name": "John",
111+
"last_name": "Doe",
112+
"email": "john@example.com",
113+
"plan": "pro",
114+
"team_size": 5,
115+
"terms": True,
116+
})
117+
print(f"All steps valid: {result['success']}")
118+
119+
# Exportar HTML
120+
print("\n--- HTML Export ---")
121+
export = form.export("html_bootstrap5")
122+
print(export["output"][:300] + "...")
123+
124+
# JSON roundtrip
125+
print("\n--- JSON Roundtrip ---")
126+
json_str = form.model_dump_json()
127+
restored = Form.model_validate_json(json_str)
128+
print(f"Restored steps: {len(restored.get_steps())}")
129+
print(f"Restored fields: {len(restored.fields)}")

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.1.1"
3+
version = "0.2.0"
44
description = "Python library for creating, validating, and rendering web forms using Pydantic"
55
readme = "README.md"
66
requires-python = ">=3.9"

src/codeforms/__init__.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from codeforms.fields import (
22
FieldType,
33
ValidationRule,
4+
VisibilityRule,
5+
DependentOptionsConfig,
46
FormFieldBase,
57
SelectOption,
68
CheckboxField,
@@ -17,8 +19,15 @@
1719
TextareaField,
1820
ListField,
1921
FieldGroup,
22+
FormStep,
23+
)
24+
from codeforms.forms import (
25+
Form,
26+
FormDataValidator,
27+
validate_form_data,
28+
evaluate_visibility,
29+
validate_form_data_dynamic,
2030
)
21-
from codeforms.forms import Form, FormDataValidator, validate_form_data
2231
from codeforms.export import ExportFormat
2332
from codeforms.i18n import (
2433
t,
@@ -32,3 +41,46 @@
3241
register_field_type,
3342
get_registered_field_types,
3443
)
44+
45+
__all__ = [
46+
# Field types and base
47+
"FieldType",
48+
"ValidationRule",
49+
"VisibilityRule",
50+
"DependentOptionsConfig",
51+
"FormFieldBase",
52+
"SelectOption",
53+
"CheckboxField",
54+
"CheckboxGroupField",
55+
"RadioField",
56+
"SelectField",
57+
"TextField",
58+
"EmailField",
59+
"NumberField",
60+
"DateField",
61+
"FileField",
62+
"HiddenField",
63+
"UrlField",
64+
"TextareaField",
65+
"ListField",
66+
"FieldGroup",
67+
"FormStep",
68+
# Form
69+
"Form",
70+
"FormDataValidator",
71+
"validate_form_data",
72+
"evaluate_visibility",
73+
"validate_form_data_dynamic",
74+
# Export
75+
"ExportFormat",
76+
# i18n
77+
"t",
78+
"set_locale",
79+
"get_locale",
80+
"get_available_locales",
81+
"register_locale",
82+
"get_messages",
83+
# Registry
84+
"register_field_type",
85+
"get_registered_field_types",
86+
]

0 commit comments

Comments
 (0)