diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 9ea03f4..9f0bdc5 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -615,6 +615,9 @@ "default": { "$ref": "#/components/schemas/cabana.jsonScalar" }, + "displayFormat": { + "type": "string" + }, "emptyOption": { "type": "string" }, @@ -632,6 +635,18 @@ }, "type": "array" }, + "firstDay": { + "description": "FirstDay is the first day of the week, 0 (Sunday) to 6.", + "type": "integer" + }, + "format": { + "description": "Format is a datepicker's WinterCMS (PHP date) display format;\nDisplayFormat is the same format in the SPA's moment-style tokens.", + "type": "string" + }, + "ignoreTimezone": { + "description": "IgnoreTimezone keeps a datetime's wall-clock value with no conversion.", + "type": "boolean" + }, "imageHeight": { "type": "integer" }, @@ -642,6 +657,9 @@ "label": { "type": "string" }, + "maxDate": { + "type": "string" + }, "maxFiles": { "description": "MaxFiles caps the number of files of an attachMany field.", "type": "integer" @@ -657,8 +675,12 @@ }, "type": "array" }, + "minDate": { + "description": "MinDate and MaxDate bound a datepicker's calendar date (YYYY-MM-DD),\nchecked again on save.", + "type": "string" + }, "mode": { - "description": "Mode is the fileupload mode (image or file, default file).", + "description": "Mode is the fileupload mode (image or file, default file) or the\ndatepicker mode (date, datetime or time, default datetime).", "type": "string" }, "multiple": { @@ -714,6 +736,10 @@ ], "description": "ThumbOptions carries the preview thumbnail mode." }, + "twelveHour": { + "description": "TwelveHour shows a 12-hour clock.", + "type": "boolean" + }, "type": { "type": "string" }, @@ -724,6 +750,13 @@ "widget": { "description": "Widget is the custom-element tag of a `type: widget` field (D-06).", "type": "string" + }, + "yearRange": { + "description": "YearRange is [n] (years either side of today) or [from, to].", + "items": { + "type": "integer" + }, + "type": "array" } }, "required": [ diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 9928b82..e8ff973 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -2893,15 +2893,26 @@ export interface components { comment?: string; context?: components["schemas"]["cabana.fieldContext"]; default?: components["schemas"]["cabana.jsonScalar"]; + displayFormat?: string; emptyOption?: string; /** @description FileTypes are the allowed lower-case extensions of a fileupload field. */ fileTypes?: string[]; /** @description Fill lists the fields of the same form the action writes back (D-07). */ fill?: string[]; + /** @description FirstDay is the first day of the week, 0 (Sunday) to 6. */ + firstDay?: number; + /** + * @description Format is a datepicker's WinterCMS (PHP date) display format; + * DisplayFormat is the same format in the SPA's moment-style tokens. + */ + format?: string; + /** @description IgnoreTimezone keeps a datetime's wall-clock value with no conversion. */ + ignoreTimezone?: boolean; imageHeight?: number; /** @description ImageWidth and ImageHeight are the preview size of an image upload. */ imageWidth?: number; label?: string; + maxDate?: string; /** @description MaxFiles caps the number of files of an attachMany field. */ maxFiles?: number; /** @description MaxFilesize is the largest accepted file in megabytes. */ @@ -2911,7 +2922,15 @@ export interface components { * fileupload field. */ mimeTypes?: string[]; - /** @description Mode is the fileupload mode (image or file, default file). */ + /** + * @description MinDate and MaxDate bound a datepicker's calendar date (YYYY-MM-DD), + * checked again on save. + */ + minDate?: string; + /** + * @description Mode is the fileupload mode (image or file, default file) or the + * datepicker mode (date, datetime or time, default datetime). + */ mode?: string; multiple?: boolean; name: string; @@ -2937,11 +2956,15 @@ export interface components { tab?: string; /** @description ThumbOptions carries the preview thumbnail mode. */ thumbOptions?: components["schemas"]["cabana.ThumbOptions"]; + /** @description TwelveHour shows a 12-hour clock. */ + twelveHour?: boolean; type: string; /** @description UseCaption lets the admin edit each file's title and description. */ useCaption?: boolean; /** @description Widget is the custom-element tag of a `type: widget` field (D-06). */ widget?: string; + /** @description YearRange is [n] (years either side of today) or [from, to]. */ + yearRange?: number[]; }; "cabana.FormMessages": { create: components["schemas"]["cabana.MessageForms"]; diff --git a/docs/backend/forms.md b/docs/backend/forms.md index e49e160..24329c5 100644 --- a/docs/backend/forms.md +++ b/docs/backend/forms.md @@ -92,8 +92,9 @@ for _, f := range form.Fields { | `widget` | A plugin custom element with a server action; see [Partials and widgets](partials-and-widgets.md). | | `partial` | A server-rendered template; see [Partials and widgets](partials-and-widgets.md). | | `fileupload` | Uploads for an attachOne or attachMany relation; see [File uploads](#file-uploads). | +| `datepicker` | A date, date and time, or time of day; see [Date pickers](#date-pickers). | -The WinterCMS widgets that are not in this list (the rich editor, the media finder, the repeater and the others) are not provided. A field with one of those types stops the start-up. +The WinterCMS widgets that are not in this list (the rich editor, the markdown editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up. ### Field options @@ -101,6 +102,48 @@ A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` ( `context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted. The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds. +## Date pickers + +A `type: datepicker` field edits one model column. Its `mode` decides the column's Go type, and the start-up stops when they do not match: + +| `mode` | Go type of the column | JSON value | +|--------|------------------------|------------| +| `datetime` (the default) | `time.Time` or `*time.Time` (a `timestamptz` column) | `"2026-10-02T10:30:00Z"` | +| `date` | `lagoon.Date` or `*lagoon.Date` (a `DATE` column) | `"2026-10-02"` | +| `time` | `lagoon.TimeOfDay` or `*lagoon.TimeOfDay` (a `TIME` column) | `"14:30:00"` | + +A plugin does not define its own date types; see [Casts and validation](../database/casts-and-validation.md) for the two framework types. Use the pointer types for an optional value: an empty picker stores NULL, and `required: true` refuses it. + +```yaml +fields: + published_on: + label: acme.blog::lang.posts.published_on + type: datepicker + mode: date + minDate: 2000-01-01 + starts_at: + label: acme.blog::lang.posts.starts_at + type: datepicker + format: d.m.Y H:i + firstDay: 1 +``` + +The field takes the generic keys plus these WinterCMS keys: + +| Key | Meaning | +|-----|---------| +| `mode` | `date`, `datetime` or `time`. | +| `format` | The display format as a PHP `date()` format. It is turned into the SPA's format at boot (the schema serves it as `displayFormat`); a letter with no equivalent, such as `T`, `U` or `c`, stops the start-up. It changes only how the value is shown, not the order of the editable segments, which follows the admin's locale. | +| `minDate`, `maxDate` | The earliest and latest calendar date (`YYYY-MM-DD`). Not valid on `mode: time`. | +| `yearRange` | Years either side of today (`10`) or a `[from, to]` list for the year selector. | +| `firstDay` | The first day of the week, 0 (Sunday) to 6. | +| `twelveHour` | Shows a 12-hour clock. | +| `ignoreTimezone` | `mode: datetime` only: the value is shown and saved as entered, with no time zone conversion. | + +`options`, `emptyOption`, `nameFrom` and every other key stop the start-up. + +`minDate` and `maxDate` are checked again on save: a value whose calendar date (the UTC date of a datetime, or its wall-clock date with `ignoreTimezone`) lies outside them is a 422 on the field. A `datetime` value is stored in UTC and the admin SPA shows and edits it in the browser's time zone; `date` and `time` values are never converted. + ## File uploads A `type: fileupload` field edits one attachment relation of the form's model. The model declares its relations with an `AttachRelations` method (`attach.HasRelations`) and implements `attach.Owner`; see [Attachments](../database/attachments.md). The field name must be one of the declared relation names, otherwise the start-up stops. diff --git a/docs/backend/lists-and-filters.md b/docs/backend/lists-and-filters.md index 3ac58d2..1c1738b 100644 --- a/docs/backend/lists-and-filters.md +++ b/docs/backend/lists-and-filters.md @@ -56,7 +56,7 @@ columns: type: datetime ``` -A column takes `label`, `type` (`text`, `datetime` or `switch`), `searchable` (default false) and `sortable` (default true, as in WinterCMS), and, for a column read through a relation, `relation` and `select`. Other WinterCMS column types and options are refused at boot. The compiled list keeps the columns in file order: +A column takes `label`, `type` (`text`, `datetime`, `date`, `time` or `switch`), `searchable` (default false) and `sortable` (default true, as in WinterCMS), and, for a column read through a relation, `relation` and `select`. Other WinterCMS column types and options are refused at boot. The compiled list keeps the columns in file order: ```go src=modules/cabana/example_test.go#ExampleCompileList // The plugin embeds its controllers/ and models/ trees; the example reads @@ -83,6 +83,8 @@ for _, f := range list.Filters { // filter published_at type=daterange column=published_at ``` +`type: date` and `type: time` are for `lagoon.Date` and `lagoon.TimeOfDay` columns (a `DATE` and a `TIME` column): the admin shows the stored `2026-10-02` or `14:30:00` as it is, with no time zone conversion, where `type: datetime` would read a date as midnight UTC and could show the previous day. A struct that stores itself in one column (it implements `sql.Scanner` or `driver.Valuer`, as both types do) is a plain column, never a relation. + Search runs over the searchable columns only; sort accepts only sortable columns. A request that names any other column is refused, never passed to SQL. ## Filters diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 5014463..3e20544 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -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: ` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: ` in `fields.yaml` render the template `{ConfigDir}/_.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. diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index da2b929..4b98b56 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -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. diff --git a/modules/cabana/crud.go b/modules/cabana/crud.go index 443e3e8..a0df829 100644 --- a/modules/cabana/crud.go +++ b/modules/cabana/crud.go @@ -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 diff --git a/modules/cabana/datepicker_smoke_test.go b/modules/cabana/datepicker_smoke_test.go new file mode 100644 index 0000000..56de3a4 --- /dev/null +++ b/modules/cabana/datepicker_smoke_test.go @@ -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()) + } +} diff --git a/modules/cabana/deferred.go b/modules/cabana/deferred.go index f6f7d01..1130ba7 100644 --- a/modules/cabana/deferred.go +++ b/modules/cabana/deferred.go @@ -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 { diff --git a/modules/cabana/field_date.go b/modules/cabana/field_date.go new file mode 100644 index 0000000..83b36a9 --- /dev/null +++ b/modules/cabana/field_date.go @@ -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 +} diff --git a/modules/cabana/field_file.go b/modules/cabana/field_file.go index e94a073..4d0ed14 100644 --- a/modules/cabana/field_file.go +++ b/modules/cabana/field_file.go @@ -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." } diff --git a/modules/cabana/form_schema.go b/modules/cabana/form_schema.go index 97220ae..aa4502b 100644 --- a/modules/cabana/form_schema.go +++ b/modules/cabana/form_schema.go @@ -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 { diff --git a/modules/cabana/list_schema.go b/modules/cabana/list_schema.go index 44e7a2f..00214b1 100644 --- a/modules/cabana/list_schema.go +++ b/modules/cabana/list_schema.go @@ -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 } diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index d5a5501..cb7be0c 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -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 diff --git a/modules/cabana/registry.go b/modules/cabana/registry.go index 9749922..fc6952c 100644 --- a/modules/cabana/registry.go +++ b/modules/cabana/registry.go @@ -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 } diff --git a/modules/cabana/schema_types.go b/modules/cabana/schema_types.go index a7a10db..8407c75 100644 --- a/modules/cabana/schema_types.go +++ b/modules/cabana/schema_types.go @@ -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 diff --git a/modules/cabana/settings.go b/modules/cabana/settings.go index 524ee44..99e76a8 100644 --- a/modules/cabana/settings.go +++ b/modules/cabana/settings.go @@ -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