Skip to content

Commit 66d5f93

Browse files
author
Juan Pablo Manson
committed
Add ObjectListField support with validation and JSON Schema export
1 parent b56dec2 commit 66d5f93

8 files changed

Lines changed: 491 additions & 268 deletions

File tree

README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,69 @@ All fields inherit from `FormFieldBase` and share these common attributes:
8787
- `accept`: Accepted file types (e.g. `"image/*,.pdf"`).
8888
- `multiple`: Allow multiple file uploads.
8989
- **`HiddenField`** — Hidden field (`<input type="hidden">`).
90+
- **`ListField`** — Array of primitive values.
91+
- `item_type`: Primitive type for each item (`text`, `number`, `email`, `url`, `date`).
92+
- `min_items`, `max_items`: List size limits.
93+
- **`ObjectListField`** — Array of homogeneous objects validated against nested subfields.
94+
- `fields`: List of subfields that define each object shape.
95+
- `min_items`, `max_items`: List size limits.
96+
97+
### `ObjectListField`
98+
99+
Use `ObjectListField` when a form needs a repeatable list of structured rows, such as parallel approvers, attendees with roles, or line items.
100+
101+
```python
102+
from codeforms import Form, ObjectListField, TextField, CheckboxField
103+
104+
form = Form(
105+
name="parallel_approvers",
106+
fields=[
107+
ObjectListField(
108+
name="parallel_approvals",
109+
label="Aprobadores",
110+
required=True,
111+
min_items=1,
112+
max_items=5,
113+
fields=[
114+
TextField(name="approver_email", label="Email", required=True),
115+
TextField(name="label", label="Etiqueta", required=True),
116+
CheckboxField(name="required", label="Obligatorio"),
117+
],
118+
)
119+
],
120+
)
121+
```
122+
123+
Expected submitted value:
124+
125+
```json
126+
{
127+
"parallel_approvals": [
128+
{
129+
"approver_email": "ana@empresa.com",
130+
"label": "Compras",
131+
"required": true
132+
},
133+
{
134+
"approver_email": "luis@empresa.com",
135+
"label": "Finanzas"
136+
}
137+
]
138+
}
139+
```
140+
141+
Validation behavior:
142+
143+
- The top-level field must be a JSON array.
144+
- Each item must be a JSON object.
145+
- Unknown keys inside items are rejected.
146+
- Required nested subfields are enforced.
147+
- Validation errors include nested paths like `parallel_approvals[0].label`.
148+
149+
Current limitation:
150+
151+
- `ObjectListField` is fully supported in backend validation and JSON Schema export.
152+
- Rich repeatable HTML UI generation is not implemented yet. If you need an interactive editor, prefer consuming the exported `json_schema` from your frontend.
90153

91154
## Data Validation
92155

@@ -271,9 +334,38 @@ Output:
271334
| `UrlField` | `string` (`format: "uri"`) | `minLength`, `maxLength` |
272335
| `TextareaField` | `string` | `minLength`, `maxLength` |
273336
| `ListField` | `array` | `minItems`, `maxItems` |
337+
| `ObjectListField` | `array` of `object` | nested `properties`, nested `required`, `minItems`, `maxItems` |
274338

275339
Field annotations like `label`, `help_text`, `default_value`, and `readonly` map to the JSON Schema keywords `title`, `description`, `default`, and `readOnly` respectively.
276340

341+
Example `ObjectListField` schema:
342+
343+
```json
344+
{
345+
"type": "array",
346+
"minItems": 1,
347+
"items": {
348+
"type": "object",
349+
"properties": {
350+
"approver_email": {
351+
"type": "string",
352+
"title": "Email"
353+
},
354+
"label": {
355+
"type": "string",
356+
"title": "Etiqueta"
357+
},
358+
"required": {
359+
"type": "boolean",
360+
"title": "Obligatorio"
361+
}
362+
},
363+
"required": ["approver_email", "label"],
364+
"additionalProperties": false
365+
}
366+
}
367+
```
368+
277369
Fields inside `FieldGroup` and `FormStep` containers are flattened into the top-level `properties` automatically.
278370

279371
## Internationalization (i18n)

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.1"
3+
version = "0.2.2"
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
FormStep,
1313
HiddenField,
1414
ListField,
15+
ObjectListField,
1516
NumberField,
1617
RadioField,
1718
SelectField,
@@ -63,6 +64,7 @@
6364
"UrlField",
6465
"TextareaField",
6566
"ListField",
67+
"ObjectListField",
6668
"FieldGroup",
6769
"FormStep",
6870
# Form

src/codeforms/export.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
FormStep,
1313
HiddenField,
1414
ListField,
15+
ObjectListField,
1516
NumberField,
1617
RadioField,
1718
SelectField,
@@ -485,6 +486,26 @@ def exporter(form: Form, output_format: str, **kwargs) -> dict:
485486
}
486487

487488

489+
def _object_list_item_schema(field: ObjectListField) -> Dict[str, Any]:
490+
properties: Dict[str, Any] = {}
491+
required = []
492+
493+
for subfield in field.fields:
494+
properties[subfield.name] = _field_to_json_schema_property(subfield)
495+
if subfield.required:
496+
required.append(subfield.name)
497+
498+
item_schema: Dict[str, Any] = {
499+
"type": "object",
500+
"properties": properties,
501+
"additionalProperties": False,
502+
}
503+
if required:
504+
item_schema["required"] = required
505+
506+
return item_schema
507+
508+
488509
def _field_to_json_schema_property(field: FormFieldBase) -> Dict[str, Any]:
489510
"""Convert a single form field to a JSON Schema property definition."""
490511
prop: Dict[str, Any] = {}
@@ -570,6 +591,14 @@ def _field_to_json_schema_property(field: FormFieldBase) -> Dict[str, Any]:
570591
if field.max_items is not None:
571592
prop["maxItems"] = field.max_items
572593

594+
elif isinstance(field, ObjectListField):
595+
prop["type"] = "array"
596+
prop["items"] = _object_list_item_schema(field)
597+
if field.min_items is not None:
598+
prop["minItems"] = field.min_items
599+
if field.max_items is not None:
600+
prop["maxItems"] = field.max_items
601+
573602
elif isinstance(field, TextField):
574603
prop["type"] = "string"
575604
if field.minlength is not None:

src/codeforms/fields.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class FieldType(str, Enum):
3030
HIDDEN = "hidden"
3131
URL = "url"
3232
LIST = "list"
33+
OBJECT_LIST = "object-list"
3334

3435

3536
class ValidationRule(BaseModel):
@@ -279,14 +280,40 @@ class TextareaField(FormFieldBase):
279280

280281

281282
class ListField(FormFieldBase):
282-
"""Campo para listas de valores (ej: lista de participantes)"""
283+
"""Campo para listas de valores primitivos (ej: lista de participantes)."""
283284

284285
field_type: FieldType = FieldType.LIST
285286
min_items: Optional[int] = None
286287
max_items: Optional[int] = None
287288
item_type: str = "text" # Tipo de cada item en la lista
288289

289290

291+
class ObjectListField(FormFieldBase):
292+
"""Campo para listas de objetos homogéneos validados por subcampos."""
293+
294+
field_type: FieldType = FieldType.OBJECT_LIST
295+
min_items: Optional[int] = None
296+
max_items: Optional[int] = None
297+
fields: List[Any] = Field(default_factory=list)
298+
299+
@model_validator(mode="before")
300+
@classmethod
301+
def resolve_object_fields(cls, data: Any) -> Any:
302+
if isinstance(data, dict) and "fields" in data:
303+
from codeforms.registry import resolve_content_item
304+
305+
data = data.copy()
306+
data["fields"] = [resolve_content_item(item) for item in data["fields"]]
307+
return data
308+
309+
@model_validator(mode="after")
310+
def validate_object_fields(self) -> "ObjectListField":
311+
names = [field.name for field in self.fields]
312+
if len(names) != len(set(names)):
313+
raise ValueError(t("form.unique_field_names_in_group", title=self.label or self.name))
314+
return self
315+
316+
290317
class FieldGroup(BaseModel):
291318
"""Representa un grupo de campos en un formulario para organización en secciones"""
292319

0 commit comments

Comments
 (0)