feat(18-01): money list column, danger record actions, preview Edit rule and table kit (task 2)
- columns.yaml type: money with currency or currencyFrom (exactly one), never searchable, not sortable by default; every list row carries the currencyFrom column's value under its own key; the schema serves currency and currencyFrom on the column - pact.AdminRecordAction.Danger, served as danger on cabana.RecordAction (absent when false); RecordActions.vue passes it to the confirm dialog - CellValue money kind through formatMoney with the row's code, the em dash when empty and the amount alone without a code; DataTable aligns money headers and cells to the right - PreviewView renders Edit only when a registered value field allows update (F6) - main.css .summer-table* and .summer-stat__note kit classes (F7) over --c-* variables; cabana README kit rows - fixture record actions void (danger) and resend; TestMoneyListCompile, TestMoneyListSmoke and TestRecordActionDangerSmoke; regenerated OpenAPI and TS types; rebuilt dist; lists-and-filters and admin-controllers docs
This commit is contained in:
File diff suppressed because one or more lines are too long
11
modules/boardwalk/dist/assets/index-B8hF3DCG.js
vendored
Normal file
11
modules/boardwalk/dist/assets/index-B8hF3DCG.js
vendored
Normal file
File diff suppressed because one or more lines are too long
11
modules/boardwalk/dist/assets/index-BPd1k6k8.js
vendored
11
modules/boardwalk/dist/assets/index-BPd1k6k8.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-c6AgbZf1.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-c6AgbZf1.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -7,8 +7,8 @@
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-BPd1k6k8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BxyrlyKr.css">
|
||||
<script type="module" crossorigin src="./assets/index-B8hF3DCG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-c6AgbZf1.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -46,9 +46,10 @@ Use cases:
|
||||
- Preset fields: `preset` on a `type: text` or `type: mltext` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text or mltext field of the same form on the create screen until the administrator edits it, per locale for an mltext pair. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
|
||||
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
||||
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
|
||||
- Money fields: a `type: money` field in `fields.yaml` edits an exact decimal amount that travels as a decimal string with a dot and at most `scale` (2 to 4, default 4) fraction digits, or null; the server never parses it into a float, never rounds it and never truncates it, and fills the accepted string into a string column or a type with its own `sql.Scanner` and `driver.Valuer` unchanged. A field declares exactly one of `currency` (an ISO 4217 code) or `currencyFrom` (a sibling string column holding the code) and may declare `max` (the column ceiling); two sources, none, a scale outside 2..4, a max beyond the scale, a column of another type or a currencyFrom that is not a string column fails boot naming the field. A save answers 422 on the field with `backend::lang.money.invalid`, `money.too_precise` (`:scale`) or `money.too_large` (`:max`, compared as scaled digit strings), on controller forms, settings forms and relation forms alike. The schema serves `currency`, `currencyFrom`, `scale` and `max` on the field.
|
||||
- Money fields: a `type: money` field in `fields.yaml` edits an exact decimal amount that travels as a decimal string with a dot and at most `scale` (2 to 4, default 4) fraction digits, or null; the server never parses it into a float, never rounds it and never truncates it, and fills the accepted string into a string column or a type with its own `sql.Scanner` and `driver.Valuer` unchanged. A field declares exactly one of `currency` (an ISO 4217 code) or `currencyFrom` (a sibling string column holding the code) and may declare `max` (the column ceiling); two sources, none, a scale outside 2..4, a max beyond the scale, a column of another type or a currencyFrom that is not a string column fails boot naming the field. A save answers 422 on the field with `backend::lang.money.invalid`, `money.too_precise` (`:scale`) or `money.too_large` (`:max`, compared as scaled digit strings), on controller forms, settings forms and relation forms alike. The schema serves `currency`, `currencyFrom`, `scale` and `max` on the field. A list column `type: money` takes the same `currency` or `currencyFrom` key, is never searchable and not sortable by default, and every list row carries the `currencyFrom` column's value under its own key even when it is not a declared column; the admin right-aligns the locale-formatted amount with its ISO code.
|
||||
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
|
||||
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
|
||||
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
|
||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
|
||||
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a composite `backend` guard (a guard another plugin already registered under that name fails `cabana.Activate`) that accepts an `Authorization: Bearer` personal API token (`cabana.APITokenPrefix` / `g15_…`) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry `X-Requested-With: XMLHttpRequest`, which blocks cross-site request forgery.
|
||||
@@ -143,6 +144,12 @@ The admin SPA ships a small set of stable CSS classes that partial templates may
|
||||
| `summer-stat` | One item of the strip: the value is shown above the label while `<dt>` stays first in the DOM. |
|
||||
| `summer-stat__label` | The item label: 13px, muted, wraps. |
|
||||
| `summer-stat__value` | The item value: 20px, weight 600, tabular numbers. |
|
||||
| `summer-stat__note` | A note under a stat value: 13px, weight 400, muted, 4px above. Place it inside `summer-stat__value` after the value text, because `summer-stat` is column-reverse. |
|
||||
| `summer-table-wrap` | A horizontally scrolling wrapper for a wide table inside a card (`overflow-x: auto`); the page never scrolls sideways. |
|
||||
| `summer-table` | A full-width collapsed table in 14px/1.5 body text. Headers are 44px high, 12px, weight 600 and muted with a bottom border; cells pad 12px 14px, align to the top, carry a bottom border and wrap long words; the first and last column drop their outer padding because the card already pads. |
|
||||
| `summer-table__num` | A numeric cell or header: end-aligned, tabular numerals, never wraps. |
|
||||
| `summer-table__sub` | A muted 13px line under a cell's main text (a unit price under an amount). |
|
||||
| `summer-table tfoot td` | A totals row: 16px top padding, no bottom border, weight 600 (no class needed). |
|
||||
| `summer-callout` | A status hint above a preview screen: a borderless block with a 12px radius and 14px 18px padding; long text wraps. |
|
||||
| `summer-callout--warning`, `summer-callout--danger` | The callout's tone: the selection tint with body text, or the soft danger background with danger text. |
|
||||
| `summer-callout__title` | The callout's first line, weight 600. |
|
||||
|
||||
@@ -490,6 +490,7 @@ func (s CRUDService) offeredRecordActions(ctx context.Context, cc *CompiledContr
|
||||
Name: name,
|
||||
Label: translateKey(ctx, s.tr, action.Label),
|
||||
Confirm: translateKey(ctx, s.tr, action.Confirm),
|
||||
Danger: action.Danger,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
@@ -325,3 +326,54 @@ func checkMoneyValues(ctx context.Context, tr *phrasebook.Translator, cc *Compil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkMoneyColumn gates the money keys of a columns.yaml entry: on a
|
||||
// `type: money` column exactly one of currency (an ISO 4217 code) or
|
||||
// currencyFrom (a string column of the model) is required, the column is
|
||||
// never searchable, takes no relation, and when the controller names its
|
||||
// model (pact.AdminRecordSource) the column must be a string or a
|
||||
// sql.Scanner and driver.Valuer type; on any other type the two keys are
|
||||
// refused.
|
||||
func checkMoneyColumn(typ, key string, spec columnDocument, ctl pact.AdminController) error {
|
||||
if typ != moneyType {
|
||||
if spec.Currency != "" {
|
||||
return fmt.Errorf("currency is only valid on type: money")
|
||||
}
|
||||
if spec.CurrencyFrom != "" {
|
||||
return fmt.Errorf("currencyFrom is only valid on type: money")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if spec.Relation != "" || spec.Select != "" {
|
||||
return fmt.Errorf("a money column is read from the model, not through a relation")
|
||||
}
|
||||
if spec.Searchable {
|
||||
return fmt.Errorf("a money column is never searchable")
|
||||
}
|
||||
if spec.Currency != "" && !isoCurrencyCode(spec.Currency) {
|
||||
return fmt.Errorf("currency %q must be a three-letter upper-case ISO 4217 code", spec.Currency)
|
||||
}
|
||||
if spec.CurrencyFrom != "" && !identifier(spec.CurrencyFrom) {
|
||||
return fmt.Errorf("currencyFrom %q is not a column name", spec.CurrencyFrom)
|
||||
}
|
||||
if (spec.Currency == "") == (spec.CurrencyFrom == "") {
|
||||
return fmt.Errorf("money needs exactly one of currency or currencyFrom")
|
||||
}
|
||||
src, ok := ctl.(pact.AdminRecordSource)
|
||||
if !ok || src == nil {
|
||||
return nil
|
||||
}
|
||||
model := src.NewRecord()
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
if sf, ok := structFieldByColumn(model, key); ok && !moneyColumnType(sf.Type) {
|
||||
return fmt.Errorf("money needs a string column or a sql.Scanner and driver.Valuer type, found %s", sf.Type)
|
||||
}
|
||||
if spec.CurrencyFrom != "" {
|
||||
if cf, ok := structFieldByColumn(model, spec.CurrencyFrom); !ok || !stringColumnType(cf.Type) {
|
||||
return fmt.Errorf("currencyFrom %s is not a string column of the model", spec.CurrencyFrom)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1166,6 +1166,20 @@ func projectRowCtx(ctx context.Context, row any, controller pact.AdminController
|
||||
}
|
||||
out[col.Key] = field.Interface()
|
||||
}
|
||||
// A money column's currencyFrom value rides along under its own key even
|
||||
// when it is not a declared column, so the admin formats each row's
|
||||
// amount with that row's code (D-07).
|
||||
for _, col := range cols {
|
||||
if col.CurrencyFrom == "" {
|
||||
continue
|
||||
}
|
||||
if _, present := out[col.CurrencyFrom]; present {
|
||||
continue
|
||||
}
|
||||
if code := fieldByColumn(v, col.CurrencyFrom); code.IsValid() && code.CanInterface() {
|
||||
out[col.CurrencyFrom] = code.Interface()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
const listSearchTerm = "search"
|
||||
|
||||
var listColumnTypes = map[string]struct{}{
|
||||
"text": {}, "datetime": {}, "switch": {}, "date": {}, "time": {},
|
||||
"text": {}, "datetime": {}, "switch": {}, "date": {}, "time": {}, moneyType: {},
|
||||
}
|
||||
|
||||
type listDocument struct {
|
||||
@@ -68,6 +68,10 @@ type columnDocument struct {
|
||||
Relation string `yaml:"relation"`
|
||||
Select string `yaml:"select"`
|
||||
Invisible bool `yaml:"invisible"`
|
||||
// Currency and CurrencyFrom are the `type: money` column's currency
|
||||
// source (D-07), exactly one of them.
|
||||
Currency string `yaml:"currency"`
|
||||
CurrencyFrom string `yaml:"currencyFrom"`
|
||||
}
|
||||
|
||||
// CompileListSchema compiles config_list.yaml and the columns.yaml it names.
|
||||
@@ -267,20 +271,27 @@ func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPa
|
||||
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s is not on the model", key))
|
||||
}
|
||||
}
|
||||
sortable := spec.Relation == ""
|
||||
if err := checkMoneyColumn(typ, key, spec, ctl); err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s: %w", key, err))
|
||||
}
|
||||
// A money column is not sortable unless the file says so (PHP:
|
||||
// sortable: false), and never searchable.
|
||||
sortable := spec.Relation == "" && typ != moneyType
|
||||
if spec.Sortable != nil {
|
||||
sortable = *spec.Sortable
|
||||
}
|
||||
compiled = append(compiled, ListColumn{
|
||||
Key: key,
|
||||
Label: spec.Label,
|
||||
Searchable: spec.Searchable,
|
||||
Sortable: sortable,
|
||||
Type: typ,
|
||||
Relation: spec.Relation,
|
||||
Select: spec.Select,
|
||||
Invisible: spec.Invisible,
|
||||
fieldType: pluginType,
|
||||
Key: key,
|
||||
Label: spec.Label,
|
||||
Searchable: spec.Searchable,
|
||||
Sortable: sortable,
|
||||
Type: typ,
|
||||
Relation: spec.Relation,
|
||||
Select: spec.Select,
|
||||
Invisible: spec.Invisible,
|
||||
Currency: spec.Currency,
|
||||
CurrencyFrom: spec.CurrencyFrom,
|
||||
fieldType: pluginType,
|
||||
})
|
||||
}
|
||||
return compiled, nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -115,6 +116,20 @@ func (paymentsInvoices) ConfigDir() string { return "controllers/inv
|
||||
func (paymentsInvoices) RequiredPermissions() []string { return []string{"acme.payments.access"} }
|
||||
func (paymentsInvoices) NewRecord() any { return &paymentsInvoice{} }
|
||||
|
||||
// AdminRecordActions registers a danger action (void) and an ordinary one
|
||||
// (resend) for the F5 flag.
|
||||
func (paymentsInvoices) AdminRecordActions() []pact.AdminRecordAction {
|
||||
run := func(context.Context, pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||
return pact.AdminRecordActionResult{}, nil
|
||||
}
|
||||
return []pact.AdminRecordAction{
|
||||
{Name: "void", Label: "Void invoice", Confirm: "Void this invoice? This cannot be undone.", Danger: true, Run: run},
|
||||
{Name: "resend", Label: "Send again", Run: run},
|
||||
}
|
||||
}
|
||||
|
||||
var _ pact.HasAdminRecordActions = paymentsInvoices{}
|
||||
|
||||
// paymentsAssemble boots plugins on the test database with the fixture's
|
||||
// config: the assembled admin handler, or the boot error.
|
||||
func paymentsAssemble(t *testing.T, gdb *gorm.DB, plugins []party.Plugin) (http.Handler, error) {
|
||||
@@ -372,8 +387,10 @@ func TestMoneyFieldSmoke(t *testing.T) {
|
||||
|
||||
// A boot whose money column cannot hold the string fails naming the field.
|
||||
t.Run("column type", func(t *testing.T) {
|
||||
// The list compiles before the form, so the columns.yaml gate names
|
||||
// the column; both gates carry the same message naming total.
|
||||
_, err := paymentsAssemble(t, e.db, []party.Plugin{moneyBadColumnPlugin{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "field total") {
|
||||
if err == nil || !strings.Contains(err.Error(), "total: money needs a string column") {
|
||||
t.Fatalf("boot with an int money column: %v", err)
|
||||
}
|
||||
})
|
||||
@@ -401,3 +418,164 @@ func (moneyBadColumnPlugin) AdminControllers() []pact.AdminController {
|
||||
type moneyBadInvoices struct{ paymentsInvoices }
|
||||
|
||||
func (moneyBadInvoices) NewRecord() any { return &moneyBadInvoice{} }
|
||||
|
||||
// moneyListFS is a one-controller plugin tree whose invoice columns.yaml is
|
||||
// the given text, for compile-only checks of the money column keys.
|
||||
func moneyListFS(columns string) fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"controllers/invoices/config_list.yaml": {Data: []byte("list: ~/plugins/acme/payments/models/invoice/columns.yaml\nmodelClass: Invoice\ntitle: Invoices\nrecordsPerPage: 20\n")},
|
||||
"models/invoice/columns.yaml": {Data: []byte(columns)},
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoneyListCompile checks the money column's boot gates without a
|
||||
// database: type money with exactly one currency source, never searchable,
|
||||
// not sortable by default, the keys refused on other types, and the model
|
||||
// column checks.
|
||||
func TestMoneyListCompile(t *testing.T) {
|
||||
schema, err := cabana.CompileList("acme.payments", paymentsInvoices{}, moneyListFS(`columns:
|
||||
number:
|
||||
label: Number
|
||||
total:
|
||||
label: Total
|
||||
type: money
|
||||
currencyFrom: currency
|
||||
fee:
|
||||
label: Fee
|
||||
type: money
|
||||
currency: EUR
|
||||
sortable: true
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byKey := map[string]cabana.ListColumn{}
|
||||
for _, col := range schema.Columns {
|
||||
byKey[col.Key] = col
|
||||
}
|
||||
if total := byKey["total"]; total.Type != "money" || total.CurrencyFrom != "currency" || total.Currency != "" || total.Sortable || total.Searchable {
|
||||
t.Fatalf("total = %+v", total)
|
||||
}
|
||||
if fee := byKey["fee"]; fee.Type != "money" || fee.Currency != "EUR" || !fee.Sortable {
|
||||
t.Fatalf("fee = %+v", fee)
|
||||
}
|
||||
raw, err := json.Marshal(byKey["total"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"currencyFrom":"currency"`) || strings.Contains(string(raw), `"currency":`) {
|
||||
t.Fatalf("column JSON = %s", raw)
|
||||
}
|
||||
refused := map[string]string{
|
||||
"searchable": "columns:\n total:\n type: money\n currency: PLN\n searchable: true\n",
|
||||
"both": "columns:\n total:\n type: money\n currency: PLN\n currencyFrom: currency\n",
|
||||
"none": "columns:\n total:\n type: money\n",
|
||||
"code": "columns:\n total:\n type: money\n currency: zl\n",
|
||||
"sibling": "columns:\n total:\n type: money\n currencyFrom: id\n",
|
||||
"on text": "columns:\n number:\n currency: PLN\n",
|
||||
"int column": "columns:\n id:\n type: money\n currency: PLN\n",
|
||||
}
|
||||
for name, columns := range refused {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := cabana.CompileList("acme.payments", paymentsInvoices{}, moneyListFS(columns)); err == nil {
|
||||
t.Fatal("compiled")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoneyListSmoke checks the list projection on Postgres: a money column
|
||||
// is served with its keys, each row carries the currencyFrom column's value
|
||||
// although it is not a declared column, and the column is neither
|
||||
// searchable nor sortable by default.
|
||||
func TestMoneyListSmoke(t *testing.T) {
|
||||
e := newPaymentsEnv(t)
|
||||
for _, body := range []map[string]any{
|
||||
{"number": "L-1", "currency": "PLN", "total": "85.4193", "fee": "1.50"},
|
||||
{"number": "L-2", "currency": "EUR", "total": "5.0000"},
|
||||
} {
|
||||
if rec := e.send(t, http.MethodPost, "/acme/payments/invoices", body, true); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
rec := e.send(t, http.MethodGet, "/acme/payments/invoices/schema/list", nil, true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("schema status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var schema cabana.Envelope[cabana.ListSchema]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys := map[string]cabana.ListColumn{}
|
||||
for _, col := range schema.Data.Columns {
|
||||
keys[col.Key] = col
|
||||
}
|
||||
if _, declared := keys["currency"]; declared {
|
||||
t.Fatalf("currency is not a declared column: %+v", schema.Data.Columns)
|
||||
}
|
||||
if total := keys["total"]; total.Type != "money" || total.CurrencyFrom != "currency" || total.Sortable || total.Searchable {
|
||||
t.Fatalf("total column = %+v", total)
|
||||
}
|
||||
if fee := keys["fee"]; fee.Type != "money" || fee.Currency != "EUR" {
|
||||
t.Fatalf("fee column = %+v", fee)
|
||||
}
|
||||
rec = e.send(t, http.MethodGet, "/acme/payments/invoices?sort=number&dir=asc", nil, true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var list cabana.ListEnvelope[[]cabana.AdminRecord]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list.Data) != 2 {
|
||||
t.Fatalf("rows = %+v", list.Data)
|
||||
}
|
||||
if row := list.Data[0]; row["total"] != "85.4193" || row["currency"] != "PLN" || row["fee"] != "1.5000" {
|
||||
t.Fatalf("row 1 = %+v (want the amount strings and the currencyFrom value)", row)
|
||||
}
|
||||
if row := list.Data[1]; row["total"] != "5.0000" || row["currency"] != "EUR" || row["fee"] != nil {
|
||||
t.Fatalf("row 2 = %+v", row)
|
||||
}
|
||||
if rec := e.send(t, http.MethodGet, "/acme/payments/invoices?sort=total&dir=asc", nil, true); rec.Code == http.StatusOK {
|
||||
t.Fatalf("sort by a money column was accepted: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordActionDangerSmoke checks that the record response carries the
|
||||
// F5 danger flag for a danger action and omits it for an ordinary one.
|
||||
func TestRecordActionDangerSmoke(t *testing.T) {
|
||||
e := newPaymentsEnv(t)
|
||||
rec := e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "D-1", "currency": "PLN", "total": "1.00"}, true)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
id := dataID(t, rec.Body.Bytes())
|
||||
show := e.send(t, http.MethodGet, fmt.Sprintf("/acme/payments/invoices/%d", id), nil, true)
|
||||
if show.Code != http.StatusOK {
|
||||
t.Fatalf("show status=%d body=%s", show.Code, show.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Meta struct {
|
||||
Actions []json.RawMessage `json:"actions"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(show.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Meta.Actions) != 2 {
|
||||
t.Fatalf("actions = %s", show.Body.String())
|
||||
}
|
||||
var void, resend cabana.RecordAction
|
||||
if err := json.Unmarshal(out.Meta.Actions[0], &void); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(out.Meta.Actions[1], &resend); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if void.Name != "void" || !void.Danger || resend.Name != "resend" || resend.Danger {
|
||||
t.Fatalf("actions = %+v %+v", void, resend)
|
||||
}
|
||||
if !strings.Contains(string(out.Meta.Actions[0]), `"danger":true`) || strings.Contains(string(out.Meta.Actions[1]), "danger") {
|
||||
t.Fatalf("raw actions = %s %s (danger true on void, the key absent on resend)", out.Meta.Actions[0], out.Meta.Actions[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ type ListColumn struct {
|
||||
// not shown: the admin renders no header or cell for it and list rows do
|
||||
// not carry its value (columns.yaml invisible).
|
||||
Invisible bool `json:"invisible,omitempty"`
|
||||
// Currency is the ISO 4217 code of a `type: money` column; CurrencyFrom
|
||||
// names the model column that holds each row's code instead. Every list
|
||||
// row carries the CurrencyFrom column's value under its own key, so the
|
||||
// admin never guesses a currency (D-07).
|
||||
Currency string `json:"currency,omitempty"`
|
||||
CurrencyFrom string `json:"currencyFrom,omitempty"`
|
||||
|
||||
// fieldType is the plugin field type a column names as its type; the
|
||||
// list then shows the type's ListValue.
|
||||
@@ -224,6 +230,9 @@ type RecordAction struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Confirm string `json:"confirm,omitempty"`
|
||||
// Danger marks an action whose confirm dialog takes the danger variant
|
||||
// (pact.AdminRecordAction.Danger); an ordinary action omits the key.
|
||||
Danger bool `json:"danger,omitempty"`
|
||||
}
|
||||
|
||||
// FormRedirects are config_form.yaml defaultRedirect, create.* and update.*.
|
||||
|
||||
@@ -2,3 +2,5 @@ name: New invoice
|
||||
form: ~/plugins/acme/payments/models/invoice/fields.yaml
|
||||
modelClass: Invoice
|
||||
defaultRedirect: acme/payments/invoices
|
||||
preview: {}
|
||||
recordActions: [void, resend]
|
||||
|
||||
@@ -2,5 +2,11 @@ columns:
|
||||
number:
|
||||
label: Number
|
||||
searchable: true
|
||||
currency:
|
||||
label: Currency
|
||||
total:
|
||||
label: Total
|
||||
type: money
|
||||
currencyFrom: currency
|
||||
fee:
|
||||
label: Fee
|
||||
type: money
|
||||
currency: EUR
|
||||
|
||||
@@ -133,7 +133,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
| `pact.AdminBulkActionInput` | What a bulk action receives: `Records`, the selected records loaded and row-locked through the list scope. |
|
||||
| `pact.AdminBulkActionResult` | What a bulk action returns: an optional message for the toast and `Affected`, the number of records it changed. |
|
||||
| `pact.HasAdminBulkActions` | Registers a controller's bulk actions for the `bulkActions` list of `config_list.yaml`. |
|
||||
| `pact.AdminRecordAction` | One named record action: name, label, optional confirm text, extra permissions, the optional `Applies` rule and the Go `Run` function. |
|
||||
| `pact.AdminRecordAction` | One named record action: name, label, optional confirm text, extra permissions, the `Danger` flag (the admin confirms it in the danger variant), the optional `Applies` rule and the Go `Run` function. |
|
||||
| `pact.AdminRecordActionInput` | What a record action receives: `RecordID` and `Record`, the record loaded and row-locked through the form scope. |
|
||||
| `pact.AdminRecordActionResult` | What a record action returns: an optional message for the toast. |
|
||||
| `pact.HasAdminRecordActions` | Registers a controller's record actions for the `recordActions` list of `config_form.yaml`. |
|
||||
|
||||
@@ -327,14 +327,19 @@ type HasAdminBulkActions interface {
|
||||
// RequiredPermissions. Applies reports whether the action applies to the
|
||||
// record in its current state: a nil Applies means the action always applies.
|
||||
// Applies must be a pure read, because it also runs when a record is shown, to
|
||||
// decide which actions the admin is offered.
|
||||
// decide which actions the admin is offered. Danger only changes how the
|
||||
// admin asks for confirmation; permissions and Applies are unchanged.
|
||||
type AdminRecordAction struct {
|
||||
Name string
|
||||
Label string
|
||||
Confirm string
|
||||
Permissions []string
|
||||
Applies func(ctx context.Context, record any) (bool, error) `json:"-"`
|
||||
Run func(ctx context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) `json:"-"`
|
||||
// Danger marks an action that moves money out or cannot be undone: the
|
||||
// admin's confirm dialog then uses its danger variant (serialized as
|
||||
// `danger` in the record response). The footer button itself is unchanged.
|
||||
Danger bool
|
||||
Applies func(ctx context.Context, record any) (bool, error) `json:"-"`
|
||||
Run func(ctx context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) `json:"-"`
|
||||
}
|
||||
|
||||
// AdminRecordActionInput is what the framework hands an AdminRecordAction.
|
||||
|
||||
Reference in New Issue
Block a user