diff --git a/go.mod b/go.mod index 3e99a4739..2a22164f6 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,7 @@ require ( github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 diff --git a/go.sum b/go.sum index 2a0d77e07..e0a38f6bb 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= @@ -236,6 +238,8 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= diff --git a/plugins/cmd/ndpgen/go.mod b/plugins/cmd/ndpgen/go.mod index 23a40564f..42e6067d0 100644 --- a/plugins/cmd/ndpgen/go.mod +++ b/plugins/cmd/ndpgen/go.mod @@ -6,7 +6,7 @@ require ( github.com/extism/go-pdk v1.1.3 github.com/onsi/ginkgo/v2 v2.27.3 github.com/onsi/gomega v1.38.3 - github.com/xeipuuv/gojsonschema v1.2.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 golang.org/x/tools v0.40.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -17,8 +17,6 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect diff --git a/plugins/cmd/ndpgen/go.sum b/plugins/cmd/ndpgen/go.sum index d95fa78b8..c74b0683f 100644 --- a/plugins/cmd/ndpgen/go.sum +++ b/plugins/cmd/ndpgen/go.sum @@ -1,8 +1,9 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -39,8 +40,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -51,12 +52,6 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_validate.go b/plugins/cmd/ndpgen/internal/xtp_schema_validate.go index ac0e69b53..94404fb79 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema_validate.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema_validate.go @@ -3,10 +3,11 @@ package internal import ( _ "embed" "encoding/json" + "errors" "fmt" "strings" - "github.com/xeipuuv/gojsonschema" + "github.com/santhosh-tekuri/jsonschema/v6" "gopkg.in/yaml.v3" ) @@ -25,27 +26,61 @@ func ValidateXTPSchema(generatedSchema []byte) error { return fmt.Errorf("failed to parse generated schema as YAML: %w", err) } - // Convert to JSON for the validator - jsonBytes, err := json.Marshal(schemaDoc) - if err != nil { - return fmt.Errorf("failed to convert schema to JSON: %w", err) + // Parse the XTP schema JSON + var xtpSchema any + if err := json.Unmarshal([]byte(xtpSchemaJSON), &xtpSchema); err != nil { + return fmt.Errorf("failed to parse XTP schema: %w", err) } - schemaLoader := gojsonschema.NewStringLoader(xtpSchemaJSON) - documentLoader := gojsonschema.NewBytesLoader(jsonBytes) - - result, err := gojsonschema.Validate(schemaLoader, documentLoader) - if err != nil { - return fmt.Errorf("schema validation failed: %w", err) + // Compile the XTP schema + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("xtp-schema.json", xtpSchema); err != nil { + return fmt.Errorf("failed to add XTP schema resource: %w", err) } - if !result.Valid() { - var errs []string - for _, desc := range result.Errors() { - errs = append(errs, fmt.Sprintf("- %s", desc)) - } - return fmt.Errorf("schema validation errors:\n%s", strings.Join(errs, "\n")) + schema, err := compiler.Compile("xtp-schema.json") + if err != nil { + return fmt.Errorf("failed to compile XTP schema: %w", err) + } + + // Validate the generated schema against XTP schema + if err := schema.Validate(schemaDoc); err != nil { + return fmt.Errorf("schema validation errors:\n%s", formatValidationErrors(err)) } return nil } + +// formatValidationErrors formats jsonschema validation errors into readable strings. +func formatValidationErrors(err error) string { + var validationErr *jsonschema.ValidationError + if !errors.As(err, &validationErr) { + return fmt.Sprintf("- %s", err.Error()) + } + + var errs []string + collectValidationErrors(validationErr, &errs) + + if len(errs) == 0 { + return fmt.Sprintf("- %s", validationErr.Error()) + } + return strings.Join(errs, "\n") +} + +// collectValidationErrors recursively collects leaf validation errors. +func collectValidationErrors(err *jsonschema.ValidationError, errs *[]string) { + if len(err.Causes) > 0 { + for _, cause := range err.Causes { + collectValidationErrors(cause, errs) + } + return + } + + // Leaf error - format with location if available + msg := err.Error() + if len(err.InstanceLocation) > 0 { + location := strings.Join(err.InstanceLocation, "/") + msg = fmt.Sprintf("%s: %s", location, msg) + } + *errs = append(*errs, fmt.Sprintf("- %s", msg)) +} diff --git a/plugins/config_validation.go b/plugins/config_validation.go new file mode 100644 index 000000000..d75f9ee7c --- /dev/null +++ b/plugins/config_validation.go @@ -0,0 +1,129 @@ +package plugins + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// ConfigValidationError represents a validation error with field path and message. +type ConfigValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +// ConfigValidationErrors is a collection of validation errors. +type ConfigValidationErrors struct { + Errors []ConfigValidationError `json:"errors"` +} + +func (e *ConfigValidationErrors) Error() string { + if len(e.Errors) == 0 { + return "validation failed" + } + var msgs []string + for _, err := range e.Errors { + if err.Field != "" { + msgs = append(msgs, fmt.Sprintf("%s: %s", err.Field, err.Message)) + } else { + msgs = append(msgs, err.Message) + } + } + return strings.Join(msgs, "; ") +} + +// ValidateConfig validates a config JSON string against a plugin's config schema. +// If the manifest has no config schema, it returns an error indicating the plugin +// has no configurable options. +// Returns nil if validation passes, ConfigValidationErrors if validation fails. +func ValidateConfig(manifest *Manifest, configJSON string) error { + // If no config schema defined, plugin has no configurable options + if !manifest.HasConfigSchema() { + return fmt.Errorf("plugin has no configurable options") + } + + // Parse the config JSON (empty string treated as empty object) + var configData any + if configJSON == "" { + configData = map[string]any{} + } else { + if err := json.Unmarshal([]byte(configJSON), &configData); err != nil { + return &ConfigValidationErrors{ + Errors: []ConfigValidationError{{ + Message: fmt.Sprintf("invalid JSON: %v", err), + }}, + } + } + } + + // Compile the schema + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("schema.json", manifest.Config.Schema); err != nil { + return fmt.Errorf("adding schema resource: %w", err) + } + + schema, err := compiler.Compile("schema.json") + if err != nil { + return fmt.Errorf("compiling schema: %w", err) + } + + // Validate config against schema + if err := schema.Validate(configData); err != nil { + return convertValidationError(err) + } + + return nil +} + +// convertValidationError converts jsonschema validation errors to our format. +func convertValidationError(err error) *ConfigValidationErrors { + var validationErr *jsonschema.ValidationError + if !errors.As(err, &validationErr) { + return &ConfigValidationErrors{ + Errors: []ConfigValidationError{{ + Message: err.Error(), + }}, + } + } + + var configErrors []ConfigValidationError + collectErrors(validationErr, &configErrors) + + if len(configErrors) == 0 { + configErrors = append(configErrors, ConfigValidationError{ + Message: validationErr.Error(), + }) + } + + return &ConfigValidationErrors{Errors: configErrors} +} + +// collectErrors recursively collects validation errors from the error tree. +func collectErrors(err *jsonschema.ValidationError, errors *[]ConfigValidationError) { + // If there are child errors, collect from them + if len(err.Causes) > 0 { + for _, cause := range err.Causes { + collectErrors(cause, errors) + } + return + } + + // Leaf error - add it + field := "" + if len(err.InstanceLocation) > 0 { + field = strings.Join(err.InstanceLocation, "/") + } + + *errors = append(*errors, ConfigValidationError{ + Field: field, + Message: err.Error(), + }) +} + +// HasConfigSchema returns true if the manifest defines a config schema. +func (m *Manifest) HasConfigSchema() bool { + return m.Config != nil && m.Config.Schema != nil +} diff --git a/plugins/config_validation_test.go b/plugins/config_validation_test.go new file mode 100644 index 000000000..20e1ce29b --- /dev/null +++ b/plugins/config_validation_test.go @@ -0,0 +1,186 @@ +//go:build !windows + +package plugins + +import ( + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Config Validation", func() { + Describe("ValidateConfig", func() { + Context("when manifest has no config schema", func() { + It("returns an error", func() { + manifest := &Manifest{ + Name: "test", + Author: "test", + Version: "1.0.0", + } + err := ValidateConfig(manifest, `{"key": "value"}`) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no configurable options")) + }) + }) + + Context("when manifest has config schema", func() { + var manifest *Manifest + + BeforeEach(func() { + manifest = &Manifest{ + Name: "test", + Author: "test", + Version: "1.0.0", + Config: &ConfigDefinition{ + Schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "apiKey": map[string]any{ + "type": "string", + "description": "API key for the service", + "minLength": float64(1), + }, + "timeout": map[string]any{ + "type": "integer", + "minimum": float64(1), + "maximum": float64(300), + }, + "enabled": map[string]any{ + "type": "boolean", + }, + }, + "required": []any{"apiKey"}, + }, + }, + } + }) + + It("accepts valid config", func() { + err := ValidateConfig(manifest, `{"apiKey": "secret123", "timeout": 30}`) + Expect(err).ToNot(HaveOccurred()) + }) + + It("rejects empty config when required fields are missing", func() { + err := ValidateConfig(manifest, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("apiKey")) + + err = ValidateConfig(manifest, "{}") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("apiKey")) + }) + + It("rejects config missing required field", func() { + err := ValidateConfig(manifest, `{"timeout": 30}`) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("apiKey")) + }) + + It("rejects config with wrong type", func() { + err := ValidateConfig(manifest, `{"apiKey": "secret", "timeout": "not a number"}`) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timeout")) + }) + + It("rejects config with value out of range", func() { + err := ValidateConfig(manifest, `{"apiKey": "secret", "timeout": 500}`) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timeout")) + }) + + It("rejects config with empty required string", func() { + err := ValidateConfig(manifest, `{"apiKey": ""}`) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("apiKey")) + }) + + It("rejects invalid JSON", func() { + err := ValidateConfig(manifest, `{invalid json}`) + Expect(err).To(HaveOccurred()) + var validationErr *ConfigValidationErrors + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors[0].Message).To(ContainSubstring("invalid JSON")) + }) + }) + + Context("with enum values", func() { + It("accepts valid enum value", func() { + manifest := &Manifest{ + Name: "test", + Author: "test", + Version: "1.0.0", + Config: &ConfigDefinition{ + Schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "logLevel": map[string]any{ + "type": "string", + "enum": []any{"debug", "info", "warn", "error"}, + }, + }, + }, + }, + } + err := ValidateConfig(manifest, `{"logLevel": "info"}`) + Expect(err).ToNot(HaveOccurred()) + }) + + It("rejects invalid enum value", func() { + manifest := &Manifest{ + Name: "test", + Author: "test", + Version: "1.0.0", + Config: &ConfigDefinition{ + Schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "logLevel": map[string]any{ + "type": "string", + "enum": []any{"debug", "info", "warn", "error"}, + }, + }, + }, + }, + } + err := ValidateConfig(manifest, `{"logLevel": "verbose"}`) + Expect(err).To(HaveOccurred()) + }) + }) + }) + + Describe("HasConfigSchema", func() { + It("returns false when config is nil", func() { + manifest := &Manifest{ + Name: "test", + Author: "test", + Version: "1.0.0", + } + Expect(manifest.HasConfigSchema()).To(BeFalse()) + }) + + It("returns false when schema is nil", func() { + manifest := &Manifest{ + Name: "test", + Author: "test", + Version: "1.0.0", + Config: &ConfigDefinition{}, + } + Expect(manifest.HasConfigSchema()).To(BeFalse()) + }) + + It("returns true when schema is present", func() { + manifest := &Manifest{ + Name: "test", + Author: "test", + Version: "1.0.0", + Config: &ConfigDefinition{ + Schema: map[string]any{ + "type": "object", + }, + }, + } + Expect(manifest.HasConfigSchema()).To(BeTrue()) + }) + }) +}) diff --git a/plugins/examples/crypto-ticker/main.go b/plugins/examples/crypto-ticker/main.go index c2ef313bb..8fc189128 100755 --- a/plugins/examples/crypto-ticker/main.go +++ b/plugins/examples/crypto-ticker/main.go @@ -25,6 +25,14 @@ const ( // ID for the reconnection schedule reconnectScheduleID = "crypto-ticker-reconnect" + + // Config keys (must match manifest.json schema property names) + symbolsKey = "symbols" + reconnectDelayKey = "reconnectDelay" + logPricesKey = "logPrices" + + // Default values + defaultReconnectDelay = 5 ) // CoinbaseSubscription message structure @@ -74,36 +82,67 @@ var ( func (p *cryptoTickerPlugin) OnInit() error { pdk.Log(pdk.LogInfo, "Crypto Ticker Plugin initializing...") - // Get ticker configuration - tickerConfig, ok := pdk.GetConfig("tickers") - if !ok || tickerConfig == "" { - tickerConfig = "BTC,ETH" // Default tickers - } - - tickers := parseTickerSymbols(tickerConfig) - pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured tickers: %v", tickers)) + // Get ticker configuration from JSON schema config + symbols := getSymbols() + pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured symbols: %v", symbols)) // Connect to WebSocket // Errors won't fail init - reconnect logic will handle it - return connectAndSubscribe(tickers) + return connectAndSubscribe(symbols) } -// parseTickerSymbols parses a comma-separated list of ticker symbols -func parseTickerSymbols(tickerConfig string) []string { - parts := strings.Split(tickerConfig, ",") - tickers := make([]string, 0, len(parts)) - for _, ticker := range parts { - ticker = strings.TrimSpace(ticker) - if ticker == "" { - continue - } - // Add -USD suffix if not present - if !strings.Contains(ticker, "-") { - ticker = ticker + "-USD" - } - tickers = append(tickers, ticker) +// getSymbols reads the symbols array from config +func getSymbols() []string { + defaultSymbols := []string{"BTC-USD"} + symbolsJSON, ok := pdk.GetConfig(symbolsKey) + if !ok || symbolsJSON == "" { + return defaultSymbols } - return tickers + + var symbols []string + if err := json.Unmarshal([]byte(symbolsJSON), &symbols); err != nil { + pdk.Log(pdk.LogWarn, fmt.Sprintf("failed to parse symbols config: %v, using defaults", err)) + return defaultSymbols + } + + if len(symbols) == 0 { + return defaultSymbols + } + + // Normalize symbols - add -USD suffix if not present + for i, s := range symbols { + s = strings.TrimSpace(s) + if !strings.Contains(s, "-") { + symbols[i] = s + "-USD" + } else { + symbols[i] = s + } + } + + return symbols +} + +// getReconnectDelay reads the reconnect delay from config +func getReconnectDelay() int32 { + delayStr, ok := pdk.GetConfig(reconnectDelayKey) + if !ok || delayStr == "" { + return defaultReconnectDelay + } + + var delay int + if _, err := fmt.Sscanf(delayStr, "%d", &delay); err != nil || delay < 1 { + return defaultReconnectDelay + } + return int32(delay) +} + +// shouldLogPrices reads the logPrices setting from config +func shouldLogPrices() bool { + logStr, ok := pdk.GetConfig(logPricesKey) + if !ok || logStr == "" { + return false + } + return logStr == "true" } // connectAndSubscribe connects to Coinbase WebSocket and subscribes to tickers @@ -164,14 +203,16 @@ func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest) // Calculate 24h change percentage change := calculatePercentChange(ticker.Open24h, ticker.Price) - // Log ticker information - pdk.Log(pdk.LogInfo, fmt.Sprintf("šŸ’° %s: $%s (24h: %s%%) Bid: $%s Ask: $%s", - ticker.ProductID, - ticker.Price, - change, - ticker.BestBid, - ticker.BestAsk, - )) + // Log ticker information (only if enabled in config) + if shouldLogPrices() { + pdk.Log(pdk.LogInfo, fmt.Sprintf("šŸ’° %s: $%s (24h: %s%%) Bid: $%s Ask: $%s", + ticker.ProductID, + ticker.Price, + change, + ticker.BestBid, + ticker.BestAsk, + )) + } return nil } @@ -196,10 +237,11 @@ func (p *cryptoTickerPlugin) OnClose(input websocket.OnCloseRequest) error { // Only attempt reconnect for our connection if input.ConnectionID == connectionID { - pdk.Log(pdk.LogInfo, "Scheduling reconnection attempt in 5 seconds...") + delay := getReconnectDelay() + pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduling reconnection attempt in %d seconds...", delay)) // Schedule a one-time reconnection attempt - _, err := host.SchedulerScheduleOneTime(5, "reconnect", reconnectScheduleID) + _, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID) if err != nil { pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule reconnection: %v", err)) } @@ -218,20 +260,16 @@ func (p *cryptoTickerPlugin) OnCallback(input scheduler.SchedulerCallbackRequest pdk.Log(pdk.LogInfo, "Attempting to reconnect to Coinbase WebSocket API...") // Get ticker configuration - tickerConfig, ok := pdk.GetConfig("tickers") - if !ok || tickerConfig == "" { - tickerConfig = "BTC,ETH" - } - - tickers := parseTickerSymbols(tickerConfig) + symbols := getSymbols() // Try to connect and subscribe - err := connectAndSubscribe(tickers) + err := connectAndSubscribe(symbols) if err != nil { - pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in 10 seconds", err)) + delay := getReconnectDelay() * 2 // Double delay on failure + pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in %d seconds", err, delay)) // Schedule another attempt - _, err := host.SchedulerScheduleOneTime(10, "reconnect", reconnectScheduleID) + _, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID) if err != nil { pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule retry: %v", err)) } diff --git a/plugins/examples/crypto-ticker/manifest.json b/plugins/examples/crypto-ticker/manifest.json index 6fd6ff514..59d00cbaa 100644 --- a/plugins/examples/crypto-ticker/manifest.json +++ b/plugins/examples/crypto-ticker/manifest.json @@ -4,6 +4,61 @@ "version": "1.0.0", "description": "Real-time cryptocurrency price ticker using Coinbase WebSocket API", "website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker", + "config": { + "schema": { + "type": "object", + "properties": { + "symbols": { + "type": "array", + "title": "Trading Pairs", + "description": "Cryptocurrency trading pairs to track (default: BTC-USD)", + "items": { + "type": "string", + "title": "Trading Pair", + "pattern": "^[A-Z]{3,5}-[A-Z]{3,5}$", + "description": "Trading pair in the format BASE-QUOTE (e.g., BTC-USD, ETH-USD)" + }, + "default": ["BTC-USD"] + }, + "reconnectDelay": { + "type": "integer", + "title": "Reconnect Delay", + "description": "Delay in seconds before attempting to reconnect after connection loss", + "default": 5, + "minimum": 1, + "maximum": 60 + }, + "logPrices": { + "type": "boolean", + "title": "Log Prices", + "description": "Whether to log price updates to the server log", + "default": false + } + } + }, + "uiSchema": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/symbols" + }, + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/reconnectDelay" + }, + { + "type": "Control", + "scope": "#/properties/logPrices" + } + ] + } + ] + } + }, "permissions": { "config": { "reason": "To read ticker symbols configuration" diff --git a/plugins/examples/discord-rich-presence-rs/manifest.json b/plugins/examples/discord-rich-presence-rs/manifest.json index f5625815f..d92069762 100644 --- a/plugins/examples/discord-rich-presence-rs/manifest.json +++ b/plugins/examples/discord-rich-presence-rs/manifest.json @@ -25,5 +25,74 @@ "artwork": { "reason": "To get track artwork URLs for rich presence display" } + }, + "config": { + "schema": { + "type": "object", + "properties": { + "clientid": { + "type": "string", + "title": "Discord Application Client ID", + "description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications", + "minLength": 17, + "maxLength": 20, + "pattern": "^[0-9]+$" + }, + "users": { + "type": "array", + "title": "User Tokens", + "description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!", + "default": [{}], + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "title": "Navidrome Username", + "description": "The Navidrome username to associate with this Discord token", + "minLength": 1 + }, + "token": { + "type": "string", + "title": "Discord Token", + "description": "The user's Discord token (keep this secret!)", + "minLength": 1 + } + }, + "required": ["username", "token"] + } + } + }, + "required": ["clientid"] + }, + "uiSchema": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/clientid" + }, + { + "type": "Control", + "scope": "#/properties/users", + "options": { + "elementLabelProp": "username", + "detail": { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/username" + }, + { + "type": "Control", + "scope": "#/properties/token" + } + ] + } + } + } + ] + } } } diff --git a/plugins/examples/discord-rich-presence-rs/src/lib.rs b/plugins/examples/discord-rich-presence-rs/src/lib.rs index 8bbcb8fb5..12bf9ed3e 100644 --- a/plugins/examples/discord-rich-presence-rs/src/lib.rs +++ b/plugins/examples/discord-rich-presence-rs/src/lib.rs @@ -8,12 +8,9 @@ //! //! ## Configuration //! -//! ```toml -//! [PluginConfig.discord-rich-presence-rs] -//! clientid = "YOUR_DISCORD_APPLICATION_ID" -//! "user.username1" = "discord_token1" -//! "user.username2" = "discord_token2" -//! ``` +//! Configure this plugin through the Navidrome UI with: +//! - Discord Application Client ID +//! - User tokens array mapping Navidrome usernames to Discord tokens //! //! **WARNING**: This plugin is for demonstration purposes only. Storing Discord tokens //! in configuration files is not secure and may violate Discord's terms of service. @@ -32,6 +29,7 @@ use nd_pdk::websocket::{ OnBinaryMessageRequest, OnCloseRequest, OnErrorRequest, OnTextMessageRequest, TextMessageProvider, }; +use serde::Deserialize; mod rpc; @@ -48,7 +46,7 @@ nd_pdk::register_websocket_close!(DiscordPlugin); // ============================================================================ const CLIENT_ID_KEY: &str = "clientid"; -const USER_KEY_PREFIX: &str = "user."; +const USERS_KEY: &str = "users"; const PAYLOAD_HEARTBEAT: &str = "heartbeat"; const PAYLOAD_CLEAR_ACTIVITY: &str = "clear-activity"; @@ -64,19 +62,31 @@ struct DiscordPlugin; // Configuration // ============================================================================ +/// User token entry from the config schema +#[derive(Debug, Deserialize)] +struct UserToken { + username: String, + token: String, +} + fn get_config() -> Result<(String, std::collections::HashMap), Error> { let client_id = config::get(CLIENT_ID_KEY)? .filter(|s| !s.is_empty()) .ok_or_else(|| Error::msg("missing clientid in configuration"))?; - // Get all user keys with the "user." prefix - let user_keys = config::keys(USER_KEY_PREFIX)?; - + // Get users array from config (JSON format) + let users_json = config::get(USERS_KEY)?.unwrap_or_default(); + let mut users = std::collections::HashMap::new(); - for key in user_keys { - let username = key.strip_prefix(USER_KEY_PREFIX).unwrap_or(&key); - if let Some(token) = config::get(&key)?.filter(|s| !s.is_empty()) { - users.insert(username.to_string(), token); + if !users_json.is_empty() { + // Parse JSON array of user tokens + let user_tokens: Vec = serde_json::from_str(&users_json) + .map_err(|e| Error::msg(format!("failed to parse users config: {}", e)))?; + + for user_token in user_tokens { + if !user_token.username.is_empty() && !user_token.token.is_empty() { + users.insert(user_token.username, user_token.token); + } } } diff --git a/plugins/examples/discord-rich-presence/main.go b/plugins/examples/discord-rich-presence/main.go index bd578ef40..abd628abb 100644 --- a/plugins/examples/discord-rich-presence/main.go +++ b/plugins/examples/discord-rich-presence/main.go @@ -11,6 +11,7 @@ package main import ( + "encoding/json" "fmt" "strings" "time" @@ -24,10 +25,16 @@ import ( // Configuration keys const ( - clientIDKey = "clientid" - userKeyPrefix = "user." + clientIDKey = "clientid" + usersKey = "users" ) +// userToken represents a user-token mapping from the config +type userToken struct { + Username string `json:"username"` + Token string `json:"token"` +} + // discordPlugin implements the scrobbler and scheduler interfaces. type discordPlugin struct{} @@ -49,24 +56,35 @@ func getConfig() (clientID string, users map[string]string, err error) { return "", nil, nil } - // Get all user keys with the "user." prefix - userKeys := host.ConfigKeys(userKeyPrefix) - if len(userKeys) == 0 { + // Get the users array from config + usersJSON, ok := pdk.GetConfig(usersKey) + if !ok || usersJSON == "" { pdk.Log(pdk.LogWarn, "no users configured") return clientID, nil, nil } + // Parse the JSON array + var userTokens []userToken + if err := json.Unmarshal([]byte(usersJSON), &userTokens); err != nil { + pdk.Log(pdk.LogError, fmt.Sprintf("failed to parse users config: %v", err)) + return clientID, nil, nil + } + + if len(userTokens) == 0 { + pdk.Log(pdk.LogWarn, "no users configured") + return clientID, nil, nil + } + + // Build the users map users = make(map[string]string) - for _, key := range userKeys { - username := strings.TrimPrefix(key, userKeyPrefix) - token, exists := host.ConfigGet(key) - if exists && token != "" { - users[username] = token + for _, ut := range userTokens { + if ut.Username != "" && ut.Token != "" { + users[ut.Username] = ut.Token } } if len(users) == 0 { - pdk.Log(pdk.LogWarn, "no users configured") + pdk.Log(pdk.LogWarn, "no valid users configured") return clientID, nil, nil } diff --git a/plugins/examples/discord-rich-presence/manifest.json b/plugins/examples/discord-rich-presence/manifest.json index 006be48e9..403cb917e 100644 --- a/plugins/examples/discord-rich-presence/manifest.json +++ b/plugins/examples/discord-rich-presence/manifest.json @@ -29,5 +29,74 @@ "artwork": { "reason": "To get track artwork URLs for rich presence display" } + }, + "config": { + "schema": { + "type": "object", + "properties": { + "clientid": { + "type": "string", + "title": "Discord Application Client ID", + "description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications", + "minLength": 17, + "maxLength": 20, + "pattern": "^[0-9]+$" + }, + "users": { + "type": "array", + "title": "User Tokens", + "description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!", + "default": [{}], + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "title": "Navidrome Username", + "description": "The Navidrome username to associate with this Discord token", + "minLength": 1 + }, + "token": { + "type": "string", + "title": "Discord Token", + "description": "The user's Discord token (keep this secret!)", + "minLength": 1 + } + }, + "required": ["username", "token"] + } + } + }, + "required": ["clientid"] + }, + "uiSchema": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/clientid" + }, + { + "type": "Control", + "scope": "#/properties/users", + "options": { + "elementLabelProp": "username", + "detail": { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/username" + }, + { + "type": "Control", + "scope": "#/properties/token" + } + ] + } + } + } + ] + } } } diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go index b49c5c7dc..5ad5af198 100644 --- a/plugins/host_config_test.go +++ b/plugins/host_config_test.go @@ -20,6 +20,105 @@ import ( . "github.com/onsi/gomega" ) +// testConfigInput is the input for nd_test_config callback. +type testConfigInput struct { + Operation string `json:"operation"` + Key string `json:"key,omitempty"` + Prefix string `json:"prefix,omitempty"` +} + +// testConfigOutput is the output from nd_test_config callback. +type testConfigOutput struct { + StringVal string `json:"string_val,omitempty"` + IntVal int64 `json:"int_val,omitempty"` + Keys []string `json:"keys,omitempty"` + Exists bool `json:"exists,omitempty"` + Error *string `json:"error,omitempty"` +} + +// setupTestConfigPlugin sets up a test environment with the test-config plugin loaded. +// Returns a cleanup function and a helper to call the plugin's nd_test_config function. +func setupTestConfigPlugin(configJSON string) (*Manager, func(context.Context, testConfigInput) (*testConfigOutput, error)) { + tmpDir, err := os.MkdirTemp("", "config-test-*") + Expect(err).ToNot(HaveOccurred()) + + // Copy the test-config plugin + srcPath := filepath.Join(testdataDir, "test-config"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-config"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Compute SHA256 for the plugin + hash := sha256.Sum256(data) + hashHex := hex.EncodeToString(hash[:]) + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.AutoReload = false + conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") + + // Setup mock DataStore + mockPluginRepo := tests.CreateMockPluginRepo() + mockPluginRepo.Permitted = true + mockPluginRepo.SetData(model.Plugins{{ + ID: "test-config", + Path: destPath, + SHA256: hashHex, + Enabled: true, + AllUsers: true, + Config: configJSON, + }}) + dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} + + // Create and start manager + manager := &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + subsonicRouter: http.NotFoundHandler(), + } + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + + // Helper to call test plugin's exported function + callTestConfig := func(ctx context.Context, input testConfigInput) (*testConfigOutput, error) { + manager.mu.RLock() + p := manager.plugins["test-config"] + manager.mu.RUnlock() + + instance, err := p.instance(ctx) + if err != nil { + return nil, err + } + defer instance.Close(ctx) + + inputBytes, _ := json.Marshal(input) + _, outputBytes, err := instance.Call("nd_test_config", inputBytes) + if err != nil { + return nil, err + } + + var output testConfigOutput + if err := json.Unmarshal(outputBytes, &output); err != nil { + return nil, err + } + if output.Error != nil { + return nil, errors.New(*output.Error) + } + return &output, nil + } + + return manager, callTestConfig +} + var _ = Describe("ConfigService", func() { var service *configServiceImpl var ctx context.Context @@ -144,59 +243,12 @@ var _ = Describe("ConfigService", func() { var _ = Describe("ConfigService Integration", Ordered, func() { var ( - manager *Manager - tmpDir string + manager *Manager + callTestConfig func(context.Context, testConfigInput) (*testConfigOutput, error) ) BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "config-test-*") - Expect(err).ToNot(HaveOccurred()) - - // Copy the test-config plugin - srcPath := filepath.Join(testdataDir, "test-config"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-config"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Compute SHA256 for the plugin - hash := sha256.Sum256(data) - hashHex := hex.EncodeToString(hash[:]) - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir - conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") - - // Setup mock DataStore with pre-enabled plugin and config - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - mockPluginRepo.SetData(model.Plugins{{ - ID: "test-config", - Path: destPath, - SHA256: hashHex, - Enabled: true, - Config: `{"api_key":"test_secret","max_retries":"5","timeout":"30"}`, - }}) - dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} - - // Create and start manager - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - subsonicRouter: http.NotFoundHandler(), - } - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) + manager, callTestConfig = setupTestConfigPlugin(`{"api_key":"test_secret","max_retries":"5","timeout":"30"}`) }) Describe("Plugin Loading", func() { @@ -205,54 +257,11 @@ var _ = Describe("ConfigService Integration", Ordered, func() { p, ok := manager.plugins["test-config"] manager.mu.RUnlock() Expect(ok).To(BeTrue()) - // Config service doesn't require permission, so Permissions can be nil - // Just verify the plugin loaded Expect(p.manifest.Name).To(Equal("Test Config Plugin")) }) }) Describe("Config Operations via Plugin", func() { - type testConfigInput struct { - Operation string `json:"operation"` - Key string `json:"key,omitempty"` - Prefix string `json:"prefix,omitempty"` - } - type testConfigOutput struct { - StringVal string `json:"string_val,omitempty"` - IntVal int64 `json:"int_val,omitempty"` - Keys []string `json:"keys,omitempty"` - Exists bool `json:"exists,omitempty"` - Error *string `json:"error,omitempty"` - } - - // Helper to call test plugin's exported function - callTestConfig := func(ctx context.Context, input testConfigInput) (*testConfigOutput, error) { - manager.mu.RLock() - p := manager.plugins["test-config"] - manager.mu.RUnlock() - - instance, err := p.instance(ctx) - if err != nil { - return nil, err - } - defer instance.Close(ctx) - - inputBytes, _ := json.Marshal(input) - _, outputBytes, err := instance.Call("nd_test_config", inputBytes) - if err != nil { - return nil, err - } - - var output testConfigOutput - if err := json.Unmarshal(outputBytes, &output); err != nil { - return nil, err - } - if output.Error != nil { - return nil, errors.New(*output.Error) - } - return &output, nil - } - It("should get string value", func() { output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ Operation: "get", @@ -285,7 +294,7 @@ var _ = Describe("ConfigService Integration", Ordered, func() { It("should return not exists for non-integer value", func() { output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ Operation: "get_int", - Key: "api_key", // This is a string, not an integer + Key: "api_key", }) Expect(err).ToNot(HaveOccurred()) Expect(output.Exists).To(BeFalse()) @@ -310,3 +319,64 @@ var _ = Describe("ConfigService Integration", Ordered, func() { }) }) }) + +var _ = Describe("Complex Config Values Integration", Ordered, func() { + var callTestConfig func(context.Context, testConfigInput) (*testConfigOutput, error) + + BeforeAll(func() { + // Config with arrays and objects - these should be properly serialized as JSON strings + _, callTestConfig = setupTestConfigPlugin(`{"api_key":"secret123","users":[{"username":"admin","token":"tok1"},{"username":"user2","token":"tok2"}],"settings":{"enabled":true,"count":5}}`) + }) + + Describe("Config Serialization", func() { + It("should make simple string config values accessible to plugin", func() { + output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ + Operation: "get", + Key: "api_key", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeTrue()) + Expect(output.StringVal).To(Equal("secret123")) + }) + + It("should serialize array config values as JSON strings", func() { + output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ + Operation: "get", + Key: "users", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeTrue()) + // Array values are serialized as JSON strings - parse to verify structure + var users []map[string]string + Expect(json.Unmarshal([]byte(output.StringVal), &users)).To(Succeed()) + Expect(users).To(HaveLen(2)) + Expect(users[0]).To(HaveKeyWithValue("username", "admin")) + Expect(users[0]).To(HaveKeyWithValue("token", "tok1")) + Expect(users[1]).To(HaveKeyWithValue("username", "user2")) + Expect(users[1]).To(HaveKeyWithValue("token", "tok2")) + }) + + It("should serialize object config values as JSON strings", func() { + output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ + Operation: "get", + Key: "settings", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeTrue()) + // Object values are serialized as JSON strings - parse to verify structure + var settings map[string]any + Expect(json.Unmarshal([]byte(output.StringVal), &settings)).To(Succeed()) + Expect(settings).To(HaveKeyWithValue("enabled", true)) + Expect(settings).To(HaveKeyWithValue("count", float64(5))) + }) + + It("should list all config keys including complex values", func() { + output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ + Operation: "list", + Prefix: "", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Keys).To(ConsistOf("api_key", "users", "settings")) + }) + }) +}) diff --git a/plugins/manager.go b/plugins/manager.go index 464c50e4e..d8d4f28ef 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -381,6 +381,30 @@ func (m *Manager) DisablePlugin(ctx context.Context, id string) error { return nil } +// ValidatePluginConfig validates a config JSON string against the plugin's config schema. +// If the plugin has no config schema defined, it returns an error. +// Returns nil if validation passes, or an error describing the validation failure. +func (m *Manager) ValidatePluginConfig(ctx context.Context, id, configJSON string) error { + if m.ds == nil { + return fmt.Errorf("datastore not configured") + } + + adminCtx := adminContext(ctx) + repo := m.ds.Plugin(adminCtx) + + plugin, err := repo.Get(id) + if err != nil { + return fmt.Errorf("getting plugin from DB: %w", err) + } + + manifest, err := readManifest(plugin.Path) + if err != nil { + return fmt.Errorf("reading manifest: %w", err) + } + + return ValidateConfig(manifest, configJSON) +} + // UpdatePluginConfig updates the configuration for a plugin. // If the plugin is enabled, it will be reloaded with the new config. func (m *Manager) UpdatePluginConfig(ctx context.Context, id, configJSON string) error { diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 21c974982..cc4d27167 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -230,11 +230,9 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { } // Parse config from JSON - var pluginConfig map[string]string - if p.Config != "" { - if err := json.Unmarshal([]byte(p.Config), &pluginConfig); err != nil { - return fmt.Errorf("parsing plugin config: %w", err) - } + pluginConfig, err := parsePluginConfig(p.Config) + if err != nil { + return err } // Parse users from JSON @@ -379,3 +377,30 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return nil } + +// parsePluginConfig parses a JSON config string into a map of string values. +// For Extism, all config values must be strings, so non-string values are serialized as JSON. +func parsePluginConfig(configJSON string) (map[string]string, error) { + if configJSON == "" { + return nil, nil + } + var rawConfig map[string]any + if err := json.Unmarshal([]byte(configJSON), &rawConfig); err != nil { + return nil, fmt.Errorf("parsing plugin config: %w", err) + } + pluginConfig := make(map[string]string) + for key, value := range rawConfig { + switch v := value.(type) { + case string: + pluginConfig[key] = v + default: + // Serialize non-string values as JSON + jsonBytes, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("serializing config value %q: %w", key, err) + } + pluginConfig[key] = string(jsonBytes) + } + } + return pluginConfig, nil +} diff --git a/plugins/manager_loader_test.go b/plugins/manager_loader_test.go new file mode 100644 index 000000000..64bc5e810 --- /dev/null +++ b/plugins/manager_loader_test.go @@ -0,0 +1,60 @@ +//go:build !windows + +package plugins + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parsePluginConfig", func() { + It("returns nil for empty string", func() { + result, err := parsePluginConfig("") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + + It("serializes object values as JSON strings", func() { + result, err := parsePluginConfig(`{"settings": {"enabled": true, "count": 5}}`) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result["settings"]).To(Equal(`{"count":5,"enabled":true}`)) + }) + + It("handles mixed value types", func() { + result, err := parsePluginConfig(`{"api_key": "secret", "timeout": 30, "rate": 1.5, "enabled": true, "tags": ["a", "b"]}`) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(5)) + Expect(result["api_key"]).To(Equal("secret")) + Expect(result["timeout"]).To(Equal("30")) + Expect(result["rate"]).To(Equal("1.5")) + Expect(result["enabled"]).To(Equal("true")) + Expect(result["tags"]).To(Equal(`["a","b"]`)) + }) + + It("returns error for invalid JSON", func() { + _, err := parsePluginConfig(`{invalid json}`) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("parsing plugin config")) + }) + + It("returns error for non-object JSON", func() { + _, err := parsePluginConfig(`["array", "not", "object"]`) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("parsing plugin config")) + }) + + It("handles null values", func() { + result, err := parsePluginConfig(`{"key": null}`) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result["key"]).To(Equal("null")) + }) + + It("handles empty object", func() { + result, err := parsePluginConfig(`{}`) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(0)) + Expect(result).ToNot(BeNil()) + }) +}) diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index ef141ae7b..881592c28 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -36,9 +36,28 @@ }, "experimental": { "$ref": "#/$defs/Experimental" + }, + "config": { + "$ref": "#/$defs/ConfigDefinition" } }, "$defs": { + "ConfigDefinition": { + "type": "object", + "description": "Configuration schema for the plugin using JSON Schema (draft-07) and optional JSONForms UI Schema", + "additionalProperties": false, + "required": ["schema"], + "properties": { + "schema": { + "type": "object", + "description": "JSON Schema (draft-07) defining the plugin's configuration options" + }, + "uiSchema": { + "type": "object", + "description": "Optional JSONForms UI Schema for customizing form layout" + } + } + }, "Experimental": { "type": "object", "description": "Experimental features that may change or be removed in future versions", diff --git a/plugins/manifest.go b/plugins/manifest.go index d2aadae97..f401a7f69 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -3,6 +3,8 @@ package plugins import ( "encoding/json" "fmt" + + "github.com/santhosh-tekuri/jsonschema/v6" ) //go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest-schema.json @@ -29,6 +31,26 @@ func (m *Manifest) Validate() error { return fmt.Errorf("'subsonicapi' permission requires 'users' permission to be declared") } } + + // Validate config schema if present + if m.Config != nil && m.Config.Schema != nil { + if err := validateConfigSchema(m.Config.Schema); err != nil { + return fmt.Errorf("invalid config schema: %w", err) + } + } + + return nil +} + +// validateConfigSchema validates that the schema is a valid JSON Schema that can be compiled. +func validateConfigSchema(schema map[string]any) error { + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("schema.json", schema); err != nil { + return fmt.Errorf("invalid schema structure: %w", err) + } + if _, err := compiler.Compile("schema.json"); err != nil { + return err + } return nil } diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index f17127358..9762babbf 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -17,6 +17,34 @@ type CachePermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` } +// Configuration schema for the plugin using JSON Schema (draft-07) and optional +// JSONForms UI Schema +type ConfigDefinition struct { + // JSON Schema (draft-07) defining the plugin's configuration options + Schema map[string]interface{} `json:"schema" yaml:"schema" mapstructure:"schema"` + + // Optional JSONForms UI Schema for customizing form layout + UiSchema map[string]interface{} `json:"uiSchema,omitempty" yaml:"uiSchema,omitempty" mapstructure:"uiSchema,omitempty"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *ConfigDefinition) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["schema"]; raw != nil && !ok { + return fmt.Errorf("field schema in ConfigDefinition: required") + } + type Plain ConfigDefinition + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + *j = ConfigDefinition(plain) + return nil +} + // Configuration access permissions for a plugin type ConfigPermission struct { // Explanation for why config access is needed @@ -81,6 +109,9 @@ type Manifest struct { // The author of the plugin Author string `json:"author" yaml:"author" mapstructure:"author"` + // Config corresponds to the JSON schema field "config". + Config *ConfigDefinition `json:"config,omitempty" yaml:"config,omitempty" mapstructure:"config,omitempty"` + // A brief description of what the plugin does Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"` diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index 053cf55d4..de15324ab 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -286,6 +286,107 @@ var _ = Describe("Manifest", func() { err := m.Validate() Expect(err).ToNot(HaveOccurred()) }) + + It("validates manifest with valid config schema", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Config: &ConfigDefinition{ + Schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "api_key": map[string]any{ + "type": "string", + }, + }, + }, + }, + } + + err := m.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + + It("validates manifest with complex config schema", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Config: &ConfigDefinition{ + Schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "users": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "username": map[string]any{"type": "string"}, + "token": map[string]any{"type": "string"}, + }, + "required": []any{"username", "token"}, + }, + }, + }, + }, + }, + } + + err := m.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error for invalid config schema - bad type", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Config: &ConfigDefinition{ + Schema: map[string]any{ + "type": "invalid_type", + }, + }, + } + + err := m.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("config schema")) + }) + + It("returns error for invalid config schema - bad minLength", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Config: &ConfigDefinition{ + Schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "minLength": "not_a_number", + }, + }, + }, + }, + } + + err := m.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("config schema")) + }) + + It("validates manifest without config", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + } + + err := m.Validate() + Expect(err).ToNot(HaveOccurred()) + }) }) Describe("ValidateWithCapabilities", func() { diff --git a/plugins/testdata/test-config/manifest.json b/plugins/testdata/test-config/manifest.json index fc606b1fc..b5ed5dd86 100644 --- a/plugins/testdata/test-config/manifest.json +++ b/plugins/testdata/test-config/manifest.json @@ -2,5 +2,60 @@ "name": "Test Config Plugin", "author": "Navidrome Test", "version": "1.0.0", - "description": "A test plugin for config service integration testing" + "description": "A test plugin for config service integration testing", + "config": { + "schema": { + "type": "object", + "properties": { + "api_key": { + "type": "string", + "title": "API Key", + "minLength": 1 + }, + "max_retries": { + "type": "string", + "title": "Max Retries" + }, + "timeout": { + "type": "string", + "title": "Timeout" + }, + "users": { + "type": "array", + "title": "Users", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "title": "Username", + "minLength": 1 + }, + "token": { + "type": "string", + "title": "Token", + "minLength": 1 + } + }, + "required": ["username", "token"] + } + }, + "settings": { + "type": "object", + "title": "Settings", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled" + }, + "count": { + "type": "integer", + "title": "Count" + } + } + } + }, + "required": ["api_key"] + } + } } diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 76280c42d..c9917c7be 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -387,6 +387,8 @@ }, "messages": { "configHelp": "Configure o plugin usando pares chave-valor. Deixe vazio se o plugin nĆ£o precisa de configuração.", + "configValidationError": "Falha na validação da configuração:", + "schemaRenderError": "NĆ£o foi possĆ­vel renderizar o formulĆ”rio de configuração. O schema do plugin pode estar invĆ”lido.", "clickPermissions": "Clique em uma permissĆ£o para ver detalhes", "noConfig": "Nenhuma configuração definida", "allUsersHelp": "Quando habilitado, o plugin terĆ” acesso a todos os usuĆ”rios, incluindo os criados no futuro.", diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index bdea69c70..b91534092 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -25,6 +25,7 @@ import ( type PluginManager interface { EnablePlugin(ctx context.Context, id string) error DisablePlugin(ctx context.Context, id string) error + ValidatePluginConfig(ctx context.Context, id, configJSON string) error UpdatePluginConfig(ctx context.Context, id, configJSON string) error UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error diff --git a/server/nativeapi/plugin.go b/server/nativeapi/plugin.go index d3b81c2f7..e733edc4b 100644 --- a/server/nativeapi/plugin.go +++ b/server/nativeapi/plugin.go @@ -171,13 +171,26 @@ func isValidJSON(s string) bool { return json.Unmarshal([]byte(s), &js) == nil } -// validateAndUpdateConfig validates the config JSON and updates the plugin. +// validateAndUpdateConfig validates the config JSON against the plugin's schema and updates the plugin. // Returns an error if validation or update fails (error response already written). func validateAndUpdateConfig(ctx context.Context, pm PluginManager, id, configJSON string, w http.ResponseWriter) error { + // Basic JSON syntax check if configJSON != "" && !isValidJSON(configJSON) { http.Error(w, "Invalid JSON in config field", http.StatusBadRequest) return errors.New("invalid JSON") } + + // Validate against plugin's config schema + if err := pm.ValidatePluginConfig(ctx, id, configJSON); err != nil { + log.Warn(ctx, "Config validation failed", "id", id, err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + // Try to return structured validation errors if available + response := map[string]any{"message": err.Error()} + _ = json.NewEncoder(w).Encode(response) + return err + } + if err := pm.UpdatePluginConfig(ctx, id, configJSON); err != nil { log.Error(ctx, "Error updating plugin config", "id", id, err) http.Error(w, "Error updating plugin configuration: "+err.Error(), http.StatusInternalServerError) diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index cb6875758..13099885f 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http/httptest" + "github.com/go-chi/jwtauth/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" @@ -19,6 +20,7 @@ import ( var _ = Describe("helpers", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) }) Describe("fakePath", func() { diff --git a/tests/mock_plugin_manager.go b/tests/mock_plugin_manager.go index 44f20088d..9691f7a38 100644 --- a/tests/mock_plugin_manager.go +++ b/tests/mock_plugin_manager.go @@ -5,7 +5,7 @@ import ( ) // MockPluginManager is a mock implementation of plugins.PluginManager for testing. -// It implements EnablePlugin, DisablePlugin, UpdatePluginConfig, UpdatePluginUsers, UpdatePluginLibraries and RescanPlugins methods. +// It implements EnablePlugin, DisablePlugin, UpdatePluginConfig, ValidatePluginConfig, UpdatePluginUsers, UpdatePluginLibraries and RescanPlugins methods. type MockPluginManager struct { // EnablePluginFn is called when EnablePlugin is invoked. If nil, returns EnableError. EnablePluginFn func(ctx context.Context, id string) error @@ -13,6 +13,8 @@ type MockPluginManager struct { DisablePluginFn func(ctx context.Context, id string) error // UpdatePluginConfigFn is called when UpdatePluginConfig is invoked. If nil, returns ConfigError. UpdatePluginConfigFn func(ctx context.Context, id, configJSON string) error + // ValidatePluginConfigFn is called when ValidatePluginConfig is invoked. If nil, returns ValidateError. + ValidatePluginConfigFn func(ctx context.Context, id, configJSON string) error // UpdatePluginUsersFn is called when UpdatePluginUsers is invoked. If nil, returns UsersError. UpdatePluginUsersFn func(ctx context.Context, id, usersJSON string, allUsers bool) error // UpdatePluginLibrariesFn is called when UpdatePluginLibraries is invoked. If nil, returns LibrariesError. @@ -24,6 +26,7 @@ type MockPluginManager struct { EnableError error DisableError error ConfigError error + ValidateError error UsersError error LibrariesError error RescanError error @@ -35,6 +38,10 @@ type MockPluginManager struct { ID string ConfigJSON string } + ValidatePluginConfigCalls []struct { + ID string + ConfigJSON string + } UpdatePluginUsersCalls []struct { ID string UsersJSON string @@ -75,6 +82,17 @@ func (m *MockPluginManager) UpdatePluginConfig(ctx context.Context, id, configJS return m.ConfigError } +func (m *MockPluginManager) ValidatePluginConfig(ctx context.Context, id, configJSON string) error { + m.ValidatePluginConfigCalls = append(m.ValidatePluginConfigCalls, struct { + ID string + ConfigJSON string + }{ID: id, ConfigJSON: configJSON}) + if m.ValidatePluginConfigFn != nil { + return m.ValidatePluginConfigFn(ctx, id, configJSON) + } + return m.ValidateError +} + func (m *MockPluginManager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error { m.UpdatePluginUsersCalls = append(m.UpdatePluginUsersCalls, struct { ID string diff --git a/ui/index.html b/ui/index.html index 0e60ec678..827751856 100644 --- a/ui/index.html +++ b/ui/index.html @@ -27,6 +27,10 @@ Navidrome + diff --git a/ui/src/dialogs/SelectPlaylistInput.jsx b/ui/src/dialogs/SelectPlaylistInput.jsx index d401dd822..847107523 100644 --- a/ui/src/dialogs/SelectPlaylistInput.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.jsx @@ -318,11 +318,10 @@ export const SelectPlaylistInput = ({ onChange }) => { const canCreateNew = Boolean( searchText.trim() && - !filteredOptions.some( - (option) => - option.name.toLowerCase() === searchText.toLowerCase().trim(), - ) && - !selectedPlaylists.some((p) => p.name === searchText.trim()), + !filteredOptions.some( + (option) => option.name.toLowerCase() === searchText.toLowerCase().trim(), + ) && + !selectedPlaylists.some((p) => p.name === searchText.trim()), ) return ( diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index c0e671b0f..ef7d87562 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -388,6 +388,8 @@ }, "messages": { "configHelp": "Configure the plugin using key-value pairs. Leave empty if the plugin requires no configuration.", + "configValidationError": "Configuration validation failed:", + "schemaRenderError": "Unable to render configuration form. The plugin's schema may be invalid.", "clickPermissions": "Click a permission for details", "noConfig": "No configuration set", "allUsersHelp": "When enabled, the plugin will have access to all users, including those created in the future.", diff --git a/ui/src/plugin/AlwaysExpandedArrayLayout.jsx b/ui/src/plugin/AlwaysExpandedArrayLayout.jsx new file mode 100644 index 000000000..2c833a1f8 --- /dev/null +++ b/ui/src/plugin/AlwaysExpandedArrayLayout.jsx @@ -0,0 +1,276 @@ +import React, { useCallback, useMemo } from 'react' +import { + composePaths, + computeLabel, + createDefaultValue, + isObjectArrayWithNesting, + isPrimitiveArrayControl, + rankWith, + findUISchema, + Resolve, +} from '@jsonforms/core' +import { + JsonFormsDispatch, + withJsonFormsArrayLayoutProps, +} from '@jsonforms/react' +import range from 'lodash/range' +import merge from 'lodash/merge' +import { Box, IconButton, Tooltip, Typography } from '@material-ui/core' +import { Add, Delete } from '@material-ui/icons' +import { makeStyles } from '@material-ui/core/styles' + +const useStyles = makeStyles((theme) => ({ + arrayItem: { + position: 'relative', + padding: theme.spacing(2), + marginBottom: theme.spacing(2), + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + '&:last-child': { + marginBottom: 0, + }, + }, + deleteButton: { + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(1), + }, + itemContent: { + paddingRight: theme.spacing(4), // Space for delete button + }, +})) + +// Default translations for array controls +const defaultTranslations = { + addTooltip: 'Add', + addAriaLabel: 'Add button', + removeTooltip: 'Delete', + removeAriaLabel: 'Delete button', + noDataMessage: 'No data', +} + +// Simplified array item renderer - clean card layout +// eslint-disable-next-line react-refresh/only-export-components +const ArrayItem = ({ + index, + path, + schema, + uischema, + uischemas, + rootSchema, + renderers, + cells, + enabled, + removeItems, + translations, + disableRemove, +}) => { + const classes = useStyles() + const childPath = composePaths(path, `${index}`) + + const foundUISchema = useMemo( + () => + findUISchema( + uischemas, + schema, + uischema.scope, + path, + undefined, + uischema, + rootSchema, + ), + [uischemas, schema, path, uischema, rootSchema], + ) + + return ( + + {enabled && !disableRemove && ( + + removeItems(path, [index])()} + size="small" + aria-label={translations.removeAriaLabel} + > + + + + )} + + + + + ) +} + +// Array toolbar with add button +// eslint-disable-next-line react-refresh/only-export-components +const ArrayToolbar = ({ + label, + description, + enabled, + addItem, + path, + createDefault, + translations, + disableAdd, +}) => ( + + + {label} + {!disableAdd && ( + + + + + + )} + + {description && ( + + {description} + + )} + +) + +const useArrayStyles = makeStyles((theme) => ({ + container: { + marginBottom: theme.spacing(2), + }, +})) + +// Main array layout component - items always expanded +// eslint-disable-next-line react-refresh/only-export-components +const AlwaysExpandedArrayLayoutComponent = (props) => { + const arrayClasses = useArrayStyles() + const { + enabled, + data, + path, + schema, + uischema, + addItem, + removeItems, + renderers, + cells, + label, + description, + required, + rootSchema, + config, + uischemas, + disableAdd, + disableRemove, + } = props + + const innerCreateDefaultValue = useCallback( + () => createDefaultValue(schema, rootSchema), + [schema, rootSchema], + ) + + const appliedUiSchemaOptions = merge({}, config, uischema.options) + const doDisableAdd = disableAdd || appliedUiSchemaOptions.disableAdd + const doDisableRemove = disableRemove || appliedUiSchemaOptions.disableRemove + const translations = defaultTranslations + + return ( +
+ +
+ {data > 0 ? ( + range(data).map((index) => ( + + )) + ) : ( + + {translations.noDataMessage} + + )} +
+
+ ) +} + +// Wrap with JSONForms HOC +const WrappedArrayLayout = withJsonFormsArrayLayoutProps( + AlwaysExpandedArrayLayoutComponent, +) + +// Custom tester that matches arrays but NOT enum arrays +// Enum arrays should be handled by MaterialEnumArrayRenderer (for checkboxes) +const isNonEnumArrayControl = (uischema, schema) => { + // First check if it matches our base conditions (object array or primitive array) + const baseCheck = + isObjectArrayWithNesting(uischema, schema) || + isPrimitiveArrayControl(uischema, schema) + + if (!baseCheck) { + return false + } + + // Resolve the actual schema for this control using JSONForms utility + const rootSchema = schema + const resolved = Resolve.schema(rootSchema, uischema?.scope, rootSchema) + + // Exclude enum arrays (uniqueItems + oneOf/enum) - let MaterialEnumArrayRenderer handle them + if (resolved?.uniqueItems && resolved?.items) { + const { items } = resolved + if (items.oneOf?.every((e) => e.const !== undefined) || items.enum) { + return false + } + } + + return true +} + +// Export as a renderer entry with high priority (5 > default 4) +// Matches both object arrays with nesting and primitive arrays, but NOT enum arrays +export const AlwaysExpandedArrayLayout = { + tester: rankWith(5, isNonEnumArrayControl), + renderer: WrappedArrayLayout, +} diff --git a/ui/src/plugin/ConfigCard.jsx b/ui/src/plugin/ConfigCard.jsx index 1fee31653..4e5bd6294 100644 --- a/ui/src/plugin/ConfigCard.jsx +++ b/ui/src/plugin/ConfigCard.jsx @@ -1,55 +1,119 @@ -import React, { useCallback } from 'react' -import { - Card, - CardContent, - Typography, - TextField as MuiTextField, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - IconButton, - Paper, -} from '@material-ui/core' -import { MdDelete } from 'react-icons/md' +import React, { useCallback, useState, useMemo } from 'react' +import PropTypes from 'prop-types' +import { Card, CardContent, Typography, Box } from '@material-ui/core' +import Alert from '@material-ui/lab/Alert' +import { SchemaConfigEditor } from './SchemaConfigEditor' + +// Navigate schema by path parts to find the title for a field +const findFieldTitle = (schema, parts) => { + let currentSchema = schema + let fieldName = parts[parts.length - 1] // Default to last part + + for (const part of parts) { + if (!currentSchema) break + + // Skip array indices (just move to items schema) + if (/^\d+$/.test(part)) { + if (currentSchema.items) { + currentSchema = currentSchema.items + } + continue + } + + // Navigate to property and always update fieldName + if (currentSchema.properties?.[part]) { + const propSchema = currentSchema.properties[part] + fieldName = propSchema.title || part + currentSchema = propSchema + } + } + + return fieldName +} + +// Extract human-readable field name from JSONForms error +const getFieldName = (error, schema) => { + // JSONForms errors can have different path formats: + // - dataPath: "users.1.token" (dot-separated) + // - instancePath: "/users/1/token" (slash-separated) + // - property: "users.1.username" (dot-separated) + const dataPath = error.dataPath || '' + const instancePath = error.instancePath || '' + const property = error.property || '' + + // Try dataPath first (dot-separated like "users.1.token") + if (dataPath) { + const parts = dataPath.split('.').filter(Boolean) + if (parts.length > 0) { + return findFieldTitle(schema, parts) + } + } + + // Try property (also dot-separated) + if (property) { + const parts = property.split('.').filter(Boolean) + if (parts.length > 0) { + return findFieldTitle(schema, parts) + } + } + + // Fall back to instancePath (slash-separated like "/users/1/token") + if (instancePath) { + const parts = instancePath.split('/').filter(Boolean) + if (parts.length > 0) { + return findFieldTitle(schema, parts) + } + } + + // Try to extract from schemaPath like "#/properties/users/items/properties/username/minLength" + const schemaPath = error.schemaPath || '' + const propMatches = [...schemaPath.matchAll(/\/properties\/([^/]+)/g)] + if (propMatches.length > 0) { + const parts = propMatches.map((m) => m[1]) + return findFieldTitle(schema, parts) + } + + return null +} export const ConfigCard = ({ - configPairs, - onConfigPairsChange, + manifest, + configData, + onConfigDataChange, classes, translate, }) => { - const handleKeyChange = useCallback( - (index, newKey) => { - const newPairs = [...configPairs] - newPairs[index] = { ...newPairs[index], key: newKey } - onConfigPairsChange(newPairs) + const [validationErrors, setValidationErrors] = useState([]) + + // Handle changes from JSONForms + const handleChange = useCallback( + (newData, errors) => { + setValidationErrors(errors || []) + onConfigDataChange(newData, errors) }, - [configPairs, onConfigPairsChange], + [onConfigDataChange], ) - const handleValueChange = useCallback( - (index, newValue) => { - const newPairs = [...configPairs] - newPairs[index] = { ...newPairs[index], value: newValue } - onConfigPairsChange(newPairs) - }, - [configPairs, onConfigPairsChange], - ) + // Only show config card if manifest has config schema defined + const hasConfigSchema = manifest?.config?.schema - const handleDeleteRow = useCallback( - (index) => { - const newPairs = configPairs.filter((_, i) => i !== index) - onConfigPairsChange(newPairs) - }, - [configPairs, onConfigPairsChange], - ) + // Format validation errors with proper field names + const formattedErrors = useMemo(() => { + if (!hasConfigSchema) { + return [] + } + const { schema } = manifest.config + return validationErrors.map((error) => ({ + fieldName: getFieldName(error, schema), + message: error.message, + })) + }, [validationErrors, manifest, hasConfigSchema]) - const handleAddRow = useCallback(() => { - onConfigPairsChange([...configPairs, { key: '', value: '' }]) - }, [configPairs, onConfigPairsChange]) + if (!hasConfigSchema) { + return null + } + + const { schema, uiSchema } = manifest.config return ( @@ -57,95 +121,44 @@ export const ConfigCard = ({ {translate('resources.plugin.sections.configuration')} - - {translate('resources.plugin.messages.configHelp')} - - - - - - - {translate('resources.plugin.fields.configKey')} - - - {translate('resources.plugin.fields.configValue')} - - - - + - - - - - - {configPairs.map((pair, index) => ( - - - handleKeyChange(index, e.target.value)} - placeholder={translate( - 'resources.plugin.placeholders.configKey', - )} - InputProps={{ - className: classes.configTableInput, - }} - /> - - - handleValueChange(index, e.target.value)} - placeholder={translate( - 'resources.plugin.placeholders.configValue', - )} - InputProps={{ - className: classes.configTableInput, - }} - inputProps={{ - style: { resize: 'vertical' }, - }} - /> - - - handleDeleteRow(index)} - aria-label={translate('ra.action.delete')} - className={classes.configActionIconButton} - > - - - - - ))} - {configPairs.length === 0 && ( - - - - {translate('resources.plugin.messages.noConfig')} - - - - )} - -
-
+ {formattedErrors.length > 0 && ( + + + {translate('resources.plugin.messages.configValidationError')} +
    + {formattedErrors.map((error, index) => ( +
  • + {error.fieldName && {error.fieldName}} + {error.fieldName && ': '} + {error.message} +
  • + ))} +
+
+
+ )} + +
) } + +ConfigCard.propTypes = { + manifest: PropTypes.shape({ + config: PropTypes.shape({ + schema: PropTypes.object, + uiSchema: PropTypes.object, + }), + }), + configData: PropTypes.object, + onConfigDataChange: PropTypes.func.isRequired, + classes: PropTypes.object.isRequired, + translate: PropTypes.func.isRequired, +} diff --git a/ui/src/plugin/OutlinedRenderers.jsx b/ui/src/plugin/OutlinedRenderers.jsx new file mode 100644 index 000000000..5156038f1 --- /dev/null +++ b/ui/src/plugin/OutlinedRenderers.jsx @@ -0,0 +1,257 @@ +/* eslint-disable react-refresh/only-export-components */ +import React, { useState } from 'react' +import { + rankWith, + isStringControl, + isIntegerControl, + isNumberControl, + isEnumControl, + isOneOfEnumControl, + and, + not, + or, + optionIs, + isDescriptionHidden, +} from '@jsonforms/core' +import { + withJsonFormsControlProps, + withJsonFormsEnumProps, + withJsonFormsOneOfEnumProps, +} from '@jsonforms/react' +import { + TextField, + FormControl, + FormHelperText, + InputLabel, + Select, + MenuItem, +} from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import merge from 'lodash/merge' + +const useStyles = makeStyles( + (theme) => ({ + control: { + marginBottom: theme.spacing(2), + }, + }), + { name: 'NDOutlinedRenderers' }, +) + +/** + * Hook for common control state (focus, validation, description visibility) + * Tracks "touched" state to only show errors after the user has interacted with the field + */ +const useControlState = (props) => { + const { config, uischema, description, visible, errors } = props + const [isFocused, setIsFocused] = useState(false) + const [isTouched, setIsTouched] = useState(false) + + const appliedUiSchemaOptions = merge({}, config, uischema?.options) + // errors is a string when there are validation errors, empty/undefined when valid + const hasErrors = errors && errors.length > 0 + // Only show as invalid after the field has been touched (blurred) + const showError = isTouched && hasErrors + + const showDescription = !isDescriptionHidden( + visible, + description, + isFocused, + appliedUiSchemaOptions.showUnfocusedDescription, + ) + + const helperText = showError ? errors : showDescription ? description : '' + + const handleFocus = () => setIsFocused(true) + const handleBlur = () => { + setIsFocused(false) + setIsTouched(true) + } + + return { + isFocused, + appliedUiSchemaOptions, + showError, + helperText, + handleFocus, + handleBlur, + } +} + +/** + * Base outlined control component that uses TextField with outlined variant + * instead of the default Input component used by JSONForms 2.x + */ +const OutlinedControl = (props) => { + const classes = useStyles() + const { + data, + id, + enabled, + label, + visible, + type = 'text', + inputProps: extraInputProps = {}, + onChange, + } = props + + const { + appliedUiSchemaOptions, + showError, + helperText, + handleFocus, + handleBlur, + } = useControlState(props) + + if (!visible) { + return null + } + + return ( + + ) +} + +// Text control wrapper +const OutlinedTextControl = (props) => { + const { path, handleChange, schema, config, uischema } = props + const appliedUiSchemaOptions = merge({}, config, uischema?.options) + + const inputProps = {} + if (appliedUiSchemaOptions.restrict && schema?.maxLength) { + inputProps.maxLength = schema.maxLength + } + + return ( + handleChange(path, ev.target.value)} + /> + ) +} + +// Number control wrapper +const OutlinedNumberControl = (props) => { + const { path, handleChange, schema } = props + const { minimum, maximum } = schema || {} + + const inputProps = {} + if (minimum !== undefined) inputProps.min = minimum + if (maximum !== undefined) inputProps.max = maximum + + const handleNumberChange = (ev) => { + const value = ev.target.value + if (value === '') { + handleChange(path, undefined) + } else { + const numValue = Number(value) + if (!isNaN(numValue)) { + handleChange(path, numValue) + } + } + } + + return ( + + ) +} + +// Enum/Select control wrapper +const OutlinedEnumControl = (props) => { + const classes = useStyles() + const { data, id, enabled, path, handleChange, options, label, visible } = + props + const { + appliedUiSchemaOptions, + showError, + helperText, + handleFocus, + handleBlur, + } = useControlState(props) + + if (!visible) { + return null + } + + return ( + + {label} + + {helperText && {helperText}} + + ) +} + +// Testers - higher rank than default to override default renderers +// Enum renderers have highest rank since isStringControl also matches enum fields +export const OutlinedEnumRenderer = { + tester: rankWith(5, isEnumControl), + renderer: withJsonFormsEnumProps(OutlinedEnumControl), +} + +export const OutlinedOneOfEnumRenderer = { + tester: rankWith(5, isOneOfEnumControl), + renderer: withJsonFormsOneOfEnumProps(OutlinedEnumControl), +} + +export const OutlinedTextRenderer = { + tester: rankWith(3, and(isStringControl, not(optionIs('format', 'radio')))), + renderer: withJsonFormsControlProps(OutlinedTextControl), +} + +export const OutlinedNumberRenderer = { + tester: rankWith(3, or(isIntegerControl, isNumberControl)), + renderer: withJsonFormsControlProps(OutlinedNumberControl), +} diff --git a/ui/src/plugin/PluginShow.jsx b/ui/src/plugin/PluginShow.jsx index 805549dfc..38e858af4 100644 --- a/ui/src/plugin/PluginShow.jsx +++ b/ui/src/plugin/PluginShow.jsx @@ -33,9 +33,11 @@ const PluginShowLayout = () => { const isSmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) useResourceRefresh('plugin') - const [configPairs, setConfigPairs] = useState([]) + const [configData, setConfigData] = useState({}) + const [configErrors, setConfigErrors] = useState([]) const [isDirty, setIsDirty] = useState(false) const [lastRecordConfig, setLastRecordConfig] = useState(null) + const [isConfigInitialized, setIsConfigInitialized] = useState(false) // Users permission state const [selectedUsers, setSelectedUsers] = useState([]) @@ -49,41 +51,26 @@ const PluginShowLayout = () => { const [lastRecordLibraries, setLastRecordLibraries] = useState(null) const [lastRecordAllLibraries, setLastRecordAllLibraries] = useState(null) - // Convert JSON config to key-value pairs - const jsonToPairs = useCallback((jsonString) => { - if (!jsonString || jsonString.trim() === '') return [] + // Parse JSON config to object + const jsonToObject = useCallback((jsonString) => { + if (!jsonString || jsonString.trim() === '') return {} try { - const obj = JSON.parse(jsonString) - return Object.entries(obj).map(([key, value]) => ({ - key, - value: typeof value === 'string' ? value : JSON.stringify(value), - })) + return JSON.parse(jsonString) } catch { - return [] + return {} } }, []) - // Convert key-value pairs to JSON config - const pairsToJson = useCallback((pairs) => { - if (pairs.length === 0) return '' - const obj = {} - pairs.forEach((pair) => { - if (pair.key.trim()) { - // Always store values as strings (backend expects map[string]string) - obj[pair.key] = pair.value - } - }) - return JSON.stringify(obj) - }, []) - // Initialize/update config when record loads or changes (e.g., from SSE refresh) React.useEffect(() => { const recordConfig = record?.config || '' if (record && recordConfig !== lastRecordConfig && !isDirty) { - setConfigPairs(jsonToPairs(recordConfig)) + setConfigData(jsonToObject(recordConfig)) setLastRecordConfig(recordConfig) + // Reset initialization flag - AJV will apply defaults on first render + setIsConfigInitialized(false) } - }, [record, lastRecordConfig, isDirty, jsonToPairs]) + }, [record, lastRecordConfig, isDirty, jsonToObject]) // Initialize/update users permission state when record loads or changes React.useEffect(() => { @@ -131,10 +118,19 @@ const PluginShowLayout = () => { } }, [record, lastRecordLibraries, lastRecordAllLibraries, isDirty]) - const handleConfigPairsChange = useCallback((newPairs) => { - setConfigPairs(newPairs) - setIsDirty(true) - }, []) + const handleConfigDataChange = useCallback( + (newData, errors) => { + setConfigData(newData) + setConfigErrors(errors || []) + // Skip marking dirty on initial onChange (when AJV applies defaults) + if (isConfigInitialized) { + setIsDirty(true) + } else { + setIsConfigInitialized(true) + } + }, + [isConfigInitialized], + ) const handleSelectedUsersChange = useCallback((newSelectedUsers) => { setSelectedUsers(newSelectedUsers) @@ -184,18 +180,23 @@ const PluginShowLayout = () => { const handleSaveConfig = useCallback(() => { if (!record) return - const config = pairsToJson(configPairs) - const data = { config } + const parsedManifest = record.manifest ? JSON.parse(record.manifest) : null + const data = {} + + // Only include config if the plugin has a config schema + if (parsedManifest?.config?.schema) { + data.config = + Object.keys(configData).length > 0 ? JSON.stringify(configData) : '' + } // Include users data if users permission is present - const manifest = record.manifest ? JSON.parse(record.manifest) : null - if (manifest?.permissions?.users) { + if (parsedManifest?.permissions?.users) { data.users = JSON.stringify(selectedUsers) data.allUsers = allUsers } // Include libraries data if library permission is present - if (manifest?.permissions?.library) { + if (parsedManifest?.permissions?.library) { data.libraries = JSON.stringify(selectedLibraries) data.allLibraries = allLibraries } @@ -204,8 +205,7 @@ const PluginShowLayout = () => { }, [ updatePlugin, record, - configPairs, - pairsToJson, + configData, selectedUsers, allUsers, selectedLibraries, @@ -273,8 +273,9 @@ const PluginShowLayout = () => { /> @@ -303,7 +304,7 @@ const PluginShowLayout = () => { color="primary" startIcon={} onClick={handleSaveConfig} - disabled={!isDirty || loading} + disabled={!isDirty || loading || configErrors.length > 0} className={classes.saveButton} > {translate('ra.action.save')} diff --git a/ui/src/plugin/SchemaConfigEditor.jsx b/ui/src/plugin/SchemaConfigEditor.jsx new file mode 100644 index 000000000..58259f772 --- /dev/null +++ b/ui/src/plugin/SchemaConfigEditor.jsx @@ -0,0 +1,241 @@ +import React, { useCallback, useEffect, useMemo, useRef } from 'react' +import PropTypes from 'prop-types' +import { JsonForms } from '@jsonforms/react' +import { materialRenderers, materialCells } from '@jsonforms/material-renderers' +import { makeStyles } from '@material-ui/core/styles' +import { Typography } from '@material-ui/core' +import { useTranslate } from 'react-admin' +import Ajv from 'ajv' +import { AlwaysExpandedArrayLayout } from './AlwaysExpandedArrayLayout' +import { + OutlinedTextRenderer, + OutlinedNumberRenderer, + OutlinedEnumRenderer, + OutlinedOneOfEnumRenderer, +} from './OutlinedRenderers' + +// Error boundary for catching JSONForms rendering errors +class SchemaErrorBoundary extends React.Component { + constructor(props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error) { + return { hasError: true, error } + } + + render() { + if (this.state.hasError) { + return this.props.fallback(this.state.error) + } + return this.props.children + } +} + +SchemaErrorBoundary.propTypes = { + children: PropTypes.node.isRequired, + fallback: PropTypes.func.isRequired, +} + +// Custom AJV instance that fixes "required" error paths for JSONForms. +// AJV outputs required errors pointing to the parent (e.g., "/users/1") with +// params.missingProperty. We transform them to point to the field directly +// (e.g., "/users/1/username") so JSONForms displays them under the correct input. +const ajv = new Ajv({ + useDefaults: true, + allErrors: true, + verbose: true, + jsonPointers: true, +}) +const origCompile = ajv.compile.bind(ajv) +ajv.compile = (schema) => { + const validate = origCompile(schema) + const wrapped = (data) => { + const valid = validate(data) + validate.errors?.forEach((e) => { + if (e.keyword === 'required' && e.params?.missingProperty) { + e.dataPath = `${e.dataPath || ''}/${e.params.missingProperty}` + } + }) + wrapped.errors = validate.errors + return valid + } + wrapped.schema = validate.schema + return wrapped +} + +const useStyles = makeStyles( + (theme) => ({ + root: { + '& .MuiFormControl-root': { + marginBottom: theme.spacing(2), + }, + // Label elements (type: "Label" in UI schema) - make slightly smaller + '& .MuiTypography-h6': { + fontSize: '0.95rem', + }, + // Group/array styling + '& .MuiPaper-root': { + backgroundColor: 'transparent', + }, + // Array items styling + '& .MuiAccordion-root': { + marginBottom: theme.spacing(1), + '&:before': { + display: 'none', + }, + }, + '& .MuiAccordionSummary-root': { + backgroundColor: + theme.palette.type === 'dark' + ? theme.palette.grey[800] + : theme.palette.grey[100], + // Hide expand icon - items are always expanded + '& .MuiAccordionSummary-expandIcon': { + display: 'none', + }, + }, + // Checkbox/switch styling + '& .MuiCheckbox-root, & .MuiSwitch-root': { + color: theme.palette.text.secondary, + }, + '& .Mui-checked': { + color: theme.palette.primary.main, + }, + }, + errorContainer: { + padding: theme.spacing(2), + backgroundColor: + theme.palette.type === 'dark' + ? 'rgba(244, 67, 54, 0.1)' + : 'rgba(244, 67, 54, 0.05)', + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.error.main}`, + }, + errorMessage: { + color: theme.palette.error.main, + marginBottom: theme.spacing(1), + }, + errorDetails: { + color: theme.palette.text.secondary, + fontSize: '0.85em', + fontFamily: 'monospace', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, + }), + { name: 'NDSchemaConfigEditor' }, +) + +// Custom renderers with outlined text inputs and always-expanded array layout +const customRenderers = [ + // Put our custom renderers first (higher priority) + OutlinedTextRenderer, + OutlinedNumberRenderer, + OutlinedEnumRenderer, + OutlinedOneOfEnumRenderer, + AlwaysExpandedArrayLayout, + // Then all the standard material renderers + ...materialRenderers, +] + +export const SchemaConfigEditor = ({ + schema, + uiSchema, + data, + onChange, + readOnly = false, +}) => { + const classes = useStyles() + const translate = useTranslate() + const containerRef = useRef(null) + + // Disable browser autocomplete on all inputs + useEffect(() => { + if (!containerRef.current) return + + const disableAutocomplete = () => { + const inputs = containerRef.current.querySelectorAll('input') + inputs.forEach((input) => { + input.setAttribute('autocomplete', 'off') + }) + } + + // Run immediately and observe for changes (new inputs added) + disableAutocomplete() + const observer = new MutationObserver(disableAutocomplete) + observer.observe(containerRef.current, { childList: true, subtree: true }) + + return () => observer.disconnect() + }, [data]) + + // Memoize the change handler to extract just the data + const handleChange = useCallback( + ({ data: newData, errors }) => { + if (onChange) { + onChange(newData, errors) + } + }, + [onChange], + ) + + // Use custom renderers with always-expanded array layout + const renderers = useMemo(() => customRenderers, []) + const cells = useMemo(() => materialCells, []) + + // JSONForms config - always show descriptions + const config = { + showUnfocusedDescription: true, + } + + // Ensure schema has required fields for JSONForms + const normalizedSchema = useMemo(() => { + if (!schema) return null + // JSONForms requires type to be set at root level + return { + type: 'object', + ...schema, + } + }, [schema]) + + if (!normalizedSchema) { + return null + } + + const renderError = (error) => ( +
+ + {translate('resources.plugin.messages.schemaRenderError')} + + {error?.message} +
+ ) + + return ( +
+ + + +
+ ) +} + +SchemaConfigEditor.propTypes = { + schema: PropTypes.object, + uiSchema: PropTypes.object, + data: PropTypes.object, + onChange: PropTypes.func, + readOnly: PropTypes.bool, +} diff --git a/ui/src/plugin/SchemaConfigEditor.test.jsx b/ui/src/plugin/SchemaConfigEditor.test.jsx new file mode 100644 index 000000000..ab93e3ac8 --- /dev/null +++ b/ui/src/plugin/SchemaConfigEditor.test.jsx @@ -0,0 +1,86 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render } from '@testing-library/react' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { Provider } from 'react-redux' +import { createStore } from 'redux' +import { SchemaConfigEditor } from './SchemaConfigEditor' + +const theme = createTheme() + +// JSONForms requires Redux +const mockStore = createStore(() => ({})) + +const renderWithProviders = (component) => { + return render( + + {component} + , + ) +} + +describe('SchemaConfigEditor', () => { + const basicSchema = { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Name', + }, + enabled: { + type: 'boolean', + title: 'Enabled', + }, + }, + } + + it('renders nothing when schema is null', () => { + const { container } = renderWithProviders( + , + ) + expect(container.firstChild).toBeNull() + }) + + it('renders the component wrapper with valid schema', () => { + const { container } = renderWithProviders( + , + ) + // Check that the wrapper div is rendered (class name is generated) + expect( + container.querySelector('[class*="NDSchemaConfigEditor-root"]'), + ).toBeTruthy() + }) + + it('calls onChange on initial render', () => { + const onChange = vi.fn() + renderWithProviders( + , + ) + + // JSONForms calls onChange on initial render with initial state + expect(onChange).toHaveBeenCalled() + }) + + it('passes data and errors to onChange callback', () => { + const onChange = vi.fn() + const initialData = { name: 'Test Value' } + + renderWithProviders( + , + ) + + // Check that onChange was called with data and errors + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Test Value' }), + expect.any(Array), + ) + }) +}) diff --git a/ui/src/plugin/ToggleEnabledSwitch.jsx b/ui/src/plugin/ToggleEnabledSwitch.jsx index 1a4df6206..0b7b4d7d7 100644 --- a/ui/src/plugin/ToggleEnabledSwitch.jsx +++ b/ui/src/plugin/ToggleEnabledSwitch.jsx @@ -22,6 +22,15 @@ const useStyles = makeStyles((theme) => ({ theme.palette.success?.main || theme.palette.primary.main, }, }, + errorSwitch: { + '& .MuiSwitch-thumb': { + backgroundColor: theme.palette.warning.main, + }, + '& .MuiSwitch-track': { + backgroundColor: theme.palette.warning.light, + opacity: 0.7, + }, + }, })) /** @@ -146,7 +155,7 @@ const ToggleEnabledSwitch = ({ checked={record?.enabled ?? false} onClick={handleClick} disabled={isDisabled} - className={classes.enabledSwitch} + className={isDisabled ? classes.errorSwitch : classes.enabledSwitch} size={size} color="primary" /> diff --git a/ui/vite.config.js b/ui/vite.config.js index dee9d3939..9d9c845f1 100644 --- a/ui/vite.config.js +++ b/ui/vite.config.js @@ -14,6 +14,9 @@ export default defineConfig({ strategies: 'injectManifest', srcDir: 'src', filename: 'sw.js', + injectManifest: { + maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MiB + }, devOptions: { enabled: true, }, @@ -27,6 +30,10 @@ export default defineConfig({ }, }, base: './', + define: { + // JSONForms and other libraries use process.env + 'process.env': JSON.stringify({}), + }, build: { outDir: 'build', sourcemap: true,