feat(12.2-02): add the datepicker field with server-side bounds and date list columns
- type: datepicker compiles the D-20 keys; format maps to displayFormat with WinterCMS's momentFormat table - boot fails when the mode does not match the column's Go type (time.Time, lagoon.Date, lagoon.TimeOfDay) - datepicker is a writable scalar field; minDate and maxDate are rechecked on save - columns.yaml accepts type: date and type: time; Scanner/Valuer structs are columns, not relations - conformance fixture carries date and datetime fields; README, forms and lists docs
This commit is contained in:
@@ -18,6 +18,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
||||
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
|
||||
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
|
||||
- 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, and a struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
|
||||
- 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` above `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 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.
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`.
|
||||
- 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: 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).
|
||||
@@ -194,6 +195,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `backend.uri` | `/backend` | Admin mount path. One or more lowercase path segments; boot fails on an invalid value. |
|
||||
| `backend.cookie_secure` | `true` | Set `false` to drop the cookie's Secure attribute for plain-HTTP development. Refused in the `production` environment. |
|
||||
| `app.url` | empty | Base URL used for the token issuer. |
|
||||
| `http.body_limits.upload_bytes` | none | Read from the HTTP configuration: caps the body of a file upload (together with the field's `maxFilesize` plus 64 KiB), and no fileupload field may declare a larger `maxFilesize`. Without it the cap is the field's limit, or 128 MiB. |
|
||||
| `http.body_limits.default_bytes` | none | Read from the HTTP configuration: caps the JSON bodies of the file caption and reorder routes (1 MiB when not set). |
|
||||
|
||||
The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs.
|
||||
|
||||
|
||||
@@ -92,6 +92,9 @@ type CompiledController struct {
|
||||
// files are the form's `type: fileupload` fields bound to the model's
|
||||
// attach.Relation, keyed by field name.
|
||||
files map[string]*compiledFile
|
||||
// dates are the form's `type: datepicker` fields after the Go type
|
||||
// check, keyed by field name.
|
||||
dates map[string]*compiledDate
|
||||
}
|
||||
|
||||
// Registry is the immutable controller map keyed by controller ID.
|
||||
|
||||
@@ -353,6 +353,12 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err != nil {
|
||||
return &CapabilityError{ControllerID: controllerID(cc)}
|
||||
}
|
||||
for field, extra := range dateBoundDetails(ctx, s.tr, cc, target, op) {
|
||||
if msgs == nil {
|
||||
msgs = map[string][]string{}
|
||||
}
|
||||
msgs[field] = append(msgs[field], extra...)
|
||||
}
|
||||
if len(msgs) > 0 {
|
||||
return &ValidationError{Details: validationDetails(msgs)}
|
||||
}
|
||||
@@ -812,7 +818,7 @@ func modelColumns(model any) map[string]struct{} {
|
||||
|
||||
func scalarFormField(typ string) bool {
|
||||
switch typ {
|
||||
case "text", "textarea", "number", "checkbox", "switch", "dropdown":
|
||||
case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
169
modules/cabana/datepicker_smoke_test.go
Normal file
169
modules/cabana/datepicker_smoke_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// datepickerFields is the gadget fields.yaml the conform controller's
|
||||
// contracts need, plus extra.
|
||||
func datepickerFields(extra string) string {
|
||||
return `fields:
|
||||
name:
|
||||
label: Name
|
||||
type: text
|
||||
group:
|
||||
label: Group
|
||||
type: relation
|
||||
nameFrom: title
|
||||
members:
|
||||
type: relation-manager
|
||||
relation: members
|
||||
context: [update]
|
||||
lookup:
|
||||
label: Lookup
|
||||
type: widget
|
||||
widget: acme-conform-lookup
|
||||
action: lookup
|
||||
fill: [name]
|
||||
summary:
|
||||
label: Summary
|
||||
type: partial
|
||||
path: summary
|
||||
` + extra
|
||||
}
|
||||
|
||||
// datepickerFS is the conform plugin tree with its gadget fields.yaml
|
||||
// replaced.
|
||||
func datepickerFS(fields string) fs.FS {
|
||||
base := conformFS().(fstest.MapFS)
|
||||
out := fstest.MapFS{}
|
||||
for name, file := range base {
|
||||
out[name] = file
|
||||
}
|
||||
out["models/gadget/fields.yaml"] = &fstest.MapFile{Data: []byte(fields)}
|
||||
return out
|
||||
}
|
||||
|
||||
type datepickerPlugin struct {
|
||||
conformPlugin
|
||||
fsys fs.FS
|
||||
}
|
||||
|
||||
func (p datepickerPlugin) AdminFS() fs.FS { return p.fsys }
|
||||
|
||||
// activateFields runs cabana.Activate with the conform plugin and a gadget
|
||||
// fields.yaml.
|
||||
func activateFields(t *testing.T, fields string) error {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-datepicker\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{datepickerPlugin{fsys: datepickerFS(fields)}})
|
||||
return err
|
||||
}
|
||||
|
||||
func TestDatepickerSmokeCompile(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
field string
|
||||
want string
|
||||
}{
|
||||
"unknown key": {" day:\n type: datepicker\n mode: date\n showWeekNumber: true\n", "unknown field showWeekNumber"},
|
||||
"ignoreTimezone date": {" day:\n type: datepicker\n mode: date\n ignoreTimezone: true\n", "ignoreTimezone is only valid with mode: datetime"},
|
||||
"unmapped token": {" starts_at:\n type: datepicker\n format: d.m.Y T\n", "token T has no equivalent"},
|
||||
"minDate on time": {" day:\n type: datepicker\n mode: time\n minDate: 2026-01-01\n", "minDate is not valid with mode: time"},
|
||||
"min after max": {" starts_at:\n type: datepicker\n minDate: 2026-02-01\n maxDate: 2026-01-01\n", "is after maxDate"},
|
||||
"key on another type": {" title:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"},
|
||||
"mode on another type": {" title:\n type: text\n mode: date\n", "mode is only valid on type: fileupload or datepicker"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := activateFields(t, datepickerFields(tc.field))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "models/gadget/fields.yaml") {
|
||||
t.Fatalf("err = %v, want %q naming the file", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
form, err := cabana.CompileForm("acme.conform", conformController{}, datepickerFS(datepickerFields(" starts_at:\n type: datepicker\n format: d.m.Y H:i\n yearRange: 10\n firstDay: 1\n twelveHour: true\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := form.CanonicalJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc struct {
|
||||
Fields []map[string]any `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := doc.Fields[len(doc.Fields)-1]
|
||||
if got["mode"] != "datetime" || got["format"] != "d.m.Y H:i" || got["displayFormat"] != "DD.MM.YYYY HH:mm" || got["firstDay"] != float64(1) || got["twelveHour"] != true {
|
||||
t.Fatalf("datepicker field = %v", got)
|
||||
}
|
||||
if years, _ := got["yearRange"].([]any); len(years) != 1 || years[0] != float64(10) {
|
||||
t.Fatalf("yearRange = %v", got["yearRange"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatepickerSmokeTypeMismatch(t *testing.T) {
|
||||
err := activateFields(t, datepickerFields(" starts_at:\n type: datepicker\n mode: date\n"))
|
||||
if err == nil || !strings.Contains(err.Error(), "field starts_at: datepicker mode date needs a lagoon.Date or *lagoon.Date column, found *time.Time") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if err := activateFields(t, datepickerFields(" released_on:\n type: datepicker\n mode: date\n starts_at:\n type: datepicker\n")); err != nil {
|
||||
t.Fatalf("matching types failed boot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDatepickerSmokeSave saves a date and a datetime through the admin
|
||||
// API: DATE stays the calendar day, the datetime is stored in UTC, and a
|
||||
// date before minDate is a 422 on the field.
|
||||
func TestDatepickerSmokeSave(t *testing.T) {
|
||||
env := newConformEnv(t)
|
||||
env.loginAs(t, env.login)
|
||||
rec := env.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "dates-" + env.stamp, "released_on": "2026-10-02", "starts_at": "2026-10-02T12:30:00+02: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())
|
||||
var body struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data["released_on"] != "2026-10-02" {
|
||||
t.Fatalf("record = %s", rec.Body.String())
|
||||
}
|
||||
var row struct {
|
||||
Day string
|
||||
UTCTime string
|
||||
}
|
||||
err := env.db.Raw(`SELECT released_on::text AS day, to_char(starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI') AS utc_time FROM cabana_conform_gadgets WHERE id = ?`, id).Scan(&row).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.Day != "2026-10-02" || row.UTCTime != "2026-10-02 10:30" {
|
||||
t.Fatalf("stored = %+v", row)
|
||||
}
|
||||
|
||||
early := env.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "early-" + env.stamp, "released_on": "1999-12-31"}, true)
|
||||
if early.Code != http.StatusUnprocessableEntity || !strings.Contains(early.Body.String(), "released on must be a date after or equal to 2000-01-01") {
|
||||
t.Fatalf("before minDate status=%d body=%s", early.Code, early.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -114,9 +114,9 @@ func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *Compil
|
||||
}
|
||||
switch {
|
||||
case cf.required && n == 0:
|
||||
details[cf.name] = []string{fileMessage(ctx, s.tr, "required", cf.name, nil)}
|
||||
details[cf.name] = []string{fieldMessage(ctx, s.tr, "required", cf.name, nil)}
|
||||
case cf.relation.Many && cf.maxFiles > 0 && n > int64(cf.maxFiles):
|
||||
details[cf.name] = []string{fileMessage(ctx, s.tr, "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)})}
|
||||
details[cf.name] = []string{fieldMessage(ctx, s.tr, "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)})}
|
||||
}
|
||||
}
|
||||
if len(details) > 0 {
|
||||
|
||||
346
modules/cabana/field_date.go
Normal file
346
modules/cabana/field_date.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
)
|
||||
|
||||
// datepickerKeys are valid only on `type: datepicker` (D-20). mode is shared
|
||||
// with fileupload and gated by value.
|
||||
var datepickerKeys = []string{
|
||||
"format", "minDate", "maxDate", "yearRange", "firstDay", "twelveHour", "ignoreTimezone",
|
||||
}
|
||||
|
||||
// datepickerRefusedKeys are generic field keys with no meaning on a
|
||||
// datepicker.
|
||||
var datepickerRefusedKeys = []string{"options", "emptyOption", "nameFrom"}
|
||||
|
||||
// momentTokens is WinterCMS's DateTime::momentFormat table restricted to the
|
||||
// PHP date() letters the admin SPA's formatter renders. Every other letter
|
||||
// fails boot: t, L, B, I, O, P, T, Z, c, r and U have no equivalent, and the
|
||||
// rest are not supported by the SPA.
|
||||
var momentTokens = map[byte]string{
|
||||
'd': "DD", 'j': "D", 'D': "ddd", 'l': "dddd",
|
||||
'm': "MM", 'n': "M", 'M': "MMM", 'F': "MMMM",
|
||||
'Y': "YYYY", 'y': "YY",
|
||||
'H': "HH", 'G': "H", 'h': "hh", 'g': "h",
|
||||
'i': "mm", 's': "ss", 'A': "A", 'a': "a",
|
||||
}
|
||||
|
||||
var (
|
||||
dateGoType = reflect.TypeOf(lagoon.Date{})
|
||||
timeOfDayGoType = reflect.TypeOf(lagoon.TimeOfDay{})
|
||||
)
|
||||
|
||||
// compileDatepickerKeys decodes the D-20 keys of a `type: datepicker` field.
|
||||
// They are refused on every other type.
|
||||
func compileDatepickerKeys(typ string, values map[string]ast.Node, field *FormField) error {
|
||||
if typ != "datepicker" {
|
||||
for _, key := range datepickerKeys {
|
||||
if _, ok := values[key]; ok {
|
||||
return fmt.Errorf("%s is only valid on type: datepicker", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, key := range datepickerRefusedKeys {
|
||||
if _, ok := values[key]; ok {
|
||||
return fmt.Errorf("%s is not valid on type: datepicker", key)
|
||||
}
|
||||
}
|
||||
field.Mode = "datetime"
|
||||
if node, ok := values["mode"]; ok {
|
||||
mode, err := nodeString(node)
|
||||
if err != nil || (mode != "date" && mode != "datetime" && mode != "time") {
|
||||
return fmt.Errorf("mode %q must be date, datetime or time on type: datepicker", nodeText(node))
|
||||
}
|
||||
field.Mode = mode
|
||||
}
|
||||
if node, ok := values["format"]; ok {
|
||||
format, err := nodeString(node)
|
||||
if err != nil || strings.TrimSpace(format) == "" {
|
||||
return fmt.Errorf("format must be a PHP date format string")
|
||||
}
|
||||
display, err := momentFormat(format)
|
||||
if err != nil {
|
||||
return fmt.Errorf("format %q: %w", format, err)
|
||||
}
|
||||
field.Format = format
|
||||
field.DisplayFormat = display
|
||||
}
|
||||
for _, key := range []string{"minDate", "maxDate"} {
|
||||
node, ok := values[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if field.Mode == "time" {
|
||||
return fmt.Errorf("%s is not valid with mode: time", key)
|
||||
}
|
||||
d, err := boundDate(node)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
if key == "minDate" {
|
||||
field.MinDate = d.String()
|
||||
} else {
|
||||
field.MaxDate = d.String()
|
||||
}
|
||||
}
|
||||
if field.MinDate != "" && field.MaxDate != "" && field.MinDate > field.MaxDate {
|
||||
return fmt.Errorf("minDate %s is after maxDate %s", field.MinDate, field.MaxDate)
|
||||
}
|
||||
if node, ok := values["yearRange"]; ok {
|
||||
years, err := compileYearRange(node)
|
||||
if err != nil {
|
||||
return fmt.Errorf("yearRange: %w", err)
|
||||
}
|
||||
field.YearRange = years
|
||||
}
|
||||
if node, ok := values["firstDay"]; ok {
|
||||
n, err := nodeInt(node)
|
||||
if err != nil || n < 0 || n > 6 {
|
||||
return fmt.Errorf("firstDay %q must be an integer from 0 (Sunday) to 6", nodeText(node))
|
||||
}
|
||||
v := int(n)
|
||||
field.FirstDay = &v
|
||||
}
|
||||
if node, ok := values["twelveHour"]; ok {
|
||||
v, err := nodeBool(node)
|
||||
if err != nil {
|
||||
return fmt.Errorf("twelveHour: %w", err)
|
||||
}
|
||||
field.TwelveHour = v
|
||||
}
|
||||
if node, ok := values["ignoreTimezone"]; ok {
|
||||
v, err := nodeBool(node)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ignoreTimezone: %w", err)
|
||||
}
|
||||
if v && field.Mode != "datetime" {
|
||||
return fmt.Errorf("ignoreTimezone is only valid with mode: datetime")
|
||||
}
|
||||
field.IgnoreTimezone = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// boundDate reads a minDate or maxDate: a YYYY-MM-DD date or the date part
|
||||
// of an RFC 3339 timestamp.
|
||||
func boundDate(node ast.Node) (lagoon.Date, error) {
|
||||
text, err := nodeString(node)
|
||||
if err != nil {
|
||||
text = nodeText(node)
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if d, err := lagoon.ParseDate(text); err == nil && !d.IsZero() {
|
||||
return d, nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, text); err == nil {
|
||||
return lagoon.NewDate(t.Year(), t.Month(), t.Day()), nil
|
||||
}
|
||||
return lagoon.Date{}, fmt.Errorf("%q must be a date (YYYY-MM-DD)", text)
|
||||
}
|
||||
|
||||
// compileYearRange reads a positive integer (years either side of today) or
|
||||
// a two-year list [from, to] with from <= to.
|
||||
func compileYearRange(node ast.Node) ([]int, error) {
|
||||
switch n := unwrapNode(node).(type) {
|
||||
case *ast.IntegerNode:
|
||||
v, err := nodeInt(n)
|
||||
if err != nil || v < 1 {
|
||||
return nil, fmt.Errorf("%q must be a positive integer", nodeText(n))
|
||||
}
|
||||
return []int{int(v)}, nil
|
||||
case *ast.SequenceNode:
|
||||
items := sequenceValues(n)
|
||||
if len(items) != 2 {
|
||||
return nil, fmt.Errorf("a list must hold exactly two years")
|
||||
}
|
||||
from, err1 := nodeInt(items[0])
|
||||
to, err2 := nodeInt(items[1])
|
||||
if err1 != nil || err2 != nil || from < 1 || to < 1 {
|
||||
return nil, fmt.Errorf("a list must hold two years")
|
||||
}
|
||||
if from > to {
|
||||
return nil, fmt.Errorf("from %d is after to %d", from, to)
|
||||
}
|
||||
return []int{int(from), int(to)}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("must be an integer or a list of two years")
|
||||
}
|
||||
}
|
||||
|
||||
// momentFormat maps a PHP date() format to the moment-style display format
|
||||
// the admin SPA renders, as WinterCMS's DateTime::momentFormat does. A
|
||||
// backslash-escaped character is kept literal; a letter outside
|
||||
// momentTokens is an error naming it.
|
||||
func momentFormat(format string) (string, error) {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(format); i++ {
|
||||
c := format[i]
|
||||
switch {
|
||||
case c == '\\':
|
||||
if i+1 < len(format) {
|
||||
i++
|
||||
b.WriteByte('[')
|
||||
b.WriteByte(format[i])
|
||||
b.WriteByte(']')
|
||||
}
|
||||
case (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'):
|
||||
token, ok := momentTokens[c]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("token %c has no equivalent in the admin date formatter", c)
|
||||
}
|
||||
b.WriteString(token)
|
||||
default:
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// compiledDate is one datepicker field after the D-19 type check.
|
||||
type compiledDate struct {
|
||||
name string
|
||||
mode string
|
||||
min, max string
|
||||
ignoreTimezone bool
|
||||
}
|
||||
|
||||
// compileDateFields checks every datepicker of the controller's form
|
||||
// against its model column's Go type (D-19) and keeps its server bounds.
|
||||
func compileDateFields(pluginID string, cc *CompiledController) error {
|
||||
if cc == nil || cc.Form == nil {
|
||||
return nil
|
||||
}
|
||||
var model any
|
||||
for _, field := range cc.Form.Fields {
|
||||
if field.Type != "datepicker" {
|
||||
continue
|
||||
}
|
||||
if model == nil {
|
||||
proto, err := newWritableModel(cc)
|
||||
if err != nil {
|
||||
return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath, fmt.Errorf("field %s: type datepicker needs a fillable model", field.Name))
|
||||
}
|
||||
model = proto
|
||||
}
|
||||
if err := checkDateType(model, field); err != nil {
|
||||
return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath, err)
|
||||
}
|
||||
if cc.dates == nil {
|
||||
cc.dates = map[string]*compiledDate{}
|
||||
}
|
||||
cc.dates[field.Name] = &compiledDate{
|
||||
name: field.Name, mode: field.Mode,
|
||||
min: field.MinDate, max: field.MaxDate,
|
||||
ignoreTimezone: field.IgnoreTimezone,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDateType refuses a datepicker whose mode does not match its column's
|
||||
// Go type: time.Time for datetime, lagoon.Date for date, lagoon.TimeOfDay
|
||||
// for time, each also as a pointer.
|
||||
func checkDateType(model any, field FormField) error {
|
||||
sf, ok := structFieldByColumn(model, field.Name)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s is not a model column", field.Name)
|
||||
}
|
||||
t := sf.Type
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
want := map[string]reflect.Type{"datetime": timeType, "date": dateGoType, "time": timeOfDayGoType}[field.Mode]
|
||||
if t != want {
|
||||
names := map[string]string{"datetime": "time.Time or *time.Time", "date": "lagoon.Date or *lagoon.Date", "time": "lagoon.TimeOfDay or *lagoon.TimeOfDay"}
|
||||
return fmt.Errorf("field %s: datepicker mode %s needs a %s column, found %s", field.Name, field.Mode, names[field.Mode], sf.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dateBoundDetails rechecks minDate and maxDate on the filled model (D-20):
|
||||
// the calendar date of the value (the UTC date of a datetime, or its
|
||||
// wall-clock date with ignoreTimezone) must lie inside the bounds,
|
||||
// inclusively. A violation is a message on the field.
|
||||
func dateBoundDetails(ctx context.Context, tr *phrasebook.Translator, cc *CompiledController, model any, op string) map[string][]string {
|
||||
if cc == nil || len(cc.dates) == 0 {
|
||||
return nil
|
||||
}
|
||||
v := reflect.ValueOf(model)
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
var out map[string][]string
|
||||
for _, field := range cc.Form.Fields {
|
||||
cd := cc.dates[field.Name]
|
||||
if cd == nil || (cd.min == "" && cd.max == "") || !contextAllows(cc, cd.name, op) {
|
||||
continue
|
||||
}
|
||||
fv := fieldByColumn(v, cd.name)
|
||||
if !fv.IsValid() || !fv.CanInterface() {
|
||||
continue
|
||||
}
|
||||
day, ok := calendarDate(fv.Interface(), cd.ignoreTimezone)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var msg string
|
||||
switch {
|
||||
case cd.min != "" && day < cd.min:
|
||||
msg = fieldMessage(ctx, tr, "after_or_equal", cd.name, map[string]string{"date": cd.min})
|
||||
case cd.max != "" && day > cd.max:
|
||||
msg = fieldMessage(ctx, tr, "before_or_equal", cd.name, map[string]string{"date": cd.max})
|
||||
}
|
||||
if msg != "" {
|
||||
if out == nil {
|
||||
out = map[string][]string{}
|
||||
}
|
||||
out[cd.name] = append(out[cd.name], msg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// calendarDate is the YYYY-MM-DD date of a datepicker value, false when the
|
||||
// value is not set.
|
||||
func calendarDate(value any, wallClock bool) (string, bool) {
|
||||
switch v := value.(type) {
|
||||
case lagoon.Date:
|
||||
return v.String(), !v.IsZero()
|
||||
case *lagoon.Date:
|
||||
if v == nil || v.IsZero() {
|
||||
return "", false
|
||||
}
|
||||
return v.String(), true
|
||||
case time.Time:
|
||||
return instantDate(v, wallClock)
|
||||
case *time.Time:
|
||||
if v == nil {
|
||||
return "", false
|
||||
}
|
||||
return instantDate(*v, wallClock)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func instantDate(t time.Time, wallClock bool) (string, bool) {
|
||||
if t.IsZero() {
|
||||
return "", false
|
||||
}
|
||||
if !wallClock {
|
||||
t = t.UTC()
|
||||
}
|
||||
return t.Format("2006-01-02"), true
|
||||
}
|
||||
@@ -70,8 +70,8 @@ func compileFileuploadKeys(typ string, values map[string]ast.Node, field *FormFi
|
||||
return fmt.Errorf("%s is only valid on type: fileupload", key)
|
||||
}
|
||||
}
|
||||
if _, ok := values["mode"]; ok {
|
||||
return fmt.Errorf("mode is only valid on type: fileupload")
|
||||
if _, ok := values["mode"]; ok && typ != "datepicker" {
|
||||
return fmt.Errorf("mode is only valid on type: fileupload or datepicker")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -672,7 +672,7 @@ func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
if len(files) >= cf.maxFiles {
|
||||
return &ValidationError{Details: fieldDetail(cf.name, fileMessage(ctx, s.translator(), "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)}))}
|
||||
return &ValidationError{Details: fieldDetail(cf.name, fieldMessage(ctx, s.translator(), "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)}))}
|
||||
}
|
||||
}
|
||||
f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: cf.relation.Public}, cf.limits)
|
||||
@@ -739,7 +739,7 @@ func (s *service) writeFileError(w http.ResponseWriter, r *http.Request, cf *com
|
||||
case cf == nil:
|
||||
case errors.Is(err, attach.ErrTooLarge):
|
||||
kb := strconv.FormatInt(cf.maxBytes/1024, 10)
|
||||
detail = fileMessage(ctx, tr, "max.file", cf.name, map[string]string{"max": kb})
|
||||
detail = fieldMessage(ctx, tr, "max.file", cf.name, map[string]string{"max": kb})
|
||||
case errors.Is(err, attach.ErrFileType):
|
||||
types := cf.limits.Extensions
|
||||
if len(types) == 0 {
|
||||
@@ -748,11 +748,11 @@ func (s *service) writeFileError(w http.ResponseWriter, r *http.Request, cf *com
|
||||
types = attach.DefaultImageExtensions
|
||||
}
|
||||
}
|
||||
detail = fileMessage(ctx, tr, "mimes", cf.name, map[string]string{"values": strings.Join(types, ", ")})
|
||||
detail = fieldMessage(ctx, tr, "mimes", cf.name, map[string]string{"values": strings.Join(types, ", ")})
|
||||
case errors.Is(err, attach.ErrMIMEType):
|
||||
detail = fileMessage(ctx, tr, "mimetypes", cf.name, map[string]string{"values": strings.Join(cf.limits.MIMETypes, ", ")})
|
||||
detail = fieldMessage(ctx, tr, "mimetypes", cf.name, map[string]string{"values": strings.Join(cf.limits.MIMETypes, ", ")})
|
||||
case errors.Is(err, attach.ErrNotImage):
|
||||
detail = fileMessage(ctx, tr, "image", cf.name, nil)
|
||||
detail = fieldMessage(ctx, tr, "image", cf.name, nil)
|
||||
}
|
||||
if detail != "" {
|
||||
writeCRUDError(w, &ValidationError{Details: fieldDetail(cf.name, detail)})
|
||||
@@ -801,9 +801,9 @@ func fieldDetail(field, message string) map[string]any {
|
||||
return map[string]any{field: []string{message}}
|
||||
}
|
||||
|
||||
// fileMessage is a lagoon::validation line for a file field, with Laravel's
|
||||
// English text when no translator has the key.
|
||||
func fileMessage(ctx context.Context, tr *phrasebook.Translator, rule, field string, params map[string]string) string {
|
||||
// fieldMessage is a lagoon::validation line for a file or date field, with
|
||||
// Laravel's English text when no translator has the key.
|
||||
func fieldMessage(ctx context.Context, tr *phrasebook.Translator, rule, field string, params map[string]string) string {
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
@@ -826,6 +826,10 @@ func fileMessage(ctx context.Context, tr *phrasebook.Translator, rule, field str
|
||||
return "The " + attr + " must be an image."
|
||||
case "required":
|
||||
return "The " + attr + " field is required."
|
||||
case "after_or_equal":
|
||||
return "The " + attr + " must be a date after or equal to " + params["date"] + "."
|
||||
case "before_or_equal":
|
||||
return "The " + attr + " must be a date before or equal to " + params["date"] + "."
|
||||
}
|
||||
return "The " + attr + " is invalid."
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ var (
|
||||
formFieldTypes = map[string]struct{}{
|
||||
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
|
||||
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
|
||||
"widget": {}, "partial": {}, "fileupload": {},
|
||||
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
|
||||
}
|
||||
formSpans = map[string]struct{}{
|
||||
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
|
||||
@@ -39,6 +39,8 @@ var (
|
||||
"mode": {}, "fileTypes": {}, "mimeTypes": {}, "maxFilesize": {},
|
||||
"maxFiles": {}, "imageWidth": {}, "imageHeight": {},
|
||||
"thumbOptions": {}, "useCaption": {}, "prompt": {},
|
||||
"format": {}, "minDate": {}, "maxDate": {}, "yearRange": {},
|
||||
"firstDay": {}, "twelveHour": {}, "ignoreTimezone": {},
|
||||
}
|
||||
// widgetKeys are valid only on `type: widget` (D-06).
|
||||
widgetKeys = []string{"widget", "action", "fill"}
|
||||
@@ -152,6 +154,7 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr
|
||||
field.Fill = append([]string(nil), src.Fill...)
|
||||
field.FileTypes = append([]string(nil), src.FileTypes...)
|
||||
field.MimeTypes = append([]string(nil), src.MimeTypes...)
|
||||
field.YearRange = append([]int(nil), src.YearRange...)
|
||||
options, err := localizeOptions(ctx, tr, src, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -431,6 +434,9 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
|
||||
if err := compileFileuploadKeys(typ, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
if err := compileDatepickerKeys(typ, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
if node, ok := values["required"]; ok {
|
||||
field.Required, err = nodeBool(node)
|
||||
if err != nil {
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
const listSearchTerm = "search"
|
||||
|
||||
var listColumnTypes = map[string]struct{}{
|
||||
"text": {}, "datetime": {}, "switch": {},
|
||||
"text": {}, "datetime": {}, "switch": {}, "date": {}, "time": {},
|
||||
}
|
||||
|
||||
type listDocument struct {
|
||||
@@ -434,6 +434,11 @@ func isListRelation(t reflect.Type) bool {
|
||||
if t == reflect.TypeOf(gorm.DeletedAt{}) {
|
||||
return false
|
||||
}
|
||||
// A struct stored in one column (it scans or values itself, such as
|
||||
// lagoon.Date and lagoon.TimeOfDay) is a scalar, not a relation.
|
||||
if reflect.PointerTo(t).Implements(scannerType) || t.Implements(valuerType) || reflect.PointerTo(t).Implements(valuerType) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
return e.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", e.photoID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
|
||||
}, into[cabana.Envelope[cabana.FileMutationResult]](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "gadget-" + e.stamp, "active": true, "group": e.groupID}, true)
|
||||
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "gadget-" + e.stamp, "active": true, "group": e.groupID, "released_on": "2026-10-02", "starts_at": "2026-10-02T12:30:00+02:00"}, true)
|
||||
e.gadgetID = dataID(t, rec.Body.Bytes())
|
||||
return rec
|
||||
}, into[cabana.RecordEnvelope](), nil},
|
||||
@@ -208,7 +208,7 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
|
||||
}, into[cabana.RecordEnvelope](), nil},
|
||||
{"PUT /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), map[string]any{"name": "gadget-" + e.stamp + "-renamed", "group": nil}, true)
|
||||
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), map[string]any{"name": "gadget-" + e.stamp + "-renamed", "group": nil, "released_on": "2026-10-03", "starts_at": nil}, true)
|
||||
}, into[cabana.RecordEnvelope](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/candidates?search=%s", e.gadgetID, e.stamp), nil, true)
|
||||
@@ -535,21 +535,26 @@ func newConformEnv(t *testing.T) *conformEnv {
|
||||
}
|
||||
|
||||
type conformGadget struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Active bool `gorm:"column:active"`
|
||||
GroupID *uint `gorm:"column:group_id"`
|
||||
Members []conformMember `gorm:"-"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Active bool `gorm:"column:active"`
|
||||
GroupID *uint `gorm:"column:group_id"`
|
||||
// ReleasedOn and StartsAt are the date and datetime datepicker columns.
|
||||
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
|
||||
StartsAt *time.Time `gorm:"column:starts_at"`
|
||||
Members []conformMember `gorm:"-"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (conformGadget) TableName() string { return "cabana_conform_gadgets" }
|
||||
func (conformGadget) MorphName() string { return "acme.conform.gadget" }
|
||||
func (conformGadget) TableName() string { return "cabana_conform_gadgets" }
|
||||
func (conformGadget) MorphName() string { return "acme.conform.gadget" }
|
||||
func (conformGadget) AttachRelations() []attach.Relation {
|
||||
return []attach.Relation{{Name: "photos", Many: true, Public: true}, {Name: "manual"}}
|
||||
}
|
||||
func (conformGadget) Fillable() []string { return []string{"name", "active"} }
|
||||
func (conformGadget) Fillable() []string {
|
||||
return []string{"name", "active", "released_on", "starts_at"}
|
||||
}
|
||||
func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||
func (conformGadget) FilterScopes() []string { return []string{"filterByGroup"} }
|
||||
func (conformGadget) FilterScope(_ string, db *gorm.DB, value any) *gorm.DB {
|
||||
@@ -751,6 +756,9 @@ update:
|
||||
active:
|
||||
label: Active
|
||||
type: switch
|
||||
released_on:
|
||||
label: Released on
|
||||
type: date
|
||||
`),
|
||||
"models/gadget/fields.yaml": file(`fields:
|
||||
name:
|
||||
@@ -789,6 +797,17 @@ update:
|
||||
maxFilesize: 0.5
|
||||
thumbOptions:
|
||||
mode: crop
|
||||
released_on:
|
||||
label: Released on
|
||||
type: datepicker
|
||||
mode: date
|
||||
minDate: 2000-01-01
|
||||
firstDay: 1
|
||||
starts_at:
|
||||
label: Starts at
|
||||
type: datepicker
|
||||
format: d.m.Y H:i
|
||||
yearRange: [2000, 2040]
|
||||
manual:
|
||||
label: Manual
|
||||
type: fileupload
|
||||
|
||||
@@ -96,6 +96,9 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
|
||||
if err := compileFileFields(item.plugin.ID(), compiled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compileDateFields(item.plugin.ID(), compiled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compileExtension(item.plugin.ID(), compiled, fsys.AdminFS()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -224,8 +224,25 @@ type FormField struct {
|
||||
// Path names the controller partial of a `type: partial` field: the
|
||||
// template {ConfigDir}/_{path}.htm (D-09).
|
||||
Path string `json:"path,omitempty"`
|
||||
// Mode is the fileupload mode (image or file, default file).
|
||||
// Mode is the fileupload mode (image or file, default file) or the
|
||||
// datepicker mode (date, datetime or time, default datetime).
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// Format is a datepicker's WinterCMS (PHP date) display format;
|
||||
// DisplayFormat is the same format in the SPA's moment-style tokens.
|
||||
Format string `json:"format,omitempty"`
|
||||
DisplayFormat string `json:"displayFormat,omitempty"`
|
||||
// MinDate and MaxDate bound a datepicker's calendar date (YYYY-MM-DD),
|
||||
// checked again on save.
|
||||
MinDate string `json:"minDate,omitempty"`
|
||||
MaxDate string `json:"maxDate,omitempty"`
|
||||
// YearRange is [n] (years either side of today) or [from, to].
|
||||
YearRange []int `json:"yearRange,omitempty"`
|
||||
// FirstDay is the first day of the week, 0 (Sunday) to 6.
|
||||
FirstDay *int `json:"firstDay,omitempty"`
|
||||
// TwelveHour shows a 12-hour clock.
|
||||
TwelveHour bool `json:"twelveHour,omitempty"`
|
||||
// IgnoreTimezone keeps a datetime's wall-clock value with no conversion.
|
||||
IgnoreTimezone bool `json:"ignoreTimezone,omitempty"`
|
||||
// FileTypes are the allowed lower-case extensions of a fileupload field.
|
||||
FileTypes []string `json:"fileTypes,omitempty"`
|
||||
// MimeTypes are the allowed MIME patterns (or extensions) of a
|
||||
|
||||
@@ -84,6 +84,11 @@ func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*Compi
|
||||
if _, ok := fillable[field.Name]; !ok {
|
||||
return nil, fmt.Errorf("cabana: setting %s field %s is not fillable", item.Code, field.Name)
|
||||
}
|
||||
if field.Type == "datepicker" {
|
||||
if err := checkDateType(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
|
||||
|
||||
Reference in New Issue
Block a user