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:
Jakub Zych
2026-10-02 18:19:04 +02:00
parent bbb804961e
commit 67d4c7ff13
17 changed files with 719 additions and 32 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View 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())
}
}

View File

@@ -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 {

View 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
}

View File

@@ -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."
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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