diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index fb64a2b..87da376 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -1,6 +1,3 @@ -import json -from pathlib import Path - from django.contrib import admin, messages from django.utils.translation import gettext_lazy as _ from django_jsonform.widgets import JSONFormWidget @@ -316,9 +313,9 @@ class ServiceDefinitionAdmin(admin.ModelAdmin): ( _("Form Configuration"), { - "fields": ("form_config",), + "fields": ("advanced_fields",), "description": _( - "Optional custom form configuration. When provided, this will be used instead of auto-generating the form from the OpenAPI spec." + "Configure which fields should be hidden behind an 'Advanced' toggle in the form" ), }, ), @@ -326,13 +323,19 @@ class ServiceDefinitionAdmin(admin.ModelAdmin): def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) - schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json" - with open(schema_path) as f: - form_config_schema = json.load(f) - - if "form_config" in form.base_fields: - form.base_fields["form_config"].widget = JSONFormWidget( - schema=form_config_schema + # JSON schema for advanced_fields field + advanced_fields_schema = { + "type": "array", + "title": "Advanced Fields", + "items": { + "type": "string", + "title": "Field Name", + "description": "Field name in dot notation (e.g., spec.parameters.monitoring.enabled)", + }, + } + if "advanced_fields" in form.base_fields: + form.base_fields["advanced_fields"].widget = JSONFormWidget( + schema=advanced_fields_schema ) return form diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index e414168..5d5c34e 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -6,8 +6,7 @@ from django.db import models from django.forms.models import ModelForm, ModelFormMetaclass from django.utils.translation import gettext_lazy as _ -from servala.core.models import ServiceInstance, ControlPlaneCRD -from servala.frontend.forms.widgets import DynamicArrayField, DynamicArrayWidget +from servala.core.models import ServiceInstance class CRDModel(models.Model): @@ -23,11 +22,17 @@ class CRDModel(models.Model): def duplicate_field(field_name, model): + # Get the field from the model field = model._meta.get_field(field_name) + + # Create a new field with the same attributes new_field = type(field).__new__(type(field)) new_field.__dict__.update(field.__dict__) + + # Ensure the field is not linked to the original model new_field.model = None new_field.auto_created = False + return new_field @@ -37,7 +42,7 @@ def generate_django_model(schema, group, version, kind): """ # We always need these three fields to know our own name and our full namespace model_fields = {"__module__": "crd_models"} - for field_name in ("name", "context"): + for field_name in ("name", "organization", "context"): model_fields[field_name] = duplicate_field(field_name, ServiceInstance) # All other fields are generated from the schema, except for the @@ -245,6 +250,8 @@ def get_django_field( ) elif field_type == "array": 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, @@ -284,31 +291,7 @@ def unnest_data(data): return result -class FormGeneratorMixin: - IS_CUSTOM_FORM = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if "context" in self.fields: - self.fields["context"].widget = forms.HiddenInput() - if crd := self.initial.get("context"): - crd = getattr(crd, "pk", crd) # can be int or object - self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=crd) - - if self.instance and hasattr(self.instance, "name") and self.instance.name: - if "name" in self.fields: - self.fields["name"].disabled = True - self.fields["name"].widget = forms.HiddenInput() - - def has_mandatory_fields(self, field_list): - for field_name in field_list: - if field_name in self.fields and self.fields[field_name].required: - return True - return False - - -class CrdModelFormMixin(FormGeneratorMixin): +class CrdModelFormMixin: HIDDEN_FIELDS = [ "spec.compositeDeletePolicy", "spec.compositionRef", @@ -334,6 +317,9 @@ class CrdModelFormMixin(FormGeneratorMixin): super().__init__(*args, **kwargs) self.schema = self._meta.model.SCHEMA + for field in ("organization", "context"): + self.fields[field].widget = forms.HiddenInput() + for name, field in self.fields.items(): if name in self.HIDDEN_FIELDS or any( name.startswith(f) for f in self.HIDDEN_FIELDS @@ -341,11 +327,22 @@ class CrdModelFormMixin(FormGeneratorMixin): field.widget = forms.HiddenInput() field.required = False + if self.instance and self.instance.pk: + self.fields["name"].disabled = True + self.fields["name"].help_text = _("Name cannot be changed after creation.") + self.fields["name"].widget = forms.HiddenInput() + def strip_title(self, field_name, label): field = self.fields[field_name] if field and field.label and (position := field.label.find(label)) != -1: field.label = field.label[position + len(label) :] + def has_mandatory_fields(self, field_list): + for field_name in field_list: + if field_name in self.fields and self.fields[field_name].required: + return True + return False + def get_fieldsets(self): fieldsets = [] @@ -527,136 +524,3 @@ def generate_model_form_class(model): } class_name = f"{model.__name__}ModelForm" return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields) - - -class CustomFormMixin(FormGeneratorMixin): - """ - Base for custom (user-friendly) forms generated from ServiceDefinition.form_config. - """ - - IS_CUSTOM_FORM = True - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._apply_field_config() - if ( - self.instance - and hasattr(self.instance, "name") - and self.instance.name - and "name" in self.fields - ): - self.fields["name"].widget = forms.HiddenInput() - self.fields["name"].disabled = True - self.fields.pop("context", None) - - def _apply_field_config(self): - for fieldset in self.form_config.get("fieldsets", []): - for field_config in fieldset.get("fields", []): - field_name = field_config.get("controlplane_field_mapping") - - if field_name not in self.fields: - continue - - field = self.fields[field_name] - field_type = field_config.get("type") - - field.label = field_config.get("label", field_config["name"]) - field.help_text = field_config.get("help_text", "") - field.required = field_config.get("required", False) - - if field_type == "textarea": - field.widget = forms.Textarea( - attrs={"rows": field_config.get("rows", 4)} - ) - elif field_type == "array": - field.widget = DynamicArrayWidget() - - if field_type == "number": - min_val = field_config.get("min_value") - max_val = field_config.get("max_value") - - validators = [] - if min_val is not None: - validators.append(MinValueValidator(min_val)) - if max_val is not None: - validators.append(MaxValueValidator(max_val)) - - if validators: - field.validators.extend(validators) - - field.controlplane_field_mapping = field_name - - def get_fieldsets(self): - fieldsets = [] - for fieldset_config in self.form_config.get("fieldsets", []): - field_names = [ - f["controlplane_field_mapping"] - for f in fieldset_config.get("fields", []) - ] - fieldset = { - "title": fieldset_config.get("title", "General"), - "fields": field_names, - "fieldsets": [], - "has_mandatory": self.has_mandatory_fields(field_names), - } - fieldsets.append(fieldset) - - return fieldsets - - def get_nested_data(self): - nested = {} - for field_name in self.fields.keys(): - if field_name == "context": - value = self.cleaned_data.get(field_name) - if value is not None: - nested[field_name] = value - continue - - mapping = field_name - value = self.cleaned_data.get(field_name) - parts = mapping.split(".") - current = nested - for part in parts[:-1]: - if part not in current: - current[part] = {} - current = current[part] - - current[parts[-1]] = value - - return nested - - -def generate_custom_form_class(form_config, model): - """ - Generate a custom (user-friendly) form class from form_config JSON. - """ - field_list = ["context", "name"] - - for fieldset in form_config.get("fieldsets", []): - for field_config in fieldset.get("fields", []): - field_name = field_config.get("controlplane_field_mapping") - if field_name: - field_list.append(field_name) - - fields = { - "context": forms.ModelChoiceField( - queryset=ControlPlaneCRD.objects.none(), - required=True, - widget=forms.HiddenInput(), - ), - } - - meta_attrs = { - "model": model, - "fields": field_list, - } - - form_fields = { - "Meta": type("Meta", (object,), meta_attrs), - "__module__": "crd_models", - "form_config": form_config, - **fields, - } - - class_name = f"{model.__name__}CustomForm" - return ModelFormMetaclass(class_name, (CustomFormMixin, ModelForm), form_fields) diff --git a/src/servala/core/forms.py b/src/servala/core/forms.py index 9742233..034d1c9 100644 --- a/src/servala/core/forms.py +++ b/src/servala/core/forms.py @@ -1,7 +1,3 @@ -import json -from pathlib import Path - -import jsonschema from django import forms from django.utils.translation import gettext_lazy as _ from django_jsonform.widgets import JSONFormWidget @@ -128,10 +124,6 @@ class ServiceDefinitionAdminForm(forms.ModelForm): self.fields["api_version"].initial = api_def.get("version", "") self.fields["api_kind"].initial = api_def.get("kind", "") - schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json" - with open(schema_path) as f: - self.form_config_schema = json.load(f) - def clean(self): cleaned_data = super().clean() @@ -159,25 +151,6 @@ class ServiceDefinitionAdminForm(forms.ModelForm): api_def["kind"] = api_kind cleaned_data["api_definition"] = api_def - form_config = cleaned_data.get("form_config") - if form_config: - try: - jsonschema.validate( - instance=form_config, schema=self.form_config_schema - ) - except jsonschema.ValidationError as e: - raise forms.ValidationError( - { - "form_config": _("Invalid form configuration: {}").format( - e.message - ) - } - ) - except jsonschema.SchemaError as e: - raise forms.ValidationError( - {"form_config": _("Schema error: {}").format(e.message)} - ) - return cleaned_data def save(self, *args, **kwargs): diff --git a/src/servala/core/migrations/0012_remove_advanced_fields.py b/src/servala/core/migrations/0012_remove_advanced_fields.py deleted file mode 100644 index d60d4cc..0000000 --- a/src/servala/core/migrations/0012_remove_advanced_fields.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-31 10:40 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0011_alter_organizationorigin_billing_entity"), - ] - - operations = [ - migrations.RemoveField( - model_name="servicedefinition", - name="advanced_fields", - ), - migrations.AlterField( - model_name="organization", - name="name", - field=models.CharField( - max_length=32, - validators=[ - django.core.validators.RegexValidator( - message="Organization name can only contain letters, numbers, and spaces.", - regex="^[A-Za-z0-9\\s]+$", - ) - ], - verbose_name="Name", - ), - ), - ] diff --git a/src/servala/core/migrations/0013_add_form_config.py b/src/servala/core/migrations/0013_add_form_config.py deleted file mode 100644 index bd35891..0000000 --- a/src/servala/core/migrations/0013_add_form_config.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-31 10:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0012_remove_advanced_fields"), - ] - - operations = [ - migrations.AddField( - model_name="servicedefinition", - name="form_config", - field=models.JSONField( - blank=True, - help_text='Optional custom form configuration. When provided, this configuration will be used to render the service form instead of auto-generating it from the OpenAPI spec. Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}', - null=True, - verbose_name="Form Configuration", - ), - ), - ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 1535703..d03ef6b 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -360,16 +360,6 @@ class ServiceDefinition(ServalaModelMixin, models.Model): null=True, blank=True, ) - form_config = models.JSONField( - verbose_name=_("Form Configuration"), - help_text=_( - "Optional custom form configuration. When provided, this configuration will be used " - "to render the service form instead of auto-generating it from the OpenAPI spec. " - 'Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}' - ), - null=True, - blank=True, - ) service = models.ForeignKey( to="Service", on_delete=models.CASCADE, @@ -512,22 +502,6 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): return return generate_model_form_class(self.django_model) - @cached_property - def custom_model_form_class(self): - from servala.core.crd import generate_custom_form_class - - if not self.django_model: - return - if not ( - self.service_definition - and self.service_definition.form_config - and self.service_definition.form_config.get("fieldsets") - ): - return - return generate_custom_form_class( - self.service_definition.form_config, self.django_model - ) - class ServiceOffering(ServalaModelMixin, models.Model): """ @@ -891,6 +865,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): return return self.context.django_model( name=self.name, + organization=self.organization, context=self.context, spec=self.spec, # We pass -1 as ID in order to make it clear that a) this object exists (remotely), diff --git a/src/servala/core/schemas/form_config_schema.json b/src/servala/core/schemas/form_config_schema.json deleted file mode 100644 index 1049ed8..0000000 --- a/src/servala/core/schemas/form_config_schema.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Service Definition Form Configuration Schema", - "description": "Schema for custom form configuration in ServiceDefinition", - "type": "object", - "required": ["fieldsets"], - "properties": { - "fieldsets": { - "type": "array", - "description": "Array of fieldset objects defining form sections", - "minItems": 1, - "items": { - "type": "object", - "required": ["fields"], - "properties": { - "title": { - "type": "string", - "description": "Optional title for the fieldset/tab" - }, - "fields": { - "type": "array", - "description": "Array of field definitions in this fieldset", - "minItems": 1, - "items": { - "type": "object", - "required": ["name", "type", "label", "controlplane_field_mapping"], - "properties": { - "name": { - "type": "string", - "description": "Unique field name/identifier", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" - }, - "type": { - "type": "string", - "description": "Field type", - "enum": ["text", "email", "textarea", "number", "choice", "checkbox", "array"] - }, - "label": { - "type": "string", - "description": "Human-readable field label" - }, - "help_text": { - "type": "string", - "description": "Optional help text displayed below the field" - }, - "required": { - "type": "boolean", - "description": "Whether the field is required", - "default": false - }, - "controlplane_field_mapping": { - "type": "string", - "description": "Dot-notation path mapping to Kubernetes spec field (e.g., 'spec.parameters.service.fqdn')" - }, - "max_length": { - "type": "integer", - "description": "Maximum length for text/textarea fields", - "minimum": 1 - }, - "rows": { - "type": "integer", - "description": "Number of rows for textarea fields", - "minimum": 1 - }, - "min_value": { - "type": "number", - "description": "Minimum value for number fields" - }, - "max_value": { - "type": "number", - "description": "Maximum value for number fields" - }, - "choices": { - "type": "array", - "description": "Array of [value, label] pairs for choice fields", - "items": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "min_values": { - "type": "integer", - "description": "Minimum number of values for array fields", - "minimum": 0 - }, - "max_values": { - "type": "integer", - "description": "Maximum number of values for array fields", - "minimum": 1 - }, - "validators": { - "type": "array", - "description": "Array of validator names (for future use)", - "items": { - "type": "string", - "enum": ["email", "fqdn", "url", "ipv4", "ipv6"] - } - }, - "generators": { - "type": "array", - "description": "Array of generator function names (for future use)", - "items": { - "type": "string", - "enum": ["suggest_fqdn_from_name"] - } - } - } - } - } - } - } - } - } -} diff --git a/src/servala/frontend/templates/frontend/forms/dynamic_array.html b/src/servala/frontend/templates/frontend/forms/dynamic_array.html index 00c23b0..9d61825 100644 --- a/src/servala/frontend/templates/frontend/forms/dynamic_array.html +++ b/src/servala/frontend/templates/frontend/forms/dynamic_array.html @@ -1,5 +1,5 @@
{% endif %} {% for text in field.errors %}
{{ text }}
{% endfor %} - {% if field.help_text and not field.is_hidden and not field.field.widget.input_type == "hidden" %} + {% if field.help_text %} {{ field.help_text|safe }} {% endif %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html index 74259e6..51a9213 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html @@ -22,7 +22,7 @@ {% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
{% else %} - {% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form %} + {% include "includes/tabbed_fieldset_form.html" with form=form %} {% endif %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html index 53e32d2..3305328 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -17,7 +17,7 @@ {% endif %} {% endpartialdef %} {% partialdef service-form %} -{% if service_form or custom_service_form %} +{% if service_form %}
@@ -26,7 +26,7 @@ {% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
{% else %} - {% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form %} + {% include "includes/tabbed_fieldset_form.html" with form=service_form %} {% endif %}
diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index 41f1af2..c34c41a 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -6,138 +6,55 @@ {% if form_action %}action="{{ form_action }}"{% endif %}> {% csrf_token %} {% include "frontend/forms/errors.html" %} - {% if form %} -
- -
- {% endif %} -
- {% if form and form.context %}{{ form.context }}{% endif %} - {% if form and form.get_fieldsets|length == 1 %} - {# Single fieldset - render without tabs #} - {% for fieldset in form.get_fieldsets %} -
- {% for field in fieldset.fields %} - {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} - {% for subfieldset in fieldset.fieldsets %} - {% if subfieldset.fields %} -
-

{{ subfieldset.title }}

- {% for field in subfieldset.fields %} - {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} -
- {% endif %} - {% endfor %} -
- {% endfor %} - {% elif form %} - {# Multiple fieldsets or auto-generated form - render with tabs #} - -
- {% for fieldset in form.get_fieldsets %} -
- {% for field in fieldset.fields %} - {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} - {% for subfieldset in fieldset.fieldsets %} - {% if subfieldset.fields %} -
-

{{ subfieldset.title }}

- {% for field in subfieldset.fields %} - {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} -
- {% endif %} - {% endfor %} -
- {% endfor %}
- {% endif %} + {% endfor %}
- {% if form and expert_form %} - - {% endif %} - {% if form %} - - {% endif %}
- +
-{% if form %} - -{% endif %} diff --git a/src/servala/frontend/templatetags/get_field.py b/src/servala/frontend/templatetags/get_field.py index 2141178..3214beb 100644 --- a/src/servala/frontend/templatetags/get_field.py +++ b/src/servala/frontend/templatetags/get_field.py @@ -1,5 +1,3 @@ -from contextlib import suppress - from django import template register = template.Library() @@ -7,5 +5,4 @@ register = template.Library() @register.filter def get_field(form, field_name): - with suppress(KeyError): - return form[field_name] + return form[field_name] diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 2c6923e..689f381 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -123,7 +123,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView def context_object(self): if self.request.method == "POST": return ControlPlaneCRD.objects.filter( - pk=self.request.POST.get("expert-context", self.request.POST.get("custom-context")), + pk=self.request.POST.get("context"), # Make sure we don’t use a malicious ID control_plane__in=self.planes, ).first() @@ -131,49 +131,37 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView control_plane=self.selected_plane, service_offering=self.object ).first() + def get_instance_form(self): + if not self.context_object or not self.context_object.model_form_class: + return None - def get_instance_form_kwargs(self, ignore_data=False): - return {"initial": { + initial = { "organization": self.request.organization, "context": self.context_object, - }, "prefix": "expert", "data": self.request.POST if (self.request.method == "POST" and not ignore_data) else None - } + } - def get_instance_form(self, ignore_data=False): - if not self.context_object or not self.context_object.model_form_class: - return + # Pre-populate FQDN field if it exists and control plane has wildcard DNS + form_class = self.context_object.model_form_class + if ( + "spec.parameters.service.fqdn" in form_class.base_fields + and self.context_object.control_plane.wildcard_dns + ): + # Generate initial FQDN: instancename-namespace.wildcard_dns + # We'll set a placeholder that JavaScript will replace dynamically + initial["spec.parameters.service.fqdn"] = "" - return self.context_object.model_form_class(**self.get_instance_form_kwargs(ignore_data=ignore_data)) - - def get_custom_instance_form(self, ignore_data=False): - if not self.context_object or not self.context_object.custom_model_form_class: - return - kwargs = self.get_instance_form_kwargs(ignore_data=ignore_data) - kwargs["prefix"] = "custom" - return self.context_object.custom_model_form_class(**kwargs) - - @property - def is_custom_form(self): - # Note: "custom form" = user-friendly, subset of fields - # vs "expert form" = auto-generated (all technical fields) - return self.request.POST.get("active_form", "expert") == "custom" + return form_class( + data=self.request.POST if self.request.method == "POST" else None, + initial=initial, + ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["select_form"] = self.select_form context["has_control_planes"] = self.planes.exists() context["selected_plane"] = self.selected_plane - if self.request.method == "POST": - if self.is_custom_form: - context["service_form"] = self.get_instance_form(ignore_data=True) - context["custom_service_form"] = self.get_custom_instance_form() - else: - context["service_form"] = self.get_instance_form() - context["custom_service_form"] = self.get_custom_instance_form(ignore_data=True) - else: - context["service_form"] = self.get_instance_form() - context["custom_service_form"] = self.get_custom_instance_form() - + context["service_form"] = self.get_instance_form() + # Pass data for dynamic FQDN generation if self.selected_plane and self.selected_plane.wildcard_dns: context["wildcard_dns"] = self.selected_plane.wildcard_dns context["organization_namespace"] = self.request.organization.namespace @@ -187,10 +175,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView context["form_error"] = True return self.render_to_response(context) - if self.is_custom_form: - form = self.get_custom_instance_form() - else: - form = self.get_instance_form() + form = self.get_instance_form() if not form: # Should not happen if context_object is valid, but as a safeguard messages.error( self.request, @@ -218,6 +203,8 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView ) form.add_error(None, error_message) + # If the form is not valid or if the service creation failed, we render it again + context["service_form"] = form return self.render_to_response(context) @@ -406,75 +393,11 @@ class ServiceInstanceUpdateView( def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["instance"] = self.object.spec_object - kwargs["prefix"] = "expert" return kwargs - def get_form(self, *args, ignore_data=False, **kwargs): - if not ignore_data: - return super().get_form(*args, **kwargs) - cls = self.get_form_class() - kwargs = self.get_form_kwargs() - if ignore_data: - kwargs.pop("data", None) - return cls(**kwargs) - - def get_custom_form(self, ignore_data=False): - cls = self.object.context.custom_model_form_class - if not cls: - return - kwargs = self.get_form_kwargs() - kwargs["prefix"] = "custom" - if ignore_data: - kwargs.pop("data", None) - return cls(**kwargs) - - @property - def is_custom_form(self): - # Note: "custom form" = user-friendly, subset of fields - # vs "expert form" = auto-generated (all technical fields) - return self.request.POST.get("active_form", "expert") == "custom" - - def post(self, request, *args, **kwargs): - self.object = self.get_object() - - if self.is_custom_form: - form = self.get_custom_form() - else: - form = self.get_form() - - if form.is_valid(): - return self.form_valid(form) - return self.form_invalid(form) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - if self.request.method == "POST": - if self.is_custom_form: - context["custom_form"] = self.get_custom_form() - context["form"] = self.get_form(ignore_data=True) - else: - context["custom_form"] = self.get_custom_form(ignore_data=True) - else: - context["custom_form"] = self.get_custom_form() - return context - - def _deep_merge(self, base, update): - for key, value in update.items(): - if key in base and isinstance(base[key], dict) and isinstance(value, dict): - self._deep_merge(base[key], value) - else: - base[key] = value - return base - def form_valid(self, form): try: - form_data = form.get_nested_data() - spec_data = form_data.get("spec") - - if self.is_custom_form: - current_spec = dict(self.object.spec) if self.object.spec else {} - spec_data = self._deep_merge(current_spec, spec_data) - + spec_data = form.get_nested_data().get("spec") self.object.update_spec(spec_data=spec_data, updated_by=self.request.user) messages.success( self.request, diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css index cc69a4f..067720b 100644 --- a/src/servala/static/css/servala.css +++ b/src/servala/static/css/servala.css @@ -237,42 +237,42 @@ a.btn-keycloak { flex-grow: 1; } -/* Expert CRD Form mandatory field styling */ -.expert-crd-form .form-group.mandatory .form-label { +/* CRD Form mandatory field styling */ +.crd-form .form-group.mandatory .form-label { font-weight: bold; position: relative; } -.expert-crd-form .form-group.mandatory .form-label::after { +.crd-form .form-group.mandatory .form-label::after { content: " *"; color: #dc3545; font-weight: bold; } -.expert-crd-form .form-group.mandatory { +.crd-form .form-group.mandatory { border-left: 3px solid #dc3545; padding-left: 10px; background-color: rgba(220, 53, 69, 0.05); border-radius: 3px; } -.expert-crd-form .nav-tabs .nav-link .mandatory-indicator { +.crd-form .nav-tabs .nav-link .mandatory-indicator { color: #dc3545; font-weight: bold; font-size: 1.1em; margin-left: 4px; } -html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory { +html[data-bs-theme="dark"] .crd-form .form-group.mandatory { background-color: rgba(220, 53, 69, 0.1); border-left-color: #ff6b6b; } -html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory .form-label::after { +html[data-bs-theme="dark"] .crd-form .form-group.mandatory .form-label::after { color: #ff6b6b; } -html[data-bs-theme="dark"] .expert-crd-form .nav-tabs .nav-link .mandatory-indicator { +html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator { color: #ff6b6b; } diff --git a/src/servala/static/js/expert-mode.js b/src/servala/static/js/expert-mode.js deleted file mode 100644 index d83bb69..0000000 --- a/src/servala/static/js/expert-mode.js +++ /dev/null @@ -1,48 +0,0 @@ -(function() { - 'use strict'; - - let isExpertMode = false; - - function initExpertMode() { - const toggleButton = document.getElementById('expert-mode-toggle'); - if (!toggleButton) return; - - const customFormContainer = document.getElementById('custom-form-container'); - const expertFormContainer = document.getElementById('expert-form-container'); - - if (!customFormContainer || !expertFormContainer) { - console.warn('Expert mode containers not found'); - return; - } - - toggleButton.addEventListener('click', function() { - isExpertMode = !isExpertMode; - - const activeFormInput = document.getElementById('active-form-input'); - - if (isExpertMode) { - customFormContainer.style.display = 'none'; - expertFormContainer.style.display = 'block'; - toggleButton.innerHTML = ' Show Simplified Form'; - if (activeFormInput) activeFormInput.value = 'expert'; - } else { - customFormContainer.style.display = 'block'; - expertFormContainer.style.display = 'none'; - toggleButton.innerHTML = ' Show Expert Mode'; - if (activeFormInput) activeFormInput.value = 'custom'; - } - }); - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initExpertMode); - } else { - initExpertMode(); - } - - document.addEventListener('htmx:afterSwap', function(event) { - if (event.detail.target.id === 'service-form' || event.detail.target.classList.contains('crd-form')) { - initExpertMode(); - } - }); -})(); diff --git a/src/servala/static/js/fqdn.js b/src/servala/static/js/fqdn.js index 178c586..a92071d 100644 --- a/src/servala/static/js/fqdn.js +++ b/src/servala/static/js/fqdn.js @@ -1,7 +1,7 @@ -const initializeFqdnGeneration = (prefix) => { - const nameField = document.querySelector(`input#id_${prefix}-name`); - const fqdnField = document.getElementById(`${prefix}-spec.parameters.service.fqdn_container`).querySelector('input.array-item-input'); +const initializeFqdnGeneration = () => { + const nameField = document.querySelector('input#id_name'); + const fqdnField = document.querySelector('label[for="id_spec.parameters.service.fqdn"] + div input.array-item-input'); if (nameField && fqdnField) { const generateFqdn = (instanceName) => { @@ -9,23 +9,28 @@ const initializeFqdnGeneration = (prefix) => { return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`; } - nameField.addEventListener('input', function() { - if (!fqdnField.dataset.manuallyEdited) { - fqdnField.value = generateFqdn(this.value); - const container = fqdnField.closest('.dynamic-array-widget'); + const newNameField = nameField.cloneNode(true); + nameField.parentNode.replaceChild(newNameField, nameField); + const newFqdnField = fqdnField.cloneNode(true); + fqdnField.parentNode.replaceChild(newFqdnField, fqdnField); + + newNameField.addEventListener('input', function() { + if (!newFqdnField.dataset.manuallyEdited) { + newFqdnField.value = generateFqdn(this.value); + const container = newFqdnField.closest('.dynamic-array-widget'); if (container && window.updateHiddenInput) { window.updateHiddenInput(container); } } }); - fqdnField.addEventListener('input', function() { + newFqdnField.addEventListener('input', function() { this.dataset.manuallyEdited = 'true'; }); - if (nameField.value && !fqdnField.value) { - fqdnField.value = generateFqdn(nameField.value); - const container = fqdnField.closest('.dynamic-array-widget'); + if (newNameField.value && !newFqdnField.value) { + newFqdnField.value = generateFqdn(newNameField.value); + const container = newFqdnField.closest('.dynamic-array-widget'); if (container && window.updateHiddenInput) { window.updateHiddenInput(container); } @@ -33,10 +38,9 @@ const initializeFqdnGeneration = (prefix) => { } } -document.addEventListener('DOMContentLoaded', () => {initializeFqdnGeneration("custom"), initializeFqdnGeneration("expert")}); +document.addEventListener('DOMContentLoaded', initializeFqdnGeneration); document.body.addEventListener('htmx:afterSwap', function(event) { if (event.detail.target.id === 'service-form') { - initializeFqdnGeneration("custom"); - initializeFqdnGeneration("expert"); + initializeFqdnGeneration(); } });