diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py
index d287b42..6e17949 100644
--- a/src/servala/core/crd.py
+++ b/src/servala/core/crd.py
@@ -142,11 +142,30 @@ def get_django_field(
parent_required=is_required,
)
elif field_type == "array":
- # TODO: handle items / validate items, build multi-select input
- # if field_schema.get("items") and (choices := field_schema["items"].get("enum")):
- # choices = [c, c for c in choices]
- kwargs["help_text"] = _("JSON field (array)")
- return models.JSONField(**kwargs)
+ kwargs["help_text"] = field_schema.get("description") or _("List of values")
+ from servala.frontend.forms.widgets import DynamicArrayField
+
+ field = models.JSONField(**kwargs)
+ formfield_kwargs = {
+ "label": field.verbose_name,
+ "required": not field.blank,
+ }
+
+ array_validation = {}
+ if min_items := field_schema.get("min_items"):
+ array_validation["min_items"] = min_items
+ if max_items := field_schema.get("max_items"):
+ array_validation["max_items"] = max_items
+ if unique_items := field_schema.get("unique_items"):
+ array_validation["unique_items"] = unique_items
+ if items_schema := field_schema.get("items"):
+ array_validation["items_schema"] = items_schema
+ if array_validation:
+ formfield_kwargs["array_validation"] = array_validation
+
+ field.formfield = lambda: DynamicArrayField(**formfield_kwargs)
+
+ return field
return models.CharField(max_length=255, **kwargs)
@@ -205,7 +224,7 @@ class CrdModelFormMixin:
def strip_title(self, field_name, label):
field = self.fields[field_name]
- if field and (position := field.label.find(label)) != -1:
+ if field and field.label and (position := field.label.find(label)) != -1:
field.label = field.label[position + len(label) :]
def get_fieldsets(self):
diff --git a/src/servala/frontend/forms/widgets.py b/src/servala/frontend/forms/widgets.py
new file mode 100644
index 0000000..16e19bf
--- /dev/null
+++ b/src/servala/frontend/forms/widgets.py
@@ -0,0 +1,191 @@
+import json
+
+from django import forms
+from django.core.exceptions import ValidationError
+
+
+class DynamicArrayWidget(forms.Widget):
+ """
+ Widget for dynamic array input fields with add/remove functionality.
+ Renders as a list of input fields with + and - buttons.
+ """
+
+ template_name = "frontend/forms/dynamic_array.html"
+
+ def __init__(self, attrs=None, base_widget=None):
+ super().__init__(attrs)
+ self.base_widget = base_widget or forms.TextInput()
+
+ def format_value(self, value):
+ if value is None or value == "null":
+ return []
+ if isinstance(value, str):
+ if value.strip() == "" or value.strip().lower() == "null":
+ return []
+ try:
+ parsed = json.loads(value)
+ if parsed is None:
+ return []
+ if isinstance(parsed, list):
+ return [item for item in parsed if item is not None]
+ return [parsed]
+ except (json.JSONDecodeError, TypeError):
+ return [value] if value else []
+ if isinstance(value, list):
+ return [item for item in value if item is not None]
+ if value == "":
+ return []
+ return [str(value)]
+
+ def get_context(self, name, value, attrs):
+ context = super().get_context(name, value, attrs)
+ value_list = self.format_value(value)
+ if not isinstance(value_list, list):
+ value_list = []
+ context["value_list"] = value_list
+ context["value_list_json"] = json.dumps(value_list)
+ return context
+
+ def value_from_datadict(self, data, files, name):
+ value = data.get(name)
+ if value:
+ try:
+ parsed = json.loads(value)
+ if parsed:
+ return [
+ item
+ for item in parsed
+ if item is not None and str(item).strip()
+ ]
+ return []
+ except (json.JSONDecodeError, TypeError):
+ pass
+ return []
+
+
+class DynamicArrayField(forms.JSONField):
+ """
+ Custom field that works with DynamicArrayWidget.
+ Handles the conversion between list data and JSON for k8s generated fields.
+ """
+
+ widget = DynamicArrayWidget
+
+ def __init__(self, **kwargs):
+ self.array_validation = kwargs.pop("array_validation", {})
+ kwargs.setdefault("widget", DynamicArrayWidget())
+ super().__init__(**kwargs)
+
+ def to_python(self, value):
+ """Convert the value to a Python list"""
+ if not value:
+ return []
+
+ if isinstance(value, list):
+ return [item for item in value if item is not None and str(item).strip()]
+
+ if isinstance(value, str):
+ if value.strip() == "" or value.strip().lower() == "null":
+ return []
+ try:
+ parsed = json.loads(value)
+ if not parsed:
+ return []
+ if isinstance(parsed, list):
+ return [item for item in parsed if item is not None]
+ return [parsed]
+ except (json.JSONDecodeError, TypeError, ValueError):
+ raise ValidationError(f"Invalid JSON: {value}")
+
+ return []
+
+ def prepare_value(self, value):
+ """Prepare value for widget rendering"""
+ if value is None:
+ return []
+ if isinstance(value, list):
+ return value
+ if isinstance(value, str):
+ try:
+ parsed = json.loads(value)
+ return parsed if isinstance(parsed, list) else [parsed]
+ except (json.JSONDecodeError, TypeError):
+ return [value] if value else []
+ return [str(value)]
+
+ def bound_data(self, data, initial):
+ """Handle bound data properly to avoid JSON parsing issues"""
+ if data is None:
+ return initial
+ # If data is already a list (from our widget), return it as-is
+ if isinstance(data, list):
+ return data
+ # If data is a string, try to parse it as JSON
+ if isinstance(data, str):
+ try:
+ parsed = json.loads(data)
+ return parsed if isinstance(parsed, list) else [parsed]
+ except (json.JSONDecodeError, TypeError):
+ return [data] if data else []
+ return data
+
+ def validate(self, value):
+ if not self.required and (not value or value == []):
+ return
+ super().validate(value)
+ if value:
+ self._validate_array_constraints(value)
+
+ def _validate_array_constraints(self, value):
+ if not isinstance(value, list):
+ return
+
+ if min_items := self.array_validation.get("min_items"):
+ if len(value) < min_items:
+ raise ValidationError(
+ f"Array must contain at least {min_items} item(s). Currently has {len(value)}."
+ )
+
+ if max_items := self.array_validation.get("max_items"):
+ if len(value) > max_items:
+ raise ValidationError(
+ f"Array must contain at most {max_items} item(s). Currently has {len(value)}."
+ )
+
+ if self.array_validation.get("unique_items"):
+ if len(value) != len(set(value)):
+ raise ValidationError("All array items must be unique.")
+
+ if items_schema := self.array_validation.get("items_schema"):
+ item_type = items_schema.get("type")
+
+ if item_type == "string":
+ for i, item in enumerate(value):
+ if not isinstance(item, str):
+ raise ValidationError(f"Item {i + 1} must be a string.")
+
+ if min_length := items_schema.get("minLength"):
+ if len(item) < min_length:
+ raise ValidationError(
+ f"Item {i + 1} must be at least {min_length} characters long."
+ )
+
+ if max_length := items_schema.get("maxLength"):
+ if len(item) > max_length:
+ raise ValidationError(
+ f"Item {i + 1} must be at most {max_length} characters long."
+ )
+
+ if pattern := items_schema.get("pattern"):
+ import re
+
+ if not re.match(pattern, item):
+ raise ValidationError(
+ f"Item {i + 1} does not match the required pattern."
+ )
+
+ if enum_values := items_schema.get("enum"):
+ if item not in enum_values:
+ raise ValidationError(
+ f"Item {i + 1} must be one of: {', '.join(enum_values)}"
+ )
diff --git a/src/servala/frontend/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html
index 89d363f..28ecee9 100644
--- a/src/servala/frontend/templates/frontend/base.html
+++ b/src/servala/frontend/templates/frontend/base.html
@@ -74,5 +74,6 @@
+