feat(18-01): add the cabana money field (task 1)

- type: money on fields.yaml with currency or currencyFrom, scale (2..4,
  default 4) and max; boot fails naming the field on two sources, none,
  a bad scale or max, a column that is not a string or a Scanner/Valuer,
  or a currencyFrom that is not a string column
- a save answers 422 money.invalid, money.too_precise or money.too_large
  on the field and fills the accepted decimal string unchanged; the same
  check runs on controller, settings and relation child saves
- backend::lang money.* phrases in en and pl
- admin SPA: money.ts (parseMoneyInput, trimMoneyDisplay, formatMoney over
  Intl with the string itself), MoneyField.vue with the currency suffix,
  blur validation and the read-only box, preview through the control,
  the field scope on PreviewView; regenerated OpenAPI and TS types,
  rebuilt modules/boardwalk/dist
- acme.payments fixture with TestMoneyFieldCompile and the Docker
  TestMoneyFieldSmoke; forms.md Money section and cabana README
This commit is contained in:
Jakub Zych
2026-10-13 11:15:05 +02:00
parent 99ccae232c
commit 20385e53ee
33 changed files with 1388 additions and 26 deletions

View File

@@ -12,6 +12,7 @@ import (
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -33,7 +34,12 @@ type SettingsResult struct {
}
// SettingsService reads and transactionally updates compiled singleton rows.
type SettingsService struct{ DB *gorm.DB }
// Tr localizes the 422 messages of the settings Put (a money value outside
// its field's scale or max); nil keeps the phrase keys.
type SettingsService struct {
DB *gorm.DB
Tr *phrasebook.Translator
}
// compileSettingLink registers a settings entry that opens an admin
// controller. It declares no singleton form and no model; the controller ID
@@ -115,6 +121,11 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS
return nil, fmt.Errorf("cabana: setting %s: %w", item.Code, err)
}
}
if field.Type == moneyType {
if err := checkMoneyType(model, field); err != nil {
return nil, fmt.Errorf("cabana: setting %s: %w", item.Code, err)
}
}
writable = append(writable, WritableField{Name: field.Name, FillKey: field.Name})
}
return &CompiledSetting{PluginID: pluginID, Item: item, Form: form, Writable: writable}, nil
@@ -151,6 +162,9 @@ func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body
if err := requireSettingInputs(setting, body); err != nil {
return SettingsResult{}, err
}
if details := moneyDetails(ctx, s.Tr, setting.Form.Fields, body, nil); len(details) > 0 {
return SettingsResult{}, &ValidationError{Details: details}
}
var result SettingsResult
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
model, err := newSettingModel(setting)