mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-02 06:24:14 +00:00
feat(plugins): add JSONForms-based plugin configuration UI (#4911)
* feat(plugins): add JSONForms schema for plugin configuration Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance error handling by formatting validation errors with field names Signed-off-by: Deluan <deluan@navidrome.org> * feat: enforce required fields in config validation and improve error handling Signed-off-by: Deluan <deluan@navidrome.org> * format JS code Signed-off-by: Deluan <deluan@navidrome.org> * feat: add config schema validation and enhance manifest structure Signed-off-by: Deluan <deluan@navidrome.org> * feat: refactor plugin config parsing and add unit tests Signed-off-by: Deluan <deluan@navidrome.org> * feat: add config validation error message in Portuguese * feat: enhance AlwaysExpandedArrayLayout with description support and improve array control testing Signed-off-by: Deluan <deluan@navidrome.org> * feat: update Discord Rust plugin configuration to use JSONForm for user tokens and enhance schema validation Signed-off-by: Deluan <deluan@navidrome.org> * fix: resolve React Hooks linting issues in plugin UI components * Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * format code Signed-off-by: Deluan <deluan@navidrome.org> * feat: migrate schema validation to use santhosh-tekuri/jsonschema and improve error formatting Signed-off-by: Deluan <deluan@navidrome.org> * address PR comments Signed-off-by: Deluan <deluan@navidrome.org> * fix flaky test Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance array layout and configuration handling with AJV defaults Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement custom tester to exclude enum arrays from AlwaysExpandedArrayLayout Signed-off-by: Deluan <deluan@navidrome.org> * feat: add error boundary for schema rendering and improve error messages Signed-off-by: Deluan <deluan@navidrome.org> * feat: refine non-enum array control logic by utilizing JSONForms schema resolution Signed-off-by: Deluan <deluan@navidrome.org> * feat: add error styling to ToggleEnabledSwitch for disabled state Signed-off-by: Deluan <deluan@navidrome.org> * feat: adjust label positioning and styling in SchemaConfigEditor for improved layout Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement outlined input controls renderers to replace custom fragile CSS Signed-off-by: Deluan <deluan@navidrome.org> * feat: remove margin from last form control inside array items for better spacing Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance AJV error handling to transform required errors for field-level validation Signed-off-by: Deluan <deluan@navidrome.org> * feat: set default value for User Tokens in manifest.json to improve user experience Signed-off-by: Deluan <deluan@navidrome.org> * format Signed-off-by: Deluan <deluan@navidrome.org> * feat: add margin to outlined input controls for improved spacing Signed-off-by: Deluan <deluan@navidrome.org> * feat: remove redundant margin rule for last form control in array items Signed-off-by: Deluan <deluan@navidrome.org> * feat: adjust font size of label elements in SchemaConfigEditor for improved readability Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
parent
66474fc9f4
commit
f1e75c40dc
1
go.mod
1
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
|
||||
|
||||
4
go.sum
4
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=
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
129
plugins/config_validation.go
Normal file
129
plugins/config_validation.go
Normal file
@ -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
|
||||
}
|
||||
186
plugins/config_validation_test.go
Normal file
186
plugins/config_validation_test.go
Normal file
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String, String>), 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<UserToken> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
60
plugins/manager_loader_test.go
Normal file
60
plugins/manager_loader_test.go
Normal file
@ -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())
|
||||
})
|
||||
})
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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"`
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
57
plugins/testdata/test-config/manifest.json
vendored
57
plugins/testdata/test-config/manifest.json
vendored
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -4,8 +4,10 @@ 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"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
@ -17,6 +19,7 @@ import (
|
||||
var _ = Describe("helpers", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
|
||||
})
|
||||
|
||||
Describe("fakePath", func() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -27,6 +27,10 @@
|
||||
<meta property="og:image:width" content="300">
|
||||
<meta property="og:image:height" content="300">
|
||||
<title>Navidrome</title>
|
||||
<script>
|
||||
// Shim for libraries that check for Node.js process object
|
||||
window.process = { env: {} };
|
||||
</script>
|
||||
<script>
|
||||
window.__APP_CONFIG__ = {{ .AppConfig }}
|
||||
</script>
|
||||
|
||||
4742
ui/package-lock.json
generated
4742
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,9 @@
|
||||
"postinstall": "bin/update-workbox.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jsonforms/core": "^2.5.2",
|
||||
"@jsonforms/material-renderers": "^2.5.2",
|
||||
"@jsonforms/react": "^2.5.2",
|
||||
"@material-ui/core": "^4.12.4",
|
||||
"@material-ui/icons": "^4.11.3",
|
||||
"@material-ui/lab": "^4.0.0-alpha.61",
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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.",
|
||||
|
||||
276
ui/src/plugin/AlwaysExpandedArrayLayout.jsx
Normal file
276
ui/src/plugin/AlwaysExpandedArrayLayout.jsx
Normal file
@ -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 (
|
||||
<Box className={classes.arrayItem}>
|
||||
{enabled && !disableRemove && (
|
||||
<Tooltip
|
||||
title={translations.removeTooltip}
|
||||
className={classes.deleteButton}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => removeItems(path, [index])()}
|
||||
size="small"
|
||||
aria-label={translations.removeAriaLabel}
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Box className={classes.itemContent}>
|
||||
<JsonFormsDispatch
|
||||
enabled={enabled}
|
||||
schema={schema}
|
||||
uischema={foundUISchema}
|
||||
path={childPath}
|
||||
key={childPath}
|
||||
renderers={renderers}
|
||||
cells={cells}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Array toolbar with add button
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const ArrayToolbar = ({
|
||||
label,
|
||||
description,
|
||||
enabled,
|
||||
addItem,
|
||||
path,
|
||||
createDefault,
|
||||
translations,
|
||||
disableAdd,
|
||||
}) => (
|
||||
<Box mb={1}>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="h6">{label}</Typography>
|
||||
{!disableAdd && (
|
||||
<Tooltip
|
||||
title={translations.addTooltip}
|
||||
aria-label={translations.addAriaLabel}
|
||||
>
|
||||
<IconButton
|
||||
onClick={addItem(path, createDefault())}
|
||||
disabled={!enabled}
|
||||
size="small"
|
||||
>
|
||||
<Add />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
{description && (
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
|
||||
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 (
|
||||
<div className={arrayClasses.container}>
|
||||
<ArrayToolbar
|
||||
translations={translations}
|
||||
label={computeLabel(
|
||||
label,
|
||||
required,
|
||||
appliedUiSchemaOptions.hideRequiredAsterisk,
|
||||
)}
|
||||
description={description}
|
||||
path={path}
|
||||
enabled={enabled}
|
||||
addItem={addItem}
|
||||
createDefault={innerCreateDefaultValue}
|
||||
disableAdd={doDisableAdd}
|
||||
/>
|
||||
<div>
|
||||
{data > 0 ? (
|
||||
range(data).map((index) => (
|
||||
<ArrayItem
|
||||
key={index}
|
||||
index={index}
|
||||
path={path}
|
||||
schema={schema}
|
||||
uischema={uischema}
|
||||
uischemas={uischemas}
|
||||
rootSchema={rootSchema}
|
||||
renderers={renderers}
|
||||
cells={cells}
|
||||
enabled={enabled}
|
||||
removeItems={removeItems}
|
||||
translations={translations}
|
||||
disableRemove={doDisableRemove}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography color="textSecondary">
|
||||
{translations.noDataMessage}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
@ -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 (
|
||||
<Card className={classes.section}>
|
||||
@ -57,95 +121,44 @@ export const ConfigCard = ({
|
||||
<Typography variant="h6" className={classes.sectionTitle}>
|
||||
{translate('resources.plugin.sections.configuration')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
{translate('resources.plugin.messages.configHelp')}
|
||||
</Typography>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small" className={classes.configTable}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="40%">
|
||||
{translate('resources.plugin.fields.configKey')}
|
||||
</TableCell>
|
||||
<TableCell width="50%">
|
||||
{translate('resources.plugin.fields.configValue')}
|
||||
</TableCell>
|
||||
<TableCell width="10%" align="right">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleAddRow}
|
||||
aria-label={translate('resources.plugin.actions.addConfig')}
|
||||
className={classes.configActionIconButton}
|
||||
>
|
||||
+
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{configPairs.map((pair, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<MuiTextField
|
||||
fullWidth
|
||||
size="small"
|
||||
variant="outlined"
|
||||
value={pair.key}
|
||||
onChange={(e) => handleKeyChange(index, e.target.value)}
|
||||
placeholder={translate(
|
||||
'resources.plugin.placeholders.configKey',
|
||||
)}
|
||||
InputProps={{
|
||||
className: classes.configTableInput,
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MuiTextField
|
||||
fullWidth
|
||||
size="small"
|
||||
variant="outlined"
|
||||
multiline
|
||||
minRows={1}
|
||||
value={pair.value}
|
||||
onChange={(e) => handleValueChange(index, e.target.value)}
|
||||
placeholder={translate(
|
||||
'resources.plugin.placeholders.configValue',
|
||||
)}
|
||||
InputProps={{
|
||||
className: classes.configTableInput,
|
||||
}}
|
||||
inputProps={{
|
||||
style: { resize: 'vertical' },
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDeleteRow(index)}
|
||||
aria-label={translate('ra.action.delete')}
|
||||
className={classes.configActionIconButton}
|
||||
>
|
||||
<MdDelete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{configPairs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} align="center">
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{translate('resources.plugin.messages.noConfig')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{formattedErrors.length > 0 && (
|
||||
<Box mb={2}>
|
||||
<Alert severity="error">
|
||||
{translate('resources.plugin.messages.configValidationError')}
|
||||
<ul style={{ margin: '8px 0 0', paddingLeft: 20 }}>
|
||||
{formattedErrors.map((error, index) => (
|
||||
<li key={index}>
|
||||
{error.fieldName && <strong>{error.fieldName}</strong>}
|
||||
{error.fieldName && ': '}
|
||||
{error.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SchemaConfigEditor
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
data={configData}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
257
ui/src/plugin/OutlinedRenderers.jsx
Normal file
257
ui/src/plugin/OutlinedRenderers.jsx
Normal file
@ -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 (
|
||||
<TextField
|
||||
id={id}
|
||||
label={label}
|
||||
type={type}
|
||||
value={data ?? ''}
|
||||
onChange={onChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={!enabled}
|
||||
autoFocus={appliedUiSchemaOptions.focus}
|
||||
multiline={type === 'text' && appliedUiSchemaOptions.multi}
|
||||
rows={appliedUiSchemaOptions.multi ? 3 : undefined}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
size="small"
|
||||
error={showError}
|
||||
helperText={helperText}
|
||||
inputProps={extraInputProps}
|
||||
className={classes.control}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<OutlinedControl
|
||||
{...props}
|
||||
type={appliedUiSchemaOptions.format === 'password' ? 'password' : 'text'}
|
||||
inputProps={inputProps}
|
||||
onChange={(ev) => 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 (
|
||||
<OutlinedControl
|
||||
{...props}
|
||||
type="number"
|
||||
inputProps={inputProps}
|
||||
onChange={handleNumberChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<FormControl
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="small"
|
||||
error={showError}
|
||||
className={classes.control}
|
||||
>
|
||||
<InputLabel id={`${id}-label`}>{label}</InputLabel>
|
||||
<Select
|
||||
labelId={`${id}-label`}
|
||||
id={id}
|
||||
value={data ?? ''}
|
||||
onChange={(ev) => handleChange(path, ev.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={!enabled}
|
||||
autoFocus={appliedUiSchemaOptions.focus}
|
||||
label={label}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{options?.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{helperText && <FormHelperText>{helperText}</FormHelperText>}
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
||||
// 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),
|
||||
}
|
||||
@ -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 = () => {
|
||||
/>
|
||||
|
||||
<ConfigCard
|
||||
configPairs={configPairs}
|
||||
onConfigPairsChange={handleConfigPairsChange}
|
||||
manifest={manifest}
|
||||
configData={configData}
|
||||
onConfigDataChange={handleConfigDataChange}
|
||||
classes={classes}
|
||||
translate={translate}
|
||||
/>
|
||||
@ -303,7 +304,7 @@ const PluginShowLayout = () => {
|
||||
color="primary"
|
||||
startIcon={<MdSave />}
|
||||
onClick={handleSaveConfig}
|
||||
disabled={!isDirty || loading}
|
||||
disabled={!isDirty || loading || configErrors.length > 0}
|
||||
className={classes.saveButton}
|
||||
>
|
||||
{translate('ra.action.save')}
|
||||
|
||||
241
ui/src/plugin/SchemaConfigEditor.jsx
Normal file
241
ui/src/plugin/SchemaConfigEditor.jsx
Normal file
@ -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) => (
|
||||
<div className={classes.errorContainer}>
|
||||
<Typography className={classes.errorMessage}>
|
||||
{translate('resources.plugin.messages.schemaRenderError')}
|
||||
</Typography>
|
||||
<Typography className={classes.errorDetails}>{error?.message}</Typography>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={classes.root}>
|
||||
<SchemaErrorBoundary fallback={renderError}>
|
||||
<JsonForms
|
||||
schema={normalizedSchema}
|
||||
uischema={uiSchema}
|
||||
data={data || {}}
|
||||
renderers={renderers}
|
||||
cells={cells}
|
||||
config={config}
|
||||
onChange={handleChange}
|
||||
readonly={readOnly}
|
||||
ajv={ajv}
|
||||
validationMode="ValidateAndShow"
|
||||
/>
|
||||
</SchemaErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
SchemaConfigEditor.propTypes = {
|
||||
schema: PropTypes.object,
|
||||
uiSchema: PropTypes.object,
|
||||
data: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
readOnly: PropTypes.bool,
|
||||
}
|
||||
86
ui/src/plugin/SchemaConfigEditor.test.jsx
Normal file
86
ui/src/plugin/SchemaConfigEditor.test.jsx
Normal file
@ -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(
|
||||
<Provider store={mockStore}>
|
||||
<ThemeProvider theme={theme}>{component}</ThemeProvider>
|
||||
</Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<SchemaConfigEditor schema={null} data={{}} onChange={vi.fn()} />,
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the component wrapper with valid schema', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<SchemaConfigEditor schema={basicSchema} data={{}} onChange={vi.fn()} />,
|
||||
)
|
||||
// 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(
|
||||
<SchemaConfigEditor
|
||||
schema={basicSchema}
|
||||
data={{ name: 'Test' }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
// 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(
|
||||
<SchemaConfigEditor
|
||||
schema={basicSchema}
|
||||
data={initialData}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Check that onChange was called with data and errors
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'Test Value' }),
|
||||
expect.any(Array),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user