Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
VERSION := $(shell git describe --tags ${TAG})
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto admin-app compose-up-dev
.DEFAULT_GOAL := build
PROTON_COMMIT := "e806c3a6f5ae280a23d02480becddc2818661715"
PROTON_COMMIT := "330c7558f34570056814d418f99730fb45cfe80f"

admin-app:
@echo " > generating admin build"
Expand Down
45 changes: 36 additions & 9 deletions core/preference/preference.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ const (
ScopeIDGlobal = "00000000-0000-0000-0000-000000000000"
)

// InputHintOption represents a selectable option with machine-readable name and user-friendly description
type InputHintOption struct {
// Machine-readable identifier (e.g., "sq_km", "megagram")
Name string `json:"name" yaml:"name"`
// User-friendly display description (e.g., "Square Kilometers", "Megagram (Mg)")
Description string `json:"description" yaml:"description"`
}

type Trait struct {
// Level at which the trait is applicable (say "platform", "organization", "user")
ResourceType string `json:"resource_type" yaml:"resource_type"`
Expand All @@ -68,7 +76,11 @@ type Trait struct {
// Type of input to be used to collect the value for the trait (say "text", "select", "checkbox", etc.)
Input TraitInput `json:"input" yaml:"input"`
// Acceptable values to be provided in the input (say "true,false") for a TraitInput of type Checkbox
// Deprecated: Use InputOptions for structured options with name and title
InputHints string `json:"input_hints" yaml:"input_hints"`
// Structured input options with name and title for select/combobox/multiselect inputs
// Takes precedence over InputHints when both are provided
InputOptions []InputHintOption `json:"input_options" yaml:"input_options"`
// Default value to be used for the trait if the preference is not set (say "true" for a TraitInput of type Checkbox)
Default string `json:"default" yaml:"default"`
// AllowedScopes specifies which scope types are valid for this trait
Expand All @@ -94,22 +106,37 @@ func (t Trait) GetValidator() PreferenceValidator {
case TraitInputText, TraitInputTextarea:
return NewTextValidator()
case TraitInputSelect, TraitInputCombobox:
// Use InputOptions if available, otherwise fall back to InputHints
if len(t.InputOptions) > 0 {
return NewSelectValidatorFromOptions(t.InputOptions)
}
return NewSelectValidator(t.InputHints)
default:
return NewTextValidator()
}
}

type Preference struct {
ID string `json:"id"`
Name string `json:"name"`
Value string `json:"value"`
ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"`
ScopeType string `json:"scope_type"`
ScopeID string `json:"scope_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Value string `json:"value"`
ValueDescription string `json:"value_description"`
ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"`
ScopeType string `json:"scope_type"`
ScopeID string `json:"scope_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

// GetValueDescription returns the human-readable description for a value from InputOptions
func (t Trait) GetValueDescription(value string) string {
for _, opt := range t.InputOptions {
if opt.Name == value {
return opt.Description
}
}
return ""
}

var DefaultTraits = []Trait{
Expand Down
23 changes: 16 additions & 7 deletions core/preference/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,13 @@ func (s *Service) Create(ctx context.Context, preference Preference) (Preference
if !validator.Validate(preference.Value) {
return Preference{}, ErrInvalidValue
}
return s.repo.Set(ctx, preference)
created, err := s.repo.Set(ctx, preference)
if err != nil {
return Preference{}, err
}
// Populate ValueDescription from trait's InputOptions
created.ValueDescription = matchedTrait.GetValueDescription(created.Value)
return created, nil
}

func (s *Service) Get(ctx context.Context, id string) (Preference, error) {
Expand Down Expand Up @@ -128,17 +134,20 @@ func (s *Service) LoadUserPreferences(ctx context.Context, filter Filter) ([]Pre
continue
}
if pref, exists := prefMap[trait.Name]; exists {
// Populate ValueDescription from trait's InputOptions
pref.ValueDescription = trait.GetValueDescription(pref.Value)
result = append(result, pref)
delete(prefMap, trait.Name) // mark as processed
} else if trait.Default != "" {
// Add default preference for unset trait
result = append(result, Preference{
Name: trait.Name,
Value: trait.Default,
ResourceID: filter.UserID,
ResourceType: schema.UserPrincipal,
ScopeType: filter.ScopeType,
ScopeID: filter.ScopeID,
Name: trait.Name,
Value: trait.Default,
ValueDescription: trait.GetValueDescription(trait.Default),
ResourceID: filter.UserID,
ResourceType: schema.UserPrincipal,
ScopeType: filter.ScopeType,
ScopeID: filter.ScopeID,
})
}
}
Expand Down
124 changes: 124 additions & 0 deletions core/preference/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/raystack/frontier/internal/bootstrap/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

type MockRepository struct {
Expand Down Expand Up @@ -61,6 +62,26 @@ func TestLoadUserPreferences(t *testing.T) {
},
}

// Define test traits with InputOptions for ValueDescription testing
testTraitsWithOptions := []Trait{
{
ResourceType: schema.UserPrincipal,
Name: "unit_area",
Default: "sq_km",
InputOptions: []InputHintOption{
{Name: "sq_km", Description: "Square Kilometers"},
{Name: "sq_ft", Description: "Square Feet"},
{Name: "acres", Description: "Acres"},
},
},
{
ResourceType: schema.UserPrincipal,
Name: "theme",
Default: "light",
// No InputOptions - ValueDescription should be empty
},
}

t.Run("without scope returns global DB preferences plus defaults", func(t *testing.T) {
mockRepo := new(MockRepository)
svc := NewService(mockRepo, testTraits)
Expand Down Expand Up @@ -229,4 +250,107 @@ func TestLoadUserPreferences(t *testing.T) {
assert.Equal(t, ErrInvalidFilter, err)
mockRepo.AssertExpectations(t)
})

t.Run("ValueDescription is populated from trait InputOptions for DB preferences", func(t *testing.T) {
mockRepo := new(MockRepository)
svc := NewService(mockRepo, testTraitsWithOptions)

filter := Filter{UserID: userID}
dbPrefs := []Preference{
{ID: "1", Name: "unit_area", Value: "sq_ft", ResourceType: schema.UserPrincipal, ResourceID: userID},
}
mockRepo.On("List", ctx, filter).Return(dbPrefs, nil)

result, err := svc.LoadUserPreferences(ctx, filter)

assert.NoError(t, err)
assert.Len(t, result, 2) // unit_area from DB + theme default

resultMap := make(map[string]Preference)
for _, p := range result {
resultMap[p.Name] = p
}

// unit_area should have ValueDescription populated from InputOptions
assert.Equal(t, "sq_ft", resultMap["unit_area"].Value)
assert.Equal(t, "Square Feet", resultMap["unit_area"].ValueDescription)

// theme has no InputOptions, so ValueDescription should be empty
assert.Equal(t, "light", resultMap["theme"].Value)
assert.Equal(t, "", resultMap["theme"].ValueDescription)

mockRepo.AssertExpectations(t)
})

t.Run("ValueDescription is populated for default preferences", func(t *testing.T) {
mockRepo := new(MockRepository)
svc := NewService(mockRepo, testTraitsWithOptions)

filter := Filter{UserID: userID}
// No DB preferences - should get defaults
mockRepo.On("List", ctx, filter).Return([]Preference{}, nil)

result, err := svc.LoadUserPreferences(ctx, filter)

assert.NoError(t, err)
assert.Len(t, result, 2) // unit_area default + theme default

resultMap := make(map[string]Preference)
for _, p := range result {
resultMap[p.Name] = p
}

// unit_area default should have ValueDescription from InputOptions
assert.Equal(t, "sq_km", resultMap["unit_area"].Value)
assert.Equal(t, "Square Kilometers", resultMap["unit_area"].ValueDescription)

mockRepo.AssertExpectations(t)
})
}

func TestCreate(t *testing.T) {
ctx := context.Background()
userID := "user-123"

testTraits := []Trait{
{
ResourceType: schema.UserPrincipal,
Name: "unit_area",
Input: TraitInputSelect,
Default: "sq_km",
InputOptions: []InputHintOption{
{Name: "sq_km", Description: "Square Kilometers"},
{Name: "sq_ft", Description: "Square Feet"},
{Name: "acres", Description: "Acres"},
},
},
}

t.Run("Create populates ValueDescription from InputOptions", func(t *testing.T) {
mockRepo := new(MockRepository)
svc := NewService(mockRepo, testTraits)

pref := Preference{
Name: "unit_area",
Value: "sq_ft",
ResourceID: userID,
ResourceType: schema.UserPrincipal,
}

// Mock repo returns the preference as-is (like a real DB would)
mockRepo.On("Set", ctx, pref).Return(Preference{
ID: "pref-123",
Name: "unit_area",
Value: "sq_ft",
ResourceID: userID,
ResourceType: schema.UserPrincipal,
}, nil)

result, err := svc.Create(ctx, pref)

require.NoError(t, err)
assert.Equal(t, "sq_ft", result.Value)
assert.Equal(t, "Square Feet", result.ValueDescription) // Should be populated from trait
mockRepo.AssertExpectations(t)
})
}
15 changes: 15 additions & 0 deletions core/preference/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ func NewSelectValidator(inputHints string) *SelectValidator {
}
}

// NewSelectValidatorFromOptions creates a SelectValidator from InputHintOptions
// It extracts the Name field from each option as the allowed value
func NewSelectValidatorFromOptions(options []InputHintOption) *SelectValidator {
var allowed []string
for _, opt := range options {
trimmed := strings.TrimSpace(opt.Name)
if trimmed != "" {
allowed = append(allowed, trimmed)
}
}
return &SelectValidator{
allowedValues: allowed,
}
}

func (v *SelectValidator) Validate(value string) bool {
// If no allowed values are configured, accept any non-empty value
if len(v.allowedValues) == 0 {
Expand Down
28 changes: 19 additions & 9 deletions internal/api/v1beta1connect/preferences.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,15 +265,16 @@ func (h *ConnectHandler) ListPlatformPreferences(ctx context.Context) (map[strin

func transformPreferenceToPB(pref preference.Preference) *frontierv1beta1.Preference {
return &frontierv1beta1.Preference{
Id: pref.ID,
Name: pref.Name,
Value: pref.Value,
ResourceId: pref.ResourceID,
ResourceType: pref.ResourceType,
ScopeType: pref.ScopeType,
ScopeId: pref.ScopeID,
CreatedAt: timestamppb.New(pref.CreatedAt),
UpdatedAt: timestamppb.New(pref.UpdatedAt),
Id: pref.ID,
Name: pref.Name,
Value: pref.Value,
ValueDescription: pref.ValueDescription,
ResourceId: pref.ResourceID,
ResourceType: pref.ResourceType,
ScopeType: pref.ScopeType,
ScopeId: pref.ScopeID,
CreatedAt: timestamppb.New(pref.CreatedAt),
UpdatedAt: timestamppb.New(pref.UpdatedAt),
}
}

Expand All @@ -290,6 +291,15 @@ func transformPreferenceTraitToPB(pref preference.Trait) *frontierv1beta1.Prefer
InputHints: pref.InputHints,
Default: pref.Default,
}

// Convert InputOptions to proto
for _, opt := range pref.InputOptions {
pbTrait.InputOptions = append(pbTrait.InputOptions, &frontierv1beta1.InputHintOption{
Name: opt.Name,
Description: opt.Description,
})
}

switch pref.Input {
case preference.TraitInputText:
pbTrait.InputType = frontierv1beta1.PreferenceTrait_INPUT_TYPE_TEXT
Expand Down
21 changes: 21 additions & 0 deletions proto/apidocs.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13324,6 +13324,16 @@ definitions:
trialed:
type: boolean
title: Has the billing account trialed the plan
v1beta1InputHintOption:
type: object
properties:
name:
type: string
title: Machine-readable identifier (e.g., "sq_km", "megagram")
description:
type: string
title: User-friendly display description (e.g., "Square Kilometers", "Megagram (Mg)")
title: InputHintOption represents a selectable option with a machine-readable name and user-friendly description
v1beta1Invitation:
type: object
properties:
Expand Down Expand Up @@ -14416,6 +14426,11 @@ definitions:
title: |-
scope_id is the identifier of the scope context (e.g., organization ID)
Only applicable when scope_type is set
value_description:
type: string
title: |-
value_description is the human-readable display description for the value
Populated from InputHintOption.description when the trait has input_options configured
created_at:
type: string
format: date-time
Expand Down Expand Up @@ -14468,6 +14483,12 @@ definitions:
type: string
input_type:
$ref: '#/definitions/PreferenceTraitInputType'
input_options:
type: array
items:
type: object
$ref: '#/definitions/v1beta1InputHintOption'
title: Structured input options with name and description for select/combobox/multiselect inputs
title: |-
PreferenceTrait is a trait that can be used to add preferences to a resource
it explains what preferences are available for a resource
Expand Down
Loading
Loading