From 1c69c634cc3ceecdf2b50b5e1658c1c3d6f16d5d Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 26 Jun 2025 13:37:21 +0200 Subject: [PATCH] Add dynamic JSON array fields --- src/servala/core/crd.py | 31 ++- src/servala/frontend/forms/widgets.py | 191 ++++++++++++++++++ .../frontend/templates/frontend/base.html | 1 + .../frontend/forms/dynamic_array.html | 31 +++ src/servala/frontend/views/service.py | 4 + src/servala/static/js/dynamic-array.js | 126 ++++++++++++ 6 files changed, 378 insertions(+), 6 deletions(-) create mode 100644 src/servala/frontend/forms/widgets.py create mode 100644 src/servala/frontend/templates/frontend/forms/dynamic_array.html create mode 100644 src/servala/static/js/dynamic-array.js 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 @@ + diff --git a/src/servala/frontend/templates/frontend/forms/dynamic_array.html b/src/servala/frontend/templates/frontend/forms/dynamic_array.html new file mode 100644 index 0000000..4b7e68c --- /dev/null +++ b/src/servala/frontend/templates/frontend/forms/dynamic_array.html @@ -0,0 +1,31 @@ +
+
+ {% for item in value_list %} +
+ + {% if forloop.counter != 1 %} + + {% endif %} +
+ {% empty %} +
+ +
+ {% endfor %} +
+ + +
diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 3bd6060..2d74842 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -346,6 +346,10 @@ class ServiceInstanceUpdateView( kwargs["instance"] = self.object.spec_object return kwargs + def form_invalid(self, form): + messages.error(self.request, form.errors) + return super().form_invalid(form) + def form_valid(self, form): try: spec_data = form.get_nested_data().get("spec") diff --git a/src/servala/static/js/dynamic-array.js b/src/servala/static/js/dynamic-array.js new file mode 100644 index 0000000..d038fdc --- /dev/null +++ b/src/servala/static/js/dynamic-array.js @@ -0,0 +1,126 @@ +/** + * Dynamic Array Widget JavaScript + * Provides functionality for adding and removing array items dynamically + */ + +const initDynamicArrayWidget = () => { + const containers = document.querySelectorAll('.dynamic-array-widget') + + containers.forEach(container => { + const itemsContainer = container.querySelector('.array-items') + const addButton = container.querySelector('.add-array-item') + const hiddenInput = container.querySelector('input[type="hidden"]') + const name = container.dataset.name + + addButton?.addEventListener('click', () => { + addArrayItem(itemsContainer, name) + updateHiddenInput(container) + }) + addRemoveEventListeners(container) + addInputEventListeners(container) + updateRemoveButtonVisibility(container) + }) +} + +const addArrayItem = (itemsContainer, name) => { + const itemCount = itemsContainer.querySelectorAll('.array-item').length + const itemHtml = ` +
+ + +
+ ` + itemsContainer.insertAdjacentHTML('beforeend', itemHtml) + + // Add event listeners to the new item + const container = itemsContainer.closest('.dynamic-array-widget') + addRemoveEventListeners(container) + addInputEventListeners(container) + updateRemoveButtonVisibility(container) + + // Focus on the new input + const newItem = itemsContainer.lastElementChild + const newInput = newItem.querySelector('.array-item-input') + newInput?.focus() +} + +const addRemoveEventListeners = (container) => { + const removeButtons = container.querySelectorAll('.remove-array-item') + removeButtons.forEach(button => { + button.removeEventListener('click', handleRemoveItem) + button.addEventListener('click', handleRemoveItem) + }) +} + +const addInputEventListeners = (container) => { + const inputs = container.querySelectorAll('.array-item-input') + inputs.forEach(input => { + // removal should not be necessary, but better safe than sorry + input.removeEventListener('input', handleInputChange) + input.removeEventListener('blur', handleInputChange) + input.addEventListener('input', handleInputChange) + input.addEventListener('blur', handleInputChange) + }) +} + +const handleRemoveItem = (ev) => { + const button = ev.target + const arrayItem = button.closest('.array-item') + const container = button.closest('.dynamic-array-widget') + const itemsContainer = container.querySelector('.array-items') + + const itemCount = itemsContainer.querySelectorAll('.array-item').length + if (itemCount <= 1) { + const input = arrayItem.querySelector('.array-item-input') + if (input) { input.value = '' } + } else { + arrayItem.remove() + } + + updateHiddenInput(container) + reindexItems(container) + updateRemoveButtonVisibility(container) +} + +const handleInputChange = (ev) => { + const container = ev.target.closest('.dynamic-array-widget') + updateHiddenInput(container) +} + +const updateHiddenInput = (container) => { + const inputs = container.querySelectorAll('.array-item-input') + const hiddenInput = container.querySelector('input[type="hidden"]') + + if (!hiddenInput) return + + const values = Array.from(inputs) + .map(input => input.value.trim()) + .filter(value => value !== '') + + hiddenInput.value = JSON.stringify(values) +} + +const reindexItems = (container) => { + const items = container.querySelectorAll('.array-item-input') + items.forEach((input, index) => { + input.dataset.index = index + }) +} + +const updateRemoveButtonVisibility = (container) => { + const items = container.querySelectorAll('.array-item') + const removeButtons = container.querySelectorAll('.remove-array-item') + + // Show/hide remove buttons based on item count + removeButtons.forEach(button => { + if (items.length <= 1) { + button.style.display = 'none' + } else { + button.style.display = 'inline-block' + } + }) +} + +document.addEventListener('DOMContentLoaded', initDynamicArrayWidget) +document.addEventListener('htmx:afterSwap', initDynamicArrayWidget) +document.addEventListener('htmx:afterSettle', initDynamicArrayWidget)