codeforms 0.2.1__py3-none-any.whl → 0.2.2__py3-none-any.whl

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/__init__.py CHANGED
@@ -12,6 +12,7 @@ from codeforms.fields import (
12
12
  FormStep,
13
13
  HiddenField,
14
14
  ListField,
15
+ ObjectListField,
15
16
  NumberField,
16
17
  RadioField,
17
18
  SelectField,
@@ -63,6 +64,7 @@ __all__ = [
63
64
  "UrlField",
64
65
  "TextareaField",
65
66
  "ListField",
67
+ "ObjectListField",
66
68
  "FieldGroup",
67
69
  "FormStep",
68
70
  # Form
codeforms/export.py CHANGED
@@ -12,6 +12,7 @@ from codeforms.fields import (
12
12
  FormStep,
13
13
  HiddenField,
14
14
  ListField,
15
+ ObjectListField,
15
16
  NumberField,
16
17
  RadioField,
17
18
  SelectField,
@@ -485,6 +486,26 @@ _LIST_ITEM_TYPE_MAP: Dict[str, str] = {
485
486
  }
486
487
 
487
488
 
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
+
488
509
  def _field_to_json_schema_property(field: FormFieldBase) -> Dict[str, Any]:
489
510
  """Convert a single form field to a JSON Schema property definition."""
490
511
  prop: Dict[str, Any] = {}
@@ -570,6 +591,14 @@ def _field_to_json_schema_property(field: FormFieldBase) -> Dict[str, Any]:
570
591
  if field.max_items is not None:
571
592
  prop["maxItems"] = field.max_items
572
593
 
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
+
573
602
  elif isinstance(field, TextField):
574
603
  prop["type"] = "string"
575
604
  if field.minlength is not None:
codeforms/fields.py CHANGED
@@ -30,6 +30,7 @@ class FieldType(str, Enum):
30
30
  HIDDEN = "hidden"
31
31
  URL = "url"
32
32
  LIST = "list"
33
+ OBJECT_LIST = "object-list"
33
34
 
34
35
 
35
36
  class ValidationRule(BaseModel):
@@ -279,7 +280,7 @@ class TextareaField(FormFieldBase):
279
280
 
280
281
 
281
282
  class ListField(FormFieldBase):
282
- """Campo para listas de valores (ej: lista de participantes)"""
283
+ """Campo para listas de valores primitivos (ej: lista de participantes)."""
283
284
 
284
285
  field_type: FieldType = FieldType.LIST
285
286
  min_items: Optional[int] = None
@@ -287,6 +288,32 @@ class ListField(FormFieldBase):
287
288
  item_type: str = "text" # Tipo de cada item en la lista
288
289
 
289
290
 
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
+
290
317
  class FieldGroup(BaseModel):
291
318
  """Representa un grupo de campos en un formulario para organización en secciones"""
292
319
 
codeforms/forms.py CHANGED
@@ -191,8 +191,22 @@ class Form(BaseModel):
191
191
  "message": t("checkbox_group.invalid_options"),
192
192
  }
193
193
  )
194
+ elif isinstance(field, ListField):
195
+ value, field_errors = _validate_list_field_value(field, field_value)
196
+ if field_errors:
197
+ errors.extend(field_errors)
198
+ else:
199
+ validated_data[field.name] = value
200
+ elif isinstance(field, ObjectListField):
201
+ value, field_errors = _validate_object_list_field_value(
202
+ field, field_value
203
+ )
204
+ if field_errors:
205
+ errors.extend(field_errors)
206
+ else:
207
+ validated_data[field.name] = value
194
208
 
195
- if not errors:
209
+ if not errors and field.name not in validated_data:
196
210
  validated_data[field.name] = field_value
197
211
 
198
212
  return {
@@ -324,6 +338,10 @@ class FormDataModel(BaseModel):
324
338
  fields[field.name] = (float, ... if field.required else None)
325
339
  elif isinstance(field, DateField):
326
340
  fields[field.name] = (date, ... if field.required else None)
341
+ elif isinstance(field, ListField):
342
+ fields[field.name] = (List[Any], ... if field.required else [])
343
+ elif isinstance(field, ObjectListField):
344
+ fields[field.name] = (List[Dict[str, Any]], ... if field.required else [])
327
345
  else:
328
346
  fields[field.name] = (str, ... if field.required else None)
329
347
 
@@ -426,6 +444,10 @@ class FormDataValidator:
426
444
  field_type = float
427
445
  elif isinstance(field, DateField):
428
446
  field_type = date
447
+ elif isinstance(field, ListField):
448
+ field_type = List[Any]
449
+ elif isinstance(field, ObjectListField):
450
+ field_type = List[Dict[str, Any]]
429
451
  else:
430
452
  field_type = str
431
453
 
@@ -434,7 +456,7 @@ class FormDataValidator:
434
456
  fields[field.name] = Field(..., description=field.help_text)
435
457
  else:
436
458
  default_value = None
437
- if field_type == List[str]:
459
+ if field_type in (List[str], List[Any], List[Dict[str, Any]]):
438
460
  default_value = []
439
461
  fields[field.name] = Field(
440
462
  default=default_value, description=field.help_text
@@ -460,223 +482,126 @@ class FormDataValidator:
460
482
  return model
461
483
 
462
484
 
485
+ def _make_error(field_name: str, message: str) -> dict:
486
+ return {"field": field_name, "message": message}
487
+
488
+
489
+ def _validate_primitive_list_item(item_type: str, value: Any) -> tuple[Any, Optional[str]]:
490
+ if item_type == "number":
491
+ try:
492
+ return float(value), None
493
+ except (TypeError, ValueError):
494
+ return None, t("number.invalid")
495
+
496
+ if item_type == "email":
497
+ if not re.match(r"[^@]+@[^@]+\.[^@]+", str(value)):
498
+ return None, t("email.invalid")
499
+ return str(value), None
500
+
501
+ if item_type == "url":
502
+ if not isinstance(value, str) or not value.startswith(("http://", "https://")):
503
+ return None, t("url.invalid_scheme")
504
+ return value, None
505
+
506
+ if item_type == "date":
507
+ try:
508
+ return date.fromisoformat(str(value)).isoformat(), None
509
+ except (TypeError, ValueError):
510
+ return None, t("date.invalid_format")
511
+
512
+ if not isinstance(value, str):
513
+ value = str(value)
514
+ return value, None
515
+
516
+
517
+ def _validate_list_field_value(field: ListField, field_value: Any, field_path: Optional[str] = None) -> tuple[Any, List[dict]]:
518
+ field_name = field_path or field.name
519
+ if isinstance(field_value, str):
520
+ field_value = [field_value]
521
+
522
+ if not isinstance(field_value, list):
523
+ return None, [_make_error(field_name, t("select.value_must_be_list"))]
524
+
525
+ if field.min_items is not None and len(field_value) < field.min_items:
526
+ return None, [_make_error(field_name, f"Expected at least {field.min_items} items")]
527
+
528
+ if field.max_items is not None and len(field_value) > field.max_items:
529
+ return None, [_make_error(field_name, f"Expected at most {field.max_items} items")]
530
+
531
+ validated_items = []
532
+ errors = []
533
+ for index, item in enumerate(field_value):
534
+ validated_item, error_message = _validate_primitive_list_item(field.item_type, item)
535
+ if error_message:
536
+ errors.append(_make_error(f"{field_name}[{index}]", error_message))
537
+ else:
538
+ validated_items.append(validated_item)
539
+
540
+ return (validated_items if not errors else None), errors
541
+
542
+
543
+ def _validate_object_list_field_value(field: ObjectListField, field_value: Any, field_path: Optional[str] = None) -> tuple[Any, List[dict]]:
544
+ field_name = field_path or field.name
545
+ if not isinstance(field_value, list):
546
+ return None, [_make_error(field_name, t("select.value_must_be_list"))]
547
+
548
+ if field.min_items is not None and len(field_value) < field.min_items:
549
+ return None, [_make_error(field_name, f"Expected at least {field.min_items} items")]
550
+
551
+ if field.max_items is not None and len(field_value) > field.max_items:
552
+ return None, [_make_error(field_name, f"Expected at most {field.max_items} items")]
553
+
554
+ allowed_fields = {subfield.name for subfield in field.fields}
555
+ validated_items = []
556
+ errors = []
557
+
558
+ for index, item in enumerate(field_value):
559
+ item_path = f"{field_name}[{index}]"
560
+ if not isinstance(item, dict):
561
+ errors.append(_make_error(item_path, "Each item must be an object"))
562
+ continue
563
+
564
+ extra_fields = sorted(set(item.keys()) - allowed_fields)
565
+ if extra_fields:
566
+ errors.append(_make_error(item_path, f"Unknown fields: {', '.join(extra_fields)}"))
567
+
568
+ validated_item = {}
569
+ item_errors = []
570
+ for subfield in field.fields:
571
+ subvalue = item.get(subfield.name)
572
+ validated_value, suberrors = _validate_field_value(
573
+ subfield,
574
+ subvalue,
575
+ item,
576
+ field_path=f"{item_path}.{subfield.name}",
577
+ )
578
+ if suberrors:
579
+ item_errors.extend(suberrors)
580
+ elif validated_value is not None:
581
+ validated_item[subfield.name] = validated_value
582
+
583
+ if item_errors:
584
+ errors.extend(item_errors)
585
+ else:
586
+ validated_items.append(validated_item)
587
+
588
+ return (validated_items if not errors else None), errors
589
+
590
+
463
591
  def validate_form_data(form: Form, data: Dict[str, Any]) -> Dict[str, Any]:
464
592
  try:
465
593
  validated_data = {}
466
594
  for field in form.fields:
467
595
  field_value = data.get(field.name)
468
-
469
- # Si no hay valor, usar el valor por defecto
470
- if field_value is None:
471
- field_value = field.default_value
472
-
473
- # Validar campo requerido después de considerar el valor por defecto
474
- if field.required and field_value is None:
596
+ value, errors = _validate_field_value(field, field_value, data)
597
+ if errors:
475
598
  return {
476
599
  "success": False,
477
- "errors": [
478
- {
479
- "field": field.name,
480
- "message": t("field.required_named", name=field.name),
481
- }
482
- ],
600
+ "errors": errors,
483
601
  "message": t("form.data_validation_error"),
484
602
  }
485
-
486
- # Procesar valores específicos por tipo
487
- if field_value is not None:
488
- # Select validation (debe ir antes de otros tipos)
489
- if field.field_type == FieldType.SELECT:
490
- valid_options = [opt.value for opt in field.options]
491
- if field.multiple:
492
- # Asegurar que field_value sea una lista
493
- if isinstance(field_value, str):
494
- field_value = [field_value]
495
- if not isinstance(field_value, list):
496
- return {
497
- "success": False,
498
- "errors": [
499
- {
500
- "field": field.name,
501
- "message": t("select.value_must_be_list"),
502
- }
503
- ],
504
- "message": t("form.data_validation_error"),
505
- }
506
- # Validar cada valor en la lista
507
- invalid_values = [
508
- v for v in field_value if v not in valid_options
509
- ]
510
- if invalid_values:
511
- return {
512
- "success": False,
513
- "errors": [
514
- {
515
- "field": field.name,
516
- "message": t(
517
- "select.invalid_values",
518
- values=str(invalid_values),
519
- ),
520
- }
521
- ],
522
- "message": t("form.data_validation_error"),
523
- }
524
- else:
525
- # Validar valor único
526
- if field_value not in valid_options:
527
- return {
528
- "success": False,
529
- "errors": [
530
- {
531
- "field": field.name,
532
- "message": t(
533
- "select.invalid_option_value",
534
- value=field_value,
535
- valid=str(valid_options),
536
- ),
537
- }
538
- ],
539
- "message": t("form.data_validation_error"),
540
- }
541
- validated_data[field.name] = field_value
542
-
543
- # Email validation
544
- elif field.field_type == FieldType.EMAIL:
545
- try:
546
- if not re.match(r"[^@]+@[^@]+\.[^@]+", field_value):
547
- raise ValueError("Invalid email format")
548
- validated_data[field.name] = field_value
549
- except ValueError:
550
- return {
551
- "success": False,
552
- "errors": [
553
- {"field": field.name, "message": t("email.invalid")}
554
- ],
555
- "message": t("form.data_validation_error"),
556
- }
557
-
558
- # Checkbox group validation
559
- elif field.field_type == FieldType.CHECKBOX and hasattr(
560
- field, "options"
561
- ):
562
- valid_options = [opt.value for opt in field.options]
563
-
564
- # Ensure field_value is a list
565
- if isinstance(field_value, str):
566
- field_value = [field_value]
567
-
568
- if not isinstance(field_value, list):
569
- return {
570
- "success": False,
571
- "errors": [
572
- {
573
- "field": field.name,
574
- "message": t("select.value_must_be_list"),
575
- }
576
- ],
577
- "message": t("form.data_validation_error"),
578
- }
579
-
580
- # Check if all values are valid options
581
- invalid_values = [v for v in field_value if v not in valid_options]
582
- if invalid_values:
583
- return {
584
- "success": False,
585
- "errors": [
586
- {
587
- "field": field.name,
588
- "message": t(
589
- "select.invalid_values",
590
- values=str(invalid_values),
591
- ),
592
- }
593
- ],
594
- "message": t("form.data_validation_error"),
595
- }
596
- validated_data[field.name] = field_value
597
-
598
- # Single checkbox validation
599
- elif field.field_type == FieldType.CHECKBOX and not hasattr(
600
- field, "options"
601
- ):
602
- # Convertir a booleano si es necesario
603
- validated_data[field.name] = bool(field_value)
604
-
605
- # Radio validation
606
- elif field.field_type == FieldType.RADIO:
607
- valid_options = [opt.value for opt in field.options]
608
- if field_value not in valid_options:
609
- return {
610
- "success": False,
611
- "errors": [
612
- {
613
- "field": field.name,
614
- "message": t("radio.invalid_option"),
615
- }
616
- ],
617
- "message": t("form.data_validation_error"),
618
- }
619
- validated_data[field.name] = field_value
620
-
621
- # Number validation
622
- elif field.field_type == FieldType.NUMBER:
623
- try:
624
- num_value = float(field_value)
625
- if hasattr(field, "min_value") and field.min_value is not None:
626
- if num_value < field.min_value:
627
- raise ValueError(
628
- t("number.min_value", min=field.min_value)
629
- )
630
- if hasattr(field, "max_value") and field.max_value is not None:
631
- if num_value > field.max_value:
632
- raise ValueError(
633
- t("number.max_value", max=field.max_value)
634
- )
635
- validated_data[field.name] = num_value
636
- except ValueError as e:
637
- return {
638
- "success": False,
639
- "errors": [{"field": field.name, "message": str(e)}],
640
- "message": t("form.data_validation_error"),
641
- }
642
-
643
- # Text validation
644
- elif field.field_type == FieldType.TEXT:
645
- if not isinstance(field_value, str):
646
- field_value = str(field_value)
647
- if hasattr(field, "minlength") and field.minlength is not None:
648
- if len(field_value) < field.minlength:
649
- return {
650
- "success": False,
651
- "errors": [
652
- {
653
- "field": field.name,
654
- "message": t(
655
- "text.minlength", min=field.minlength
656
- ),
657
- }
658
- ],
659
- "message": t("form.data_validation_error"),
660
- }
661
- if hasattr(field, "maxlength") and field.maxlength is not None:
662
- if len(field_value) > field.maxlength:
663
- return {
664
- "success": False,
665
- "errors": [
666
- {
667
- "field": field.name,
668
- "message": t(
669
- "text.maxlength", max=field.maxlength
670
- ),
671
- }
672
- ],
673
- "message": t("form.data_validation_error"),
674
- }
675
- validated_data[field.name] = field_value
676
-
677
- # Default validation for other types
678
- else:
679
- validated_data[field.name] = field_value
603
+ if value is not None:
604
+ validated_data[field.name] = value
680
605
 
681
606
  return {
682
607
  "success": True,
@@ -740,26 +665,25 @@ def evaluate_visibility(field: FormFieldBase, data: Dict[str, Any]) -> bool:
740
665
 
741
666
 
742
667
  def _validate_field_value(
743
- field: FormFieldBase, field_value: Any, data: Dict[str, Any]
744
- ) -> tuple:
668
+ field: FormFieldBase, field_value: Any, data: Dict[str, Any], field_path: Optional[str] = None
669
+ ) -> tuple[Any, List[dict]]:
745
670
  """Valida un campo individual y retorna (validated_value, error_dict_or_None).
746
671
 
747
672
  Lógica de validación compartida entre validate_form_data y
748
673
  validate_form_data_dynamic. No modifica la función legacy.
749
674
  """
675
+ field_name = field_path or field.name
676
+
750
677
  # Si no hay valor, usar el valor por defecto
751
678
  if field_value is None:
752
679
  field_value = field.default_value
753
680
 
754
681
  # Validar campo requerido
755
682
  if field.required and field_value is None:
756
- return None, {
757
- "field": field.name,
758
- "message": t("field.required_named", name=field.name),
759
- }
683
+ return None, [_make_error(field_name, t("field.required_named", name=field.name))]
760
684
 
761
685
  if field_value is None:
762
- return field_value, None
686
+ return field_value, []
763
687
 
764
688
  # Select validation
765
689
  if field.field_type == FieldType.SELECT:
@@ -768,33 +692,32 @@ def _validate_field_value(
768
692
  if isinstance(field_value, str):
769
693
  field_value = [field_value]
770
694
  if not isinstance(field_value, list):
771
- return None, {
772
- "field": field.name,
773
- "message": t("select.value_must_be_list"),
774
- }
695
+ return None, [_make_error(field_name, t("select.value_must_be_list"))]
775
696
  invalid_values = [v for v in field_value if v not in valid_options]
776
697
  if invalid_values:
777
- return None, {
778
- "field": field.name,
779
- "message": t("select.invalid_values", values=str(invalid_values)),
780
- }
698
+ return None, [_make_error(field_name, t("select.invalid_values", values=str(invalid_values)))]
781
699
  else:
782
700
  if field_value not in valid_options:
783
- return None, {
784
- "field": field.name,
785
- "message": t(
701
+ return None, [_make_error(field_name, t(
786
702
  "select.invalid_option_value",
787
703
  value=field_value,
788
704
  valid=str(valid_options),
789
- ),
790
- }
791
- return field_value, None
705
+ ))]
706
+ return field_value, []
707
+
708
+ # Primitive list validation
709
+ if field.field_type == FieldType.LIST:
710
+ return _validate_list_field_value(field, field_value, field_name)
711
+
712
+ # Object list validation
713
+ if field.field_type == FieldType.OBJECT_LIST:
714
+ return _validate_object_list_field_value(field, field_value, field_name)
792
715
 
793
716
  # Email validation
794
717
  if field.field_type == FieldType.EMAIL:
795
718
  if not re.match(r"[^@]+@[^@]+\.[^@]+", str(field_value)):
796
- return None, {"field": field.name, "message": t("email.invalid")}
797
- return field_value, None
719
+ return None, [_make_error(field_name, t("email.invalid"))]
720
+ return field_value, []
798
721
 
799
722
  # Checkbox group validation
800
723
  if field.field_type == FieldType.CHECKBOX and hasattr(field, "options"):
@@ -802,17 +725,11 @@ def _validate_field_value(
802
725
  if isinstance(field_value, str):
803
726
  field_value = [field_value]
804
727
  if not isinstance(field_value, list):
805
- return None, {
806
- "field": field.name,
807
- "message": t("select.value_must_be_list"),
808
- }
728
+ return None, [_make_error(field_name, t("select.value_must_be_list"))]
809
729
  invalid_values = [v for v in field_value if v not in valid_options]
810
730
  if invalid_values:
811
- return None, {
812
- "field": field.name,
813
- "message": t("select.invalid_values", values=str(invalid_values)),
814
- }
815
- return field_value, None
731
+ return None, [_make_error(field_name, t("select.invalid_values", values=str(invalid_values)))]
732
+ return field_value, []
816
733
 
817
734
  # Single checkbox validation
818
735
  if field.field_type == FieldType.CHECKBOX and not hasattr(field, "options"):
@@ -822,8 +739,8 @@ def _validate_field_value(
822
739
  if field.field_type == FieldType.RADIO:
823
740
  valid_options = [opt.value for opt in field.options]
824
741
  if field_value not in valid_options:
825
- return None, {"field": field.name, "message": t("radio.invalid_option")}
826
- return field_value, None
742
+ return None, [_make_error(field_name, t("radio.invalid_option"))]
743
+ return field_value, []
827
744
 
828
745
  # Number validation
829
746
  if field.field_type == FieldType.NUMBER:
@@ -831,19 +748,13 @@ def _validate_field_value(
831
748
  num_value = float(field_value)
832
749
  if hasattr(field, "min_value") and field.min_value is not None:
833
750
  if num_value < field.min_value:
834
- return None, {
835
- "field": field.name,
836
- "message": t("number.min_value", min=field.min_value),
837
- }
751
+ return None, [_make_error(field_name, t("number.min_value", min=field.min_value))]
838
752
  if hasattr(field, "max_value") and field.max_value is not None:
839
753
  if num_value > field.max_value:
840
- return None, {
841
- "field": field.name,
842
- "message": t("number.max_value", max=field.max_value),
843
- }
754
+ return None, [_make_error(field_name, t("number.max_value", max=field.max_value))]
844
755
  return num_value, None
845
756
  except (ValueError, TypeError):
846
- return None, {"field": field.name, "message": t("number.invalid")}
757
+ return None, [_make_error(field_name, t("number.invalid"))]
847
758
 
848
759
  # Text validation
849
760
  if field.field_type == FieldType.TEXT:
@@ -851,20 +762,14 @@ def _validate_field_value(
851
762
  field_value = str(field_value)
852
763
  if hasattr(field, "minlength") and field.minlength is not None:
853
764
  if len(field_value) < field.minlength:
854
- return None, {
855
- "field": field.name,
856
- "message": t("text.minlength", min=field.minlength),
857
- }
765
+ return None, [_make_error(field_name, t("text.minlength", min=field.minlength))]
858
766
  if hasattr(field, "maxlength") and field.maxlength is not None:
859
767
  if len(field_value) > field.maxlength:
860
- return None, {
861
- "field": field.name,
862
- "message": t("text.maxlength", max=field.maxlength),
863
- }
864
- return field_value, None
768
+ return None, [_make_error(field_name, t("text.maxlength", max=field.maxlength))]
769
+ return field_value, []
865
770
 
866
771
  # Default: pasar el valor sin validación adicional
867
- return field_value, None
772
+ return field_value, []
868
773
 
869
774
 
870
775
  def validate_form_data_dynamic(
@@ -923,10 +828,10 @@ def validate_form_data_dynamic(
923
828
  continue # Campo oculto, no validar
924
829
 
925
830
  field_value = data.get(field.name)
926
- value, error = _validate_field_value(field, field_value, data)
831
+ value, field_errors = _validate_field_value(field, field_value, data)
927
832
 
928
- if error:
929
- errors.append(error)
833
+ if field_errors:
834
+ errors.extend(field_errors)
930
835
  elif value is not None:
931
836
  validated_data[field.name] = value
932
837
 
codeforms/registry.py CHANGED
@@ -69,6 +69,7 @@ def _init_builtin_types() -> None:
69
69
  FileField,
70
70
  HiddenField,
71
71
  ListField,
72
+ ObjectListField,
72
73
  NumberField,
73
74
  RadioField,
74
75
  SelectField,
@@ -91,6 +92,7 @@ def _init_builtin_types() -> None:
91
92
  UrlField,
92
93
  TextareaField,
93
94
  ListField,
95
+ ObjectListField,
94
96
  ]:
95
97
  _register_class(cls)
96
98
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeforms
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Python library for creating, validating, and rendering web forms using Pydantic
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -100,6 +100,69 @@ All fields inherit from `FormFieldBase` and share these common attributes:
100
100
  - `accept`: Accepted file types (e.g. `"image/*,.pdf"`).
101
101
  - `multiple`: Allow multiple file uploads.
102
102
  - **`HiddenField`** — Hidden field (`<input type="hidden">`).
103
+ - **`ListField`** — Array of primitive values.
104
+ - `item_type`: Primitive type for each item (`text`, `number`, `email`, `url`, `date`).
105
+ - `min_items`, `max_items`: List size limits.
106
+ - **`ObjectListField`** — Array of homogeneous objects validated against nested subfields.
107
+ - `fields`: List of subfields that define each object shape.
108
+ - `min_items`, `max_items`: List size limits.
109
+
110
+ ### `ObjectListField`
111
+
112
+ Use `ObjectListField` when a form needs a repeatable list of structured rows, such as parallel approvers, attendees with roles, or line items.
113
+
114
+ ```python
115
+ from codeforms import Form, ObjectListField, TextField, CheckboxField
116
+
117
+ form = Form(
118
+ name="parallel_approvers",
119
+ fields=[
120
+ ObjectListField(
121
+ name="parallel_approvals",
122
+ label="Aprobadores",
123
+ required=True,
124
+ min_items=1,
125
+ max_items=5,
126
+ fields=[
127
+ TextField(name="approver_email", label="Email", required=True),
128
+ TextField(name="label", label="Etiqueta", required=True),
129
+ CheckboxField(name="required", label="Obligatorio"),
130
+ ],
131
+ )
132
+ ],
133
+ )
134
+ ```
135
+
136
+ Expected submitted value:
137
+
138
+ ```json
139
+ {
140
+ "parallel_approvals": [
141
+ {
142
+ "approver_email": "ana@empresa.com",
143
+ "label": "Compras",
144
+ "required": true
145
+ },
146
+ {
147
+ "approver_email": "luis@empresa.com",
148
+ "label": "Finanzas"
149
+ }
150
+ ]
151
+ }
152
+ ```
153
+
154
+ Validation behavior:
155
+
156
+ - The top-level field must be a JSON array.
157
+ - Each item must be a JSON object.
158
+ - Unknown keys inside items are rejected.
159
+ - Required nested subfields are enforced.
160
+ - Validation errors include nested paths like `parallel_approvals[0].label`.
161
+
162
+ Current limitation:
163
+
164
+ - `ObjectListField` is fully supported in backend validation and JSON Schema export.
165
+ - Rich repeatable HTML UI generation is not implemented yet. If you need an interactive editor, prefer consuming the exported `json_schema` from your frontend.
103
166
 
104
167
  ## Data Validation
105
168
 
@@ -284,9 +347,38 @@ Output:
284
347
  | `UrlField` | `string` (`format: "uri"`) | `minLength`, `maxLength` |
285
348
  | `TextareaField` | `string` | `minLength`, `maxLength` |
286
349
  | `ListField` | `array` | `minItems`, `maxItems` |
350
+ | `ObjectListField` | `array` of `object` | nested `properties`, nested `required`, `minItems`, `maxItems` |
287
351
 
288
352
  Field annotations like `label`, `help_text`, `default_value`, and `readonly` map to the JSON Schema keywords `title`, `description`, `default`, and `readOnly` respectively.
289
353
 
354
+ Example `ObjectListField` schema:
355
+
356
+ ```json
357
+ {
358
+ "type": "array",
359
+ "minItems": 1,
360
+ "items": {
361
+ "type": "object",
362
+ "properties": {
363
+ "approver_email": {
364
+ "type": "string",
365
+ "title": "Email"
366
+ },
367
+ "label": {
368
+ "type": "string",
369
+ "title": "Etiqueta"
370
+ },
371
+ "required": {
372
+ "type": "boolean",
373
+ "title": "Obligatorio"
374
+ }
375
+ },
376
+ "required": ["approver_email", "label"],
377
+ "additionalProperties": false
378
+ }
379
+ }
380
+ ```
381
+
290
382
  Fields inside `FieldGroup` and `FormStep` containers are flattened into the top-level `properties` automatically.
291
383
 
292
384
  ## Internationalization (i18n)
@@ -0,0 +1,10 @@
1
+ codeforms/__init__.py,sha256=fwftisOnA1dFLFoEDKMPpYBYlVBI7vFwxY-ThP21ERU,1735
2
+ codeforms/export.py,sha256=PBfKHtsaXrQsIalQKE-MR6m_-LUKpbrAlc958DbpEAE,21265
3
+ codeforms/fields.py,sha256=dt4sGL1XKhCiCGUrgHzwiymjP9c4CptsY9QFKjZKDSM,13803
4
+ codeforms/forms.py,sha256=qWHF5TjmYuJU_Iw4eQ94_QTI-rbd8RTi20MbiH4jt_8,33692
5
+ codeforms/i18n.py,sha256=VK0n3d8UaD-q31ggrlIiJ4NSjagoYLoz200YFCXKuKU,10392
6
+ codeforms/registry.py,sha256=COYMIK0dtOdUwvNKBLhmUJEHC2ErSuJE8PU-jwZVt7U,6670
7
+ codeforms-0.2.2.dist-info/METADATA,sha256=KjHK2PpzKmqG9voez5We5vDknH9CO3KsQ9mamX8Krzs,20284
8
+ codeforms-0.2.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ codeforms-0.2.2.dist-info/licenses/LICENSE,sha256=U9D6cQ9DZJQrT03MMDJcq-aOuoB_Bn8HJlzcTTN7PfM,1074
10
+ codeforms-0.2.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.28.0
2
+ Generator: hatchling 1.29.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,10 +0,0 @@
1
- codeforms/__init__.py,sha256=ozi9bHDT22B7kkh6fjCOm7Y6suJwWaHFWKR8S6kZlW0,1691
2
- codeforms/export.py,sha256=QuzllibvfFYv8nuG0WfUld4uzL-mnxZ51-CRYRYxhTE,20398
3
- codeforms/fields.py,sha256=LcDKiDX4lJ2OLT02eTYj-BSNKXq-CE6IE_Yb8MEDs1I,12793
4
- codeforms/forms.py,sha256=VM80L5fQyHJxwiCslFqlcsD2UvPDGVE_bnw_23XP-gY,38442
5
- codeforms/i18n.py,sha256=VK0n3d8UaD-q31ggrlIiJ4NSjagoYLoz200YFCXKuKU,10392
6
- codeforms/registry.py,sha256=Qwd-9-yXhzqJiG7loDPgFrCmLjZ_zOU4_vAfTV7zdzc,6620
7
- codeforms-0.2.1.dist-info/METADATA,sha256=2HG7D1OdTSFihxfDe2FftDAIyXXNJPsTymvpeOdwFH8,17746
8
- codeforms-0.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
9
- codeforms-0.2.1.dist-info/licenses/LICENSE,sha256=U9D6cQ9DZJQrT03MMDJcq-aOuoB_Bn8HJlzcTTN7PfM,1074
10
- codeforms-0.2.1.dist-info/RECORD,,