codeforms 0.1.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {codeforms-0.1.1 → codeforms-0.2.0}/.gitignore +2 -0
  2. {codeforms-0.1.1 → codeforms-0.2.0}/PKG-INFO +1 -1
  3. codeforms-0.2.0/examples/conditional_visibility.py +103 -0
  4. codeforms-0.2.0/examples/dependent_options.py +73 -0
  5. codeforms-0.2.0/examples/wizard_form.py +129 -0
  6. {codeforms-0.1.1 → codeforms-0.2.0}/pyproject.toml +1 -1
  7. codeforms-0.2.0/src/codeforms/__init__.py +86 -0
  8. {codeforms-0.1.1 → codeforms-0.2.0}/src/codeforms/export.py +75 -6
  9. {codeforms-0.1.1 → codeforms-0.2.0}/src/codeforms/fields.py +84 -7
  10. {codeforms-0.1.1 → codeforms-0.2.0}/src/codeforms/forms.py +302 -1
  11. {codeforms-0.1.1 → codeforms-0.2.0}/src/codeforms/i18n.py +18 -0
  12. {codeforms-0.1.1 → codeforms-0.2.0}/src/codeforms/registry.py +18 -8
  13. codeforms-0.2.0/tests/test_dependent_options.py +118 -0
  14. codeforms-0.2.0/tests/test_dynamic_visibility.py +243 -0
  15. codeforms-0.2.0/tests/test_phase2_compat.py +311 -0
  16. codeforms-0.2.0/tests/test_wizard_steps.py +382 -0
  17. codeforms-0.1.1/src/codeforms/__init__.py +0 -34
  18. {codeforms-0.1.1 → codeforms-0.2.0}/.github/workflows/python-publish.yml +0 -0
  19. {codeforms-0.1.1 → codeforms-0.2.0}/.github/workflows/tests.yml +0 -0
  20. {codeforms-0.1.1 → codeforms-0.2.0}/.python-version +0 -0
  21. {codeforms-0.1.1 → codeforms-0.2.0}/LICENSE +0 -0
  22. {codeforms-0.1.1 → codeforms-0.2.0}/README.md +0 -0
  23. {codeforms-0.1.1 → codeforms-0.2.0}/examples/basic_usage.py +0 -0
  24. {codeforms-0.1.1 → codeforms-0.2.0}/examples/custom_fields.py +0 -0
  25. {codeforms-0.1.1 → codeforms-0.2.0}/examples/i18n_usage.py +0 -0
  26. {codeforms-0.1.1 → codeforms-0.2.0}/tests/__init__.py +0 -0
  27. {codeforms-0.1.1 → codeforms-0.2.0}/tests/conftest.py +0 -0
  28. {codeforms-0.1.1 → codeforms-0.2.0}/tests/test_basic_usage.py +0 -0
  29. {codeforms-0.1.1 → codeforms-0.2.0}/tests/test_i18n.py +0 -0
  30. {codeforms-0.1.1 → codeforms-0.2.0}/tests/test_registry.py +0 -0
  31. {codeforms-0.1.1 → codeforms-0.2.0}/uv.lock +0 -0
@@ -8,7 +8,9 @@ wheels/
8
8
  # Virtual environments
9
9
  .venv
10
10
 
11
+ # Private
11
12
  todo
13
+ info
12
14
 
13
15
  # Agents
14
16
  CLAUDE.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeforms
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Python library for creating, validating, and rendering web forms using Pydantic
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -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]}")
@@ -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']]}")
@@ -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)}")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codeforms"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "Python library for creating, validating, and rendering web forms using Pydantic"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -0,0 +1,86 @@
1
+ from codeforms.fields import (
2
+ FieldType,
3
+ ValidationRule,
4
+ VisibilityRule,
5
+ DependentOptionsConfig,
6
+ FormFieldBase,
7
+ SelectOption,
8
+ CheckboxField,
9
+ CheckboxGroupField,
10
+ RadioField,
11
+ SelectField,
12
+ TextField,
13
+ EmailField,
14
+ NumberField,
15
+ DateField,
16
+ FileField,
17
+ HiddenField,
18
+ UrlField,
19
+ TextareaField,
20
+ ListField,
21
+ 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,
30
+ )
31
+ from codeforms.export import ExportFormat
32
+ from codeforms.i18n import (
33
+ t,
34
+ set_locale,
35
+ get_locale,
36
+ get_available_locales,
37
+ register_locale,
38
+ get_messages,
39
+ )
40
+ from codeforms.registry import (
41
+ register_field_type,
42
+ get_registered_field_types,
43
+ )
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
+ ]
@@ -1,6 +1,6 @@
1
1
  from enum import Enum
2
2
  from codeforms.forms import Form
3
- from codeforms.fields import FormFieldBase
3
+ from codeforms.fields import FormFieldBase, FieldGroup, FormStep
4
4
  from codeforms.i18n import t
5
5
 
6
6
 
@@ -111,6 +111,17 @@ def group_exporter(group, output_format: str, **kwargs) -> str:
111
111
  return ""
112
112
 
113
113
 
114
+ def step_exporter(step, output_format: str, **kwargs) -> str:
115
+ """Exporta un paso de formulario (wizard) al formato especificado"""
116
+ if output_format in ('html', ExportFormat.HTML.value,
117
+ ExportFormat.BOOTSTRAP4.value,
118
+ ExportFormat.BOOTSTRAP5.value):
119
+ actual_kwargs = kwargs.get('kwargs', kwargs)
120
+ actual_kwargs['output_format'] = output_format
121
+ return step_to_html(step, **actual_kwargs)
122
+ return ""
123
+
124
+
114
125
  def group_to_html(group, **kwargs) -> str:
115
126
  """Genera la representación HTML del grupo de campos usando fieldset y legend"""
116
127
  output_format = kwargs.get('output_format', ExportFormat.HTML.value)
@@ -147,27 +158,85 @@ def group_to_html(group, **kwargs) -> str:
147
158
  html += description_html
148
159
  html += fields_html
149
160
  html += '</fieldset>'
150
-
161
+
162
+ return html
163
+
164
+
165
+ def step_to_html(step, **kwargs) -> str:
166
+ """Genera la representación HTML de un paso del wizard usando <section>.
167
+
168
+ Diferente de FieldGroup (que usa <fieldset>) para distinguir semánticamente.
169
+ """
170
+ output_format = kwargs.get('output_format', ExportFormat.HTML.value)
171
+ is_bootstrap = output_format in [ExportFormat.BOOTSTRAP4.value, ExportFormat.BOOTSTRAP5.value]
172
+
173
+ # Clases CSS para el section
174
+ step_class = f"form-step mb-4 {step.css_classes or ''}".strip() if is_bootstrap else f"form-step {step.css_classes or ''}".strip()
175
+ title_class = "h4 mb-3" if is_bootstrap else "step-title"
176
+
177
+ # Atributos del section
178
+ step_attrs = {
179
+ "id": f"step_{step.id}",
180
+ "class": step_class,
181
+ "data-step": "true",
182
+ "data-validation-mode": step.validation_mode,
183
+ "data-skippable": str(step.skippable).lower()
184
+ }
185
+ step_attrs.update(step.attributes)
186
+
187
+ attrs_str = " ".join(f'{k}="{v}"' for k, v in step_attrs.items() if v)
188
+
189
+ # Generar HTML del contenido (campos y/o grupos)
190
+ content_html_parts = []
191
+ for item in step.content:
192
+ if isinstance(item, FieldGroup):
193
+ content_html_parts.append(group_to_html(item, **kwargs))
194
+ else:
195
+ content_html_parts.append(field_to_html(item, **kwargs))
196
+ content_html = "\n".join(content_html_parts)
197
+
198
+ # Descripción opcional
199
+ description_html = ""
200
+ if step.description:
201
+ desc_class = "text-muted mb-3" if is_bootstrap else "step-description"
202
+ description_html = f'<p class="{desc_class}">{step.description}</p>'
203
+
204
+ html = f'<section {attrs_str}>'
205
+ html += f'<h2 class="{title_class}">{step.title}</h2>'
206
+ html += description_html
207
+ html += content_html
208
+ html += '</section>'
209
+
151
210
  return html
152
211
 
212
+
153
213
  def form_to_html(form: Form, **kwargs) -> str:
154
214
  """Genera el HTML completo del formulario"""
155
215
  output_format = kwargs.get('output_format', ExportFormat.HTML.value)
156
216
  form_class = "needs-validation" if output_format in [ExportFormat.BOOTSTRAP4.value, ExportFormat.BOOTSTRAP5.value] else ""
157
-
217
+
218
+ # Detectar si es wizard
219
+ is_wizard = any(isinstance(item, FormStep) for item in form.content)
220
+ if is_wizard:
221
+ form_class = f"{form_class} form-wizard".strip()
222
+
158
223
  attributes = {
159
224
  "id": kwargs.get('id') or str(form.id),
160
225
  "name": form.name,
161
226
  "class": f"{form_class} {form.css_classes or ''}".strip(),
162
227
  "enctype": kwargs.get('enctype') or "application/x-www-form-urlencoded"
163
228
  }
229
+ if is_wizard:
230
+ attributes["data-wizard"] = "true"
164
231
 
165
232
  attrs_str = " ".join(f'{k}="{v}"' for k, v in attributes.items() if v)
166
-
167
- # Generar HTML para cada elemento del contenido (campos o grupos)
233
+
234
+ # Generar HTML para cada elemento del contenido (campos, grupos o steps)
168
235
  content_html_parts = []
169
236
  for item in form.content:
170
- if hasattr(item, 'fields') and hasattr(item, 'title'): # Es un FieldGroup
237
+ if isinstance(item, FormStep): # Es un FormStep (wizard)
238
+ content_html_parts.append(step_to_html(item, **kwargs))
239
+ elif isinstance(item, FieldGroup): # Es un FieldGroup
171
240
  content_html_parts.append(group_to_html(item, **kwargs))
172
241
  else: # Es un campo individual
173
242
  content_html_parts.append(field_to_html(item, **kwargs))
@@ -1,7 +1,7 @@
1
1
  import re
2
2
  from datetime import date, datetime
3
3
  from enum import Enum
4
- from typing import Optional, List, Union, Dict, Any, Type, Set
4
+ from typing import Optional, List, Literal, Union, Dict, Any, Type, Set
5
5
  from uuid import UUID, uuid4
6
6
  from pydantic import BaseModel, Field, EmailStr, ConfigDict, field_validator, model_validator
7
7
  from codeforms.i18n import t
@@ -30,6 +30,25 @@ class ValidationRule(BaseModel):
30
30
  message: str
31
31
 
32
32
 
33
+ class VisibilityRule(BaseModel):
34
+ """Regla de visibilidad condicional para un campo."""
35
+ field: str # nombre del campo del que depende
36
+ operator: str = "equals" # equals, not_equals, in, not_in, gt, lt, is_empty, is_not_empty
37
+ value: Any = None # valor a comparar
38
+
39
+
40
+ class SelectOption(BaseModel):
41
+ value: str
42
+ label: str
43
+ selected: bool = False
44
+
45
+
46
+ class DependentOptionsConfig(BaseModel):
47
+ """Configuración para opciones dependientes de otro campo."""
48
+ depends_on: str # nombre del campo padre
49
+ options_map: Dict[str, List[SelectOption]] # valor_padre → opciones disponibles
50
+
51
+
33
52
  class FormFieldBase(BaseModel):
34
53
  id: UUID = Field(default_factory=uuid4)
35
54
  name: str
@@ -43,6 +62,8 @@ class FormFieldBase(BaseModel):
43
62
  css_classes: Optional[str] = None
44
63
  readonly: bool = False
45
64
  attributes: Dict[str, str] = Field(default_factory=dict)
65
+ visible_when: Optional[List[VisibilityRule]] = None
66
+ dependent_options: Optional[DependentOptionsConfig] = None
46
67
 
47
68
  @field_validator('attributes', mode='before')
48
69
  @classmethod
@@ -74,12 +95,6 @@ class FormFieldBase(BaseModel):
74
95
  return field_exporter(self, output_format, kwargs=kwargs)
75
96
 
76
97
 
77
- class SelectOption(BaseModel):
78
- value: str
79
- label: str
80
- selected: bool = False
81
-
82
-
83
98
  class CheckboxField(FormFieldBase):
84
99
  field_type: FieldType = FieldType.CHECKBOX
85
100
  checked: bool = False
@@ -258,6 +273,7 @@ class ListField(FormFieldBase):
258
273
 
259
274
  class FieldGroup(BaseModel):
260
275
  """Representa un grupo de campos en un formulario para organización en secciones"""
276
+ container_type: str = "group" # Discriminador explícito para distinguir de FormStep
261
277
  id: UUID = Field(default_factory=uuid4)
262
278
  title: str
263
279
  description: Optional[str] = None
@@ -298,3 +314,64 @@ class FieldGroup(BaseModel):
298
314
  from codeforms.export import group_exporter
299
315
  return group_exporter(self, output_format, kwargs=kwargs)
300
316
 
317
+
318
+ class FormStep(BaseModel):
319
+ """Representa un paso en un formulario multi-paso (wizard).
320
+
321
+ Agrupa campos y/o grupos de campos en una secuencia lógica.
322
+ Se diferencia de FieldGroup mediante el discriminador explícito type="step".
323
+ """
324
+ type: Literal["step"] = "step" # Discriminador explícito (RISK-1)
325
+ id: UUID = Field(default_factory=uuid4)
326
+ title: str
327
+ description: Optional[str] = None
328
+ content: List[Any] # Puede contener campos y/o FieldGroups
329
+ css_classes: Optional[str] = None
330
+ attributes: Dict[str, str] = Field(default_factory=dict)
331
+ validation_mode: str = "on_next" # on_next | on_submit | on_change
332
+ skippable: bool = False
333
+
334
+ model_config = {
335
+ "json_serializers": {
336
+ UUID: str,
337
+ datetime: lambda v: v.isoformat(),
338
+ date: lambda v: v.isoformat()
339
+ }
340
+ }
341
+
342
+ @field_validator('attributes', mode='before')
343
+ @classmethod
344
+ def coerce_attributes_to_strings(cls, v: Any) -> Dict[str, str]:
345
+ if v is None:
346
+ return {}
347
+ if isinstance(v, dict):
348
+ return {str(k): str(val) for k, val in v.items()}
349
+ return v
350
+
351
+ @model_validator(mode='before')
352
+ @classmethod
353
+ def resolve_step_content(cls, data: Any) -> Any:
354
+ """Resolver items del contenido del paso usando el registry."""
355
+ if isinstance(data, dict) and 'content' in data:
356
+ from codeforms.registry import resolve_content_item
357
+ data = data.copy()
358
+ data['content'] = [resolve_content_item(item) for item in data['content']]
359
+ return data
360
+
361
+ @property
362
+ def fields(self) -> List[FormFieldBase]:
363
+ """Devuelve una lista plana de todos los campos en este paso,
364
+ incluyendo campos dentro de FieldGroups anidados (RISK-2)."""
365
+ all_fields = []
366
+ for item in self.content:
367
+ if isinstance(item, FieldGroup):
368
+ all_fields.extend(item.fields)
369
+ elif isinstance(item, FormFieldBase):
370
+ all_fields.append(item)
371
+ return all_fields
372
+
373
+ def export(self, output_format: str = 'html', **kwargs) -> str:
374
+ """Exportar el paso en diferentes formatos."""
375
+ from codeforms.export import step_exporter
376
+ return step_exporter(self, output_format, kwargs=kwargs)
377
+