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

@@ -103,12 +103,13 @@ for _, f := range form.Fields {
| `mlmarkdown` | A markdown field with one editor per locale; see [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
| `richeditor` | A WYSIWYG HTML editor for a host text column, sanitized on every save; see [Rich text](#rich-text). |
| `repeater` | A list of rows, each with its own small form, stored as JSON in one column; see [Repeater](#repeater). |
| `money` | An exact decimal amount with a currency suffix, never a float and never rounded; see [Money](#money). |
The WinterCMS widgets that are not in this list (the code editor, the color picker, the media finder, the tag list and the others) are not provided. A field with one of those types stops the start-up, unless a plugin registers that alias as its own field type: plugins add field types with a custom element and Go code that decides what is stored; see [Plugin field types](field-types.md).
### Field options
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields, `preset` on text and mltext fields and `dependsOn` on dropdowns (see [Dependent dropdowns](#dependent-dropdowns)). WinterCMS keys outside this set, such as `readOnly`, `disabled` or `trigger`, are refused.
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields, `preset` on text and mltext fields, `dependsOn` on dropdowns (see [Dependent dropdowns](#dependent-dropdowns)) and `currency`, `currencyFrom`, `scale` and `max` on money fields (see [Money](#money)). WinterCMS keys outside this set, such as `readOnly`, `disabled` or `trigger`, are refused.
`context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted, and `context: preview` shows a field only on the [preview screen](#preview-screen). The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.
@@ -458,6 +459,51 @@ In the admin SPA the field is a TipTap editor with a toolbar for bold, italic, u
- **After a save** the notices appear under the field and the editor shows the sanitized value the server stored.
- If the editor code cannot be loaded, the field falls back to the HTML view, so the record can still be edited and saved.
## Money
`type: money` edits an exact decimal amount. The value travels as a decimal string with a dot and at most `scale` fraction digits (`"39.8400"`, `"85.4193"`, `"-12.5"`), or `null`. Neither the server nor the admin SPA ever parses it into a float: the string is checked as text, handed to the model column unchanged, and formatted for reading with `Intl.NumberFormat` from the string itself. A value is never rounded or truncated: one with too many decimals is refused.
The column is a `string` (or `*string`), or a struct type whose pointer implements `sql.Scanner` and that implements `driver.Valuer`, such as a plugin's scale-4 amount type over a `numeric(12,4)` column. Any other column type stops the start-up naming the field.
```yaml
fields:
currency:
label: acme.shop::lang.orders.currency
type: text
total:
label: acme.shop::lang.orders.total
type: money
currencyFrom: currency
max: "99999999.9999"
fee:
label: acme.shop::lang.orders.fee
type: money
currency: EUR
scale: 2
```
The field takes the generic keys plus:
| Key | Meaning |
|-----|---------|
| `currency` | A literal three-letter upper-case ISO 4217 code shown as the input's suffix. |
| `currencyFrom` | The name of a string column of the same model that holds the code; the suffix follows that field's current value on the form. Exactly one of `currency` and `currencyFrom` is required: both, or neither, stop the start-up naming the field. |
| `scale` | The most fraction digits a value may carry: 2, 3 or 4 (the default). |
| `max` | The column ceiling as a decimal string with at most `scale` places, for example `"999999.9999"` for a `numeric(10,4)` column. Optional. |
`options`, `emptyOption`, `nameFrom`, `dependsOn` and `preset` are refused on a money field, and the four money keys are refused on every other type.
A save checks every submitted money value before the transaction and answers a 422 on the field with one of three `backend::lang.money` messages:
| Value | Answer |
|-------|--------|
| Not a string, or a string that is not an optional minus, digits and at most one dot followed by digits (`"1,5"`, `"1e3"`, `"1 000"`, a JSON number) | `money.invalid` |
| More fraction digits than `scale` (`"12.34567"` at scale 4) | `money.too_precise`, with `:scale` |
| Above `max` in absolute magnitude, compared as scaled digit strings | `money.too_large`, with `:max` |
`null` is accepted and stored as NULL (or the column type's empty value); `required: true` refuses it through the usual rule. A value the administrator did not edit is sent back as it was loaded, so opening and saving a form with a stored `85.4193` writes `85.4193` again, never `85.42`. The same checks run on settings forms and relation forms.
In the admin SPA the field is a text input in decimal mode, right-aligned in tabular numerals, with the ISO code as a suffix (omitted while a `currencyFrom` sibling is still empty). A loaded value is shown with its trailing zeros trimmed down to two decimals and the locale's decimal separator; typing accepts a comma or a dot and ignores spaces, and emits the canonical dot string. The error line shows on blur for a malformed or too-precise text, and the text is still sent as typed so the server's answer is the gate. Read-only and preview show the locale-formatted amount with the code (`PLN 39.84`, `39,84 PLN`) or an em dash when empty.
## What a save may write
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). Declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are the exception: they are lifted first, as [Markdown and multilingual fields](#markdown-and-multilingual-fields). A [repeater](#repeater)'s list of rows is the other exception: it is decoded row by row before the projection. The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. A controller implementing `pact.FormRules` supplies its own rule set per operation, which replaces the model's rules for admin saves.