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.
- {codeforms-0.1.1 → codeforms-0.2.0}/.gitignore +2 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/PKG-INFO +1 -1
- codeforms-0.2.0/examples/conditional_visibility.py +103 -0
- codeforms-0.2.0/examples/dependent_options.py +73 -0
- codeforms-0.2.0/examples/wizard_form.py +129 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/pyproject.toml +1 -1
- codeforms-0.2.0/src/codeforms/__init__.py +86 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/src/codeforms/export.py +75 -6
- {codeforms-0.1.1 → codeforms-0.2.0}/src/codeforms/fields.py +84 -7
- {codeforms-0.1.1 → codeforms-0.2.0}/src/codeforms/forms.py +302 -1
- {codeforms-0.1.1 → codeforms-0.2.0}/src/codeforms/i18n.py +18 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/src/codeforms/registry.py +18 -8
- codeforms-0.2.0/tests/test_dependent_options.py +118 -0
- codeforms-0.2.0/tests/test_dynamic_visibility.py +243 -0
- codeforms-0.2.0/tests/test_phase2_compat.py +311 -0
- codeforms-0.2.0/tests/test_wizard_steps.py +382 -0
- codeforms-0.1.1/src/codeforms/__init__.py +0 -34
- {codeforms-0.1.1 → codeforms-0.2.0}/.github/workflows/python-publish.yml +0 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/.github/workflows/tests.yml +0 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/.python-version +0 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/LICENSE +0 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/README.md +0 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/examples/basic_usage.py +0 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/examples/custom_fields.py +0 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/examples/i18n_usage.py +0 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/tests/__init__.py +0 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/tests/conftest.py +0 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/tests/test_basic_usage.py +0 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/tests/test_i18n.py +0 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/tests/test_registry.py +0 -0
- {codeforms-0.1.1 → codeforms-0.2.0}/uv.lock +0 -0
|
@@ -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)}")
|
|
@@ -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
|
|
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
|
|
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
|
+
|