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

@@ -615,6 +615,9 @@
"default": { "default": {
"$ref": "#/components/schemas/cabana.jsonScalar" "$ref": "#/components/schemas/cabana.jsonScalar"
}, },
"displayFormat": {
"type": "string"
},
"emptyOption": { "emptyOption": {
"type": "string" "type": "string"
}, },
@@ -632,6 +635,18 @@
}, },
"type": "array" "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": { "imageHeight": {
"type": "integer" "type": "integer"
}, },
@@ -642,6 +657,9 @@
"label": { "label": {
"type": "string" "type": "string"
}, },
"maxDate": {
"type": "string"
},
"maxFiles": { "maxFiles": {
"description": "MaxFiles caps the number of files of an attachMany field.", "description": "MaxFiles caps the number of files of an attachMany field.",
"type": "integer" "type": "integer"
@@ -657,8 +675,12 @@
}, },
"type": "array" "type": "array"
}, },
"minDate": {
"description": "MinDate and MaxDate bound a datepicker's calendar date (YYYY-MM-DD),\nchecked again on save.",
"type": "string"
},
"mode": { "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" "type": "string"
}, },
"multiple": { "multiple": {
@@ -714,6 +736,10 @@
], ],
"description": "ThumbOptions carries the preview thumbnail mode." "description": "ThumbOptions carries the preview thumbnail mode."
}, },
"twelveHour": {
"description": "TwelveHour shows a 12-hour clock.",
"type": "boolean"
},
"type": { "type": {
"type": "string" "type": "string"
}, },
@@ -724,6 +750,13 @@
"widget": { "widget": {
"description": "Widget is the custom-element tag of a `type: widget` field (D-06).", "description": "Widget is the custom-element tag of a `type: widget` field (D-06).",
"type": "string" "type": "string"
},
"yearRange": {
"description": "YearRange is [n] (years either side of today) or [from, to].",
"items": {
"type": "integer"
},
"type": "array"
} }
}, },
"required": [ "required": [

View File

@@ -2893,15 +2893,26 @@ export interface components {
comment?: string; comment?: string;
context?: components["schemas"]["cabana.fieldContext"]; context?: components["schemas"]["cabana.fieldContext"];
default?: components["schemas"]["cabana.jsonScalar"]; default?: components["schemas"]["cabana.jsonScalar"];
displayFormat?: string;
emptyOption?: string; emptyOption?: string;
/** @description FileTypes are the allowed lower-case extensions of a fileupload field. */ /** @description FileTypes are the allowed lower-case extensions of a fileupload field. */
fileTypes?: string[]; fileTypes?: string[];
/** @description Fill lists the fields of the same form the action writes back (D-07). */ /** @description Fill lists the fields of the same form the action writes back (D-07). */
fill?: string[]; 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; imageHeight?: number;
/** @description ImageWidth and ImageHeight are the preview size of an image upload. */ /** @description ImageWidth and ImageHeight are the preview size of an image upload. */
imageWidth?: number; imageWidth?: number;
label?: string; label?: string;
maxDate?: string;
/** @description MaxFiles caps the number of files of an attachMany field. */ /** @description MaxFiles caps the number of files of an attachMany field. */
maxFiles?: number; maxFiles?: number;
/** @description MaxFilesize is the largest accepted file in megabytes. */ /** @description MaxFilesize is the largest accepted file in megabytes. */
@@ -2911,7 +2922,15 @@ export interface components {
* fileupload field. * fileupload field.
*/ */
mimeTypes?: string[]; 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; mode?: string;
multiple?: boolean; multiple?: boolean;
name: string; name: string;
@@ -2937,11 +2956,15 @@ export interface components {
tab?: string; tab?: string;
/** @description ThumbOptions carries the preview thumbnail mode. */ /** @description ThumbOptions carries the preview thumbnail mode. */
thumbOptions?: components["schemas"]["cabana.ThumbOptions"]; thumbOptions?: components["schemas"]["cabana.ThumbOptions"];
/** @description TwelveHour shows a 12-hour clock. */
twelveHour?: boolean;
type: string; type: string;
/** @description UseCaption lets the admin edit each file's title and description. */ /** @description UseCaption lets the admin edit each file's title and description. */
useCaption?: boolean; useCaption?: boolean;
/** @description Widget is the custom-element tag of a `type: widget` field (D-06). */ /** @description Widget is the custom-element tag of a `type: widget` field (D-06). */
widget?: string; widget?: string;
/** @description YearRange is [n] (years either side of today) or [from, to]. */
yearRange?: number[];
}; };
"cabana.FormMessages": { "cabana.FormMessages": {
create: components["schemas"]["cabana.MessageForms"]; create: components["schemas"]["cabana.MessageForms"];

View File

@@ -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). | | `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). | | `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). | | `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 ### 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. `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 ## 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. 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.

View File

@@ -56,7 +56,7 @@ columns:
type: datetime 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 ```go src=modules/cabana/example_test.go#ExampleCompileList
// The plugin embeds its controllers/ and models/ trees; the example reads // 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 // 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. 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 ## Filters

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. - 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. - 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. - 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. - 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`. - 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). - 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.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. | | `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. | | `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. 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 // files are the form's `type: fileupload` fields bound to the model's
// attach.Relation, keyed by field name. // attach.Relation, keyed by field name.
files map[string]*compiledFile 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. // 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 { if err != nil {
return &CapabilityError{ControllerID: controllerID(cc)} 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 { if len(msgs) > 0 {
return &ValidationError{Details: validationDetails(msgs)} return &ValidationError{Details: validationDetails(msgs)}
} }
@@ -812,7 +818,7 @@ func modelColumns(model any) map[string]struct{} {
func scalarFormField(typ string) bool { func scalarFormField(typ string) bool {
switch typ { switch typ {
case "text", "textarea", "number", "checkbox", "switch", "dropdown": case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker":
return true return true
default: default:
return false 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 { switch {
case cf.required && n == 0: 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): 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 { 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) return fmt.Errorf("%s is only valid on type: fileupload", key)
} }
} }
if _, ok := values["mode"]; ok { if _, ok := values["mode"]; ok && typ != "datepicker" {
return fmt.Errorf("mode is only valid on type: fileupload") return fmt.Errorf("mode is only valid on type: fileupload or datepicker")
} }
return nil return nil
} }
@@ -672,7 +672,7 @@ func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) {
return lifecycleFailure(cc, err) return lifecycleFailure(cc, err)
} }
if len(files) >= cf.maxFiles { 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) 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 cf == nil:
case errors.Is(err, attach.ErrTooLarge): case errors.Is(err, attach.ErrTooLarge):
kb := strconv.FormatInt(cf.maxBytes/1024, 10) 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): case errors.Is(err, attach.ErrFileType):
types := cf.limits.Extensions types := cf.limits.Extensions
if len(types) == 0 { if len(types) == 0 {
@@ -748,11 +748,11 @@ func (s *service) writeFileError(w http.ResponseWriter, r *http.Request, cf *com
types = attach.DefaultImageExtensions 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): 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): case errors.Is(err, attach.ErrNotImage):
detail = fileMessage(ctx, tr, "image", cf.name, nil) detail = fieldMessage(ctx, tr, "image", cf.name, nil)
} }
if detail != "" { if detail != "" {
writeCRUDError(w, &ValidationError{Details: fieldDetail(cf.name, 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}} return map[string]any{field: []string{message}}
} }
// fileMessage is a lagoon::validation line for a file field, with Laravel's // fieldMessage is a lagoon::validation line for a file or date field, with
// English text when no translator has the key. // 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 { func fieldMessage(ctx context.Context, tr *phrasebook.Translator, rule, field string, params map[string]string) string {
if params == nil { if params == nil {
params = map[string]string{} 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." return "The " + attr + " must be an image."
case "required": case "required":
return "The " + attr + " field is 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." return "The " + attr + " is invalid."
} }

View File

@@ -23,7 +23,7 @@ var (
formFieldTypes = map[string]struct{}{ formFieldTypes = map[string]struct{}{
"text": {}, "textarea": {}, "number": {}, "checkbox": {}, "text": {}, "textarea": {}, "number": {}, "checkbox": {},
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {}, "switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
"widget": {}, "partial": {}, "fileupload": {}, "widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
} }
formSpans = map[string]struct{}{ formSpans = map[string]struct{}{
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {}, "left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
@@ -39,6 +39,8 @@ var (
"mode": {}, "fileTypes": {}, "mimeTypes": {}, "maxFilesize": {}, "mode": {}, "fileTypes": {}, "mimeTypes": {}, "maxFilesize": {},
"maxFiles": {}, "imageWidth": {}, "imageHeight": {}, "maxFiles": {}, "imageWidth": {}, "imageHeight": {},
"thumbOptions": {}, "useCaption": {}, "prompt": {}, "thumbOptions": {}, "useCaption": {}, "prompt": {},
"format": {}, "minDate": {}, "maxDate": {}, "yearRange": {},
"firstDay": {}, "twelveHour": {}, "ignoreTimezone": {},
} }
// widgetKeys are valid only on `type: widget` (D-06). // widgetKeys are valid only on `type: widget` (D-06).
widgetKeys = []string{"widget", "action", "fill"} 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.Fill = append([]string(nil), src.Fill...)
field.FileTypes = append([]string(nil), src.FileTypes...) field.FileTypes = append([]string(nil), src.FileTypes...)
field.MimeTypes = append([]string(nil), src.MimeTypes...) field.MimeTypes = append([]string(nil), src.MimeTypes...)
field.YearRange = append([]int(nil), src.YearRange...)
options, err := localizeOptions(ctx, tr, src, provider) options, err := localizeOptions(ctx, tr, src, provider)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -431,6 +434,9 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
if err := compileFileuploadKeys(typ, values, &field); err != nil { if err := compileFileuploadKeys(typ, values, &field); err != nil {
return FormField{}, err return FormField{}, err
} }
if err := compileDatepickerKeys(typ, values, &field); err != nil {
return FormField{}, err
}
if node, ok := values["required"]; ok { if node, ok := values["required"]; ok {
field.Required, err = nodeBool(node) field.Required, err = nodeBool(node)
if err != nil { if err != nil {

View File

@@ -20,7 +20,7 @@ import (
const listSearchTerm = "search" const listSearchTerm = "search"
var listColumnTypes = map[string]struct{}{ var listColumnTypes = map[string]struct{}{
"text": {}, "datetime": {}, "switch": {}, "text": {}, "datetime": {}, "switch": {}, "date": {}, "time": {},
} }
type listDocument struct { type listDocument struct {
@@ -434,6 +434,11 @@ func isListRelation(t reflect.Type) bool {
if t == reflect.TypeOf(gorm.DeletedAt{}) { if t == reflect.TypeOf(gorm.DeletedAt{}) {
return false 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 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}) 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}, }, into[cabana.Envelope[cabana.FileMutationResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { {"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()) e.gadgetID = dataID(t, rec.Body.Bytes())
return rec return rec
}, into[cabana.RecordEnvelope](), nil}, }, 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) return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
}, into[cabana.RecordEnvelope](), nil}, }, into[cabana.RecordEnvelope](), nil},
{"PUT /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { {"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}, }, 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 { {"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) 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 { type conformGadget struct {
ID uint `gorm:"column:id;primaryKey"` ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"` Name string `gorm:"column:name"`
Active bool `gorm:"column:active"` Active bool `gorm:"column:active"`
GroupID *uint `gorm:"column:group_id"` GroupID *uint `gorm:"column:group_id"`
Members []conformMember `gorm:"-"` // ReleasedOn and StartsAt are the date and datetime datepicker columns.
CreatedAt time.Time `gorm:"column:created_at"` ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
UpdatedAt time.Time `gorm:"column:updated_at"` 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) TableName() string { return "cabana_conform_gadgets" }
func (conformGadget) MorphName() string { return "acme.conform.gadget" } func (conformGadget) MorphName() string { return "acme.conform.gadget" }
func (conformGadget) AttachRelations() []attach.Relation { func (conformGadget) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: "photos", Many: true, Public: true}, {Name: "manual"}} 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) Rules() map[string]string { return map[string]string{"name": "required"} }
func (conformGadget) FilterScopes() []string { return []string{"filterByGroup"} } func (conformGadget) FilterScopes() []string { return []string{"filterByGroup"} }
func (conformGadget) FilterScope(_ string, db *gorm.DB, value any) *gorm.DB { func (conformGadget) FilterScope(_ string, db *gorm.DB, value any) *gorm.DB {
@@ -751,6 +756,9 @@ update:
active: active:
label: Active label: Active
type: switch type: switch
released_on:
label: Released on
type: date
`), `),
"models/gadget/fields.yaml": file(`fields: "models/gadget/fields.yaml": file(`fields:
name: name:
@@ -789,6 +797,17 @@ update:
maxFilesize: 0.5 maxFilesize: 0.5
thumbOptions: thumbOptions:
mode: crop 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: manual:
label: Manual label: Manual
type: fileupload type: fileupload

View File

@@ -96,6 +96,9 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
if err := compileFileFields(item.plugin.ID(), compiled); err != nil { if err := compileFileFields(item.plugin.ID(), compiled); err != nil {
return nil, err 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 { if err := compileExtension(item.plugin.ID(), compiled, fsys.AdminFS()); err != nil {
return nil, err return nil, err
} }

View File

@@ -224,8 +224,25 @@ type FormField struct {
// Path names the controller partial of a `type: partial` field: the // Path names the controller partial of a `type: partial` field: the
// template {ConfigDir}/_{path}.htm (D-09). // template {ConfigDir}/_{path}.htm (D-09).
Path string `json:"path,omitempty"` 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"` 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 are the allowed lower-case extensions of a fileupload field.
FileTypes []string `json:"fileTypes,omitempty"` FileTypes []string `json:"fileTypes,omitempty"`
// MimeTypes are the allowed MIME patterns (or extensions) of a // 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 { if _, ok := fillable[field.Name]; !ok {
return nil, fmt.Errorf("cabana: setting %s field %s is not fillable", item.Code, field.Name) 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}) writable = append(writable, WritableField{Name: field.Name, FillKey: field.Name})
} }
return &CompiledSetting{PluginID: pluginID, Item: item, Form: form, Writable: writable}, nil return &CompiledSetting{PluginID: pluginID, Item: item, Form: form, Writable: writable}, nil