Files
summercms/modules/cabana/field_date.go
Jakub Zych 67d4c7ff13 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
2026-10-02 18:19:04 +02:00

347 lines
10 KiB
Go

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
}