From 51022d478d5e219233a9881d57d4cdb576d8d2d0 Mon Sep 17 00:00:00 2001 From: Pierre Cheynier Date: Thu, 19 Feb 2026 08:49:35 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Support=20additionalProperties=20sc?= =?UTF-8?q?hema=20for=20structured=20map=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for JSON Schema `additionalProperties` on object types, enabling maps with structured values (in addition to lists) to render as proper forms instead of flat key/json-value text inputs. Backend: - Parse `additionalProperties` in helm schema (using a custom RawJSON type to handle both boolean and object values from json-iterator) - Map it to a new AdditionalProperties field on the Field model - Propagate through the schema-to-field mapper Frontend: - Split MapField into FlatMapField (existing behavior) and StructuredMapField - Update form value converters (mapsToArray / findMaps) to handle structured map entries Co-authored-by: Cursor --- cyclops-ctrl/internal/mapper/helm.go | 88 +++++---- .../internal/models/helm/helmschema.go | 63 +++++-- cyclops-ctrl/internal/models/templates.go | 27 +-- .../components/form/TemplateFormFields.tsx | 3 + .../components/form/fields/map/MapField.tsx | 172 +++++++++++++++++- cyclops-ui/src/utils/form.tsx | 74 +++++--- 6 files changed, 336 insertions(+), 91 deletions(-) diff --git a/cyclops-ctrl/internal/mapper/helm.go b/cyclops-ctrl/internal/mapper/helm.go index 47d8b04a2..f59bcae25 100644 --- a/cyclops-ctrl/internal/mapper/helm.go +++ b/cyclops-ctrl/internal/mapper/helm.go @@ -57,50 +57,58 @@ func HelmSchemaToFields(name string, schema helm.Property, defs map[string]helm. } fields = append(fields, models.Field{ - Name: dependency.Name, - Description: dependency.RootField.Description, - Type: dependency.RootField.Type, - DisplayName: mapTitle(dependency.Name, dependency.RootField.DisplayName), - ManifestKey: dependency.RootField.ManifestKey, - Value: dependency.RootField.Value, - Properties: dependency.RootField.Properties, - Items: dependency.RootField.Items, - Enum: dependency.RootField.Enum, - Suggestions: dependency.RootField.Suggestions, - Required: dependency.RootField.Required, - FileExtension: dependency.RootField.FileExtension, - Minimum: dependency.RootField.Minimum, - Maximum: dependency.RootField.Maximum, - MultipleOf: dependency.RootField.MultipleOf, - ExclusiveMinimum: dependency.RootField.ExclusiveMinimum, - ExclusiveMaximum: dependency.RootField.ExclusiveMaximum, - MinLength: dependency.RootField.MinLength, - MaxLength: dependency.RootField.MaxLength, - Pattern: dependency.RootField.Pattern, - Immutable: dependency.RootField.Immutable, + Name: dependency.Name, + Description: dependency.RootField.Description, + Type: dependency.RootField.Type, + DisplayName: mapTitle(dependency.Name, dependency.RootField.DisplayName), + ManifestKey: dependency.RootField.ManifestKey, + Value: dependency.RootField.Value, + Properties: dependency.RootField.Properties, + AdditionalProperties: dependency.RootField.AdditionalProperties, + Items: dependency.RootField.Items, + Enum: dependency.RootField.Enum, + Suggestions: dependency.RootField.Suggestions, + Required: dependency.RootField.Required, + FileExtension: dependency.RootField.FileExtension, + Minimum: dependency.RootField.Minimum, + Maximum: dependency.RootField.Maximum, + MultipleOf: dependency.RootField.MultipleOf, + ExclusiveMinimum: dependency.RootField.ExclusiveMinimum, + ExclusiveMaximum: dependency.RootField.ExclusiveMaximum, + MinLength: dependency.RootField.MinLength, + MaxLength: dependency.RootField.MaxLength, + Pattern: dependency.RootField.Pattern, + Immutable: dependency.RootField.Immutable, }) } + var additionalProps *models.Field + if ap := schema.GetAdditionalProperties(); ap != nil { + apField := HelmSchemaToFields("", *ap, defs, nil) + additionalProps = &apField + } + return models.Field{ - Name: name, - Description: schema.Description, - Type: mapHelmPropertyTypeToFieldType(schema), - DisplayName: mapTitle(name, schema.Title), - ManifestKey: name, - Properties: fields, - Enum: schema.Enum, - Suggestions: schema.Suggestions, - Required: schema.Required, - FileExtension: schema.FileExtension, - Minimum: schema.Minimum, - Maximum: schema.Maximum, - ExclusiveMinimum: schema.ExclusiveMinimum, - ExclusiveMaximum: schema.ExclusiveMaximum, - MultipleOf: schema.MultipleOf, - MinLength: schema.MinLength, - MaxLength: schema.MaxLength, - Pattern: schema.Pattern, - Immutable: schema.Immutable, + Name: name, + Description: schema.Description, + Type: mapHelmPropertyTypeToFieldType(schema), + DisplayName: mapTitle(name, schema.Title), + ManifestKey: name, + Properties: fields, + AdditionalProperties: additionalProps, + Enum: schema.Enum, + Suggestions: schema.Suggestions, + Required: schema.Required, + FileExtension: schema.FileExtension, + Minimum: schema.Minimum, + Maximum: schema.Maximum, + ExclusiveMinimum: schema.ExclusiveMinimum, + ExclusiveMaximum: schema.ExclusiveMaximum, + MultipleOf: schema.MultipleOf, + MinLength: schema.MinLength, + MaxLength: schema.MaxLength, + Pattern: schema.Pattern, + Immutable: schema.Immutable, } } diff --git a/cyclops-ctrl/internal/models/helm/helmschema.go b/cyclops-ctrl/internal/models/helm/helmschema.go index 85ed8dc36..2cf3df0f9 100644 --- a/cyclops-ctrl/internal/models/helm/helmschema.go +++ b/cyclops-ctrl/internal/models/helm/helmschema.go @@ -6,20 +6,40 @@ import ( json "github.com/json-iterator/go" ) +// RawJSON captures any JSON value (object, boolean, string, etc.) without +// failing on type mismatches. +type RawJSON []byte + +func (r *RawJSON) UnmarshalJSON(data []byte) error { + if data == nil { + return nil + } + *r = append((*r)[0:0], data...) + return nil +} + +func (r RawJSON) MarshalJSON() ([]byte, error) { + if r == nil { + return []byte("null"), nil + } + return r, nil +} + type Property struct { - Title string `json:"title"` - Type PropertyType `json:"type"` - Description string `json:"description"` - Order []string `json:"order"` - Properties map[string]Property `json:"properties"` - Items *Property `json:"items"` - Enum []interface{} `json:"enum"` - Suggestions []interface{} `json:"x-suggestions"` - Required []string `json:"required"` - FileExtension string `json:"fileExtension"` - Reference string `json:"$ref"` - Definitions map[string]Property `json:"$defs"` - Immutable bool `json:"immutable"` + Title string `json:"title"` + Type PropertyType `json:"type"` + Description string `json:"description"` + Order []string `json:"order"` + Properties map[string]Property `json:"properties"` + AdditionalProperties RawJSON `json:"additionalProperties,omitempty"` + Items *Property `json:"items"` + Enum []interface{} `json:"enum"` + Suggestions []interface{} `json:"x-suggestions"` + Required []string `json:"required"` + FileExtension string `json:"fileExtension"` + Reference string `json:"$ref"` + Definitions map[string]Property `json:"$defs"` + Immutable bool `json:"immutable"` // number validation Minimum *float64 `json:"minimum"` @@ -37,6 +57,23 @@ type Property struct { AnyOf []Property `json:"anyOf"` } +// GetAdditionalProperties attempts to parse additionalProperties as a schema +// object. Returns nil when the value is absent, a boolean, or otherwise not a +// valid Property (e.g. additionalProperties: false). +func (p Property) GetAdditionalProperties() *Property { + if len(p.AdditionalProperties) == 0 { + return nil + } + var prop Property + if err := json.Unmarshal(p.AdditionalProperties, &prop); err != nil { + return nil + } + if len(prop.Type) == 0 && len(prop.Properties) == 0 { + return nil + } + return &prop +} + type PropertyType string func (t *PropertyType) UnmarshalJSON(data []byte) error { diff --git a/cyclops-ctrl/internal/models/templates.go b/cyclops-ctrl/internal/models/templates.go index 4d8f02350..da464b427 100644 --- a/cyclops-ctrl/internal/models/templates.go +++ b/cyclops-ctrl/internal/models/templates.go @@ -30,19 +30,20 @@ type Template struct { } type Field struct { - Name string `json:"name"` - Description string `json:"description"` - Type string `json:"type"` - DisplayName string `json:"display_name"` - ManifestKey string `json:"manifest_key"` - Value string `json:"value"` - Properties []Field `json:"properties"` - Items *Field `json:"items"` - Enum []interface{} `json:"enum"` - Suggestions []interface{} `json:"x-suggestions"` - Required []string `json:"required"` - FileExtension string `json:"fileExtension"` - Immutable bool `json:"immutable"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + ManifestKey string `json:"manifest_key"` + Value string `json:"value"` + Properties []Field `json:"properties"` + AdditionalProperties *Field `json:"additionalProperties,omitempty"` + Items *Field `json:"items"` + Enum []interface{} `json:"enum"` + Suggestions []interface{} `json:"x-suggestions"` + Required []string `json:"required"` + FileExtension string `json:"fileExtension"` + Immutable bool `json:"immutable"` // number validation Minimum *float64 `json:"minimum"` diff --git a/cyclops-ui/src/components/form/TemplateFormFields.tsx b/cyclops-ui/src/components/form/TemplateFormFields.tsx index 4e8db462e..4fb170400 100644 --- a/cyclops-ui/src/components/form/TemplateFormFields.tsx +++ b/cyclops-ui/src/components/form/TemplateFormFields.tsx @@ -188,6 +188,9 @@ export function mapFields( level={level} formItemName={formItemName} isRequired={isRequired} + initialValues={initialValues} + uniqueFieldName={uniqueFieldName} + isModuleEdit={isModuleEdit} />, ); } diff --git a/cyclops-ui/src/components/form/fields/map/MapField.tsx b/cyclops-ui/src/components/form/fields/map/MapField.tsx index 1dd53779c..ce1478b94 100644 --- a/cyclops-ui/src/components/form/fields/map/MapField.tsx +++ b/cyclops-ui/src/components/form/fields/map/MapField.tsx @@ -1,7 +1,22 @@ -import React from "react"; -import { Button, Col, Divider, Form, Input, Row } from "antd"; -import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons"; +import React, { useState } from "react"; +import { + Button, + Col, + Collapse, + Divider, + Form, + Input, + Row, + Tooltip, +} from "antd"; +import { + InfoCircleOutlined, + MinusCircleOutlined, + PlusOutlined, +} from "@ant-design/icons"; import TextArea from "antd/es/input/TextArea"; +import { mapFields } from "../../TemplateFormFields"; +import { collapseColor } from "../utils"; import { useTemplateFormFields } from "../../TemplateFormFieldsContext"; interface Props { @@ -10,9 +25,12 @@ interface Props { level: number; formItemName: string; isRequired: boolean; + initialValues?: any; + uniqueFieldName?: string[]; + isModuleEdit?: boolean; } -export const MapField = ({ +const FlatMapField = ({ field, fieldName, level, @@ -110,3 +128,149 @@ export const MapField = ({ ); }; + +const StructuredMapField = ({ + field, + fieldName, + level, + formItemName, + isRequired, + initialValues, + uniqueFieldName, + isModuleEdit, +}: Props) => { + const { themePalette } = useTemplateFormFields(); + const [open, setOpen] = useState(false); + + const ap = field.additionalProperties; + + let header = {field.display_name}; + if (field.description && field.description.length !== 0) { + header = ( + + + {field.display_name} + + + + + + + + ); + } + + return ( + + 0); + }} + > + + + + {(arrFields, { add, remove }) => ( + <> + {arrFields.map((arrField) => ( + +
+ + + + {mapFields( + isModuleEdit || false, + ap.properties, + initialValues, + [...(uniqueFieldName || []), String("")], + "", + level + 1, + 2, + arrField, + ap.required, + )} + remove(arrField.name)} + /> +
+ + ))} + + + + + )} +
+
+
+
+ + ); +}; + +export const MapField = (props: Props) => { + const hasStructuredValues = + props.field.additionalProperties?.properties?.length > 0; + + if (hasStructuredValues) { + return ; + } + return ; +}; diff --git a/cyclops-ui/src/utils/form.tsx b/cyclops-ui/src/utils/form.tsx index b3000a38d..5ca636c07 100644 --- a/cyclops-ui/src/utils/form.tsx +++ b/cyclops-ui/src/utils/form.tsx @@ -137,11 +137,29 @@ export function findMaps(fields: any[], values: any, initialValues: any): any { break; } - let object: any = {}; - valuesList.forEach((valueFromList) => { - object[valueFromList.key] = YAML.parse(String(valueFromList.value)); - }); - out[field.name] = object; + if ( + field.additionalProperties && + field.additionalProperties.properties + ) { + let structuredObject: any = {}; + valuesList.forEach((valueFromList) => { + const { key, ...rest } = valueFromList; + structuredObject[key] = findMaps( + field.additionalProperties.properties, + rest, + {}, + ); + }); + out[field.name] = structuredObject; + } else { + let object: any = {}; + valuesList.forEach((valueFromList) => { + object[valueFromList.key] = YAML.parse( + String(valueFromList.value), + ); + }); + out[field.name] = object; + } break; } }); @@ -215,27 +233,41 @@ export const mapsToArray = (fields: any[], values: any): any => { break; } - Object.keys(values[field.name]).forEach((key) => { - if (values[field.name][key] === null) { - object.push({ - key: key, - }); - return; - } + if ( + field.additionalProperties && + field.additionalProperties.properties + ) { + Object.keys(values[field.name]).forEach((key) => { + let entry = mapsToArray( + field.additionalProperties.properties, + values[field.name][key] || {}, + ); + entry.key = key; + object.push(entry); + }); + } else { + Object.keys(values[field.name]).forEach((key) => { + if (values[field.name][key] === null) { + object.push({ + key: key, + }); + return; + } + + if (typeof values[field.name][key] === "object") { + object.push({ + key: key, + value: YAML.stringify(values[field.name][key], null, 4), + }); + return; + } - if (typeof values[field.name][key] === "object") { object.push({ key: key, - value: YAML.stringify(values[field.name][key], null, 4), + value: values[field.name][key], }); - return; - } - - object.push({ - key: key, - value: values[field.name][key], }); - }); + } object.sort((a, b) => a.key.localeCompare(b.key));