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:
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user