fix(admin): datetime popover clock, unsaved confirm, inferred list dates

BM UAT on v0.1.1 showed a date-only calendar for datetime fields, a stuck discard dialog, and raw ISO timestamps when columns.yaml omitted type. The picker now edits time in the popover, confirm sits above the calendar, and omitted time.Time / Date / TimeOfDay columns compile as datetime / date / time.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-03 05:58:24 +02:00
parent 8031fd1c16
commit 55314e41f8
14 changed files with 227 additions and 32 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BfB8esTA.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B-HOzWeq.css">
<script type="module" crossorigin src="./assets/index-C1hSuckg.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Cr97p7-8.css">
</head>
<body>
<div id="app"></div>

View File

@@ -18,7 +18,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns, and a struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- 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; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. 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` whose file plus 64 KiB of multipart framing exceeds `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 retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. 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).

View File

@@ -219,9 +219,15 @@ func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPa
if err := decodeStrict(encoded, &spec); err != nil {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s: %w", key, err))
}
if spec.Type != "" {
if _, known := listColumnTypes[spec.Type]; !known {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("unsupported column type %s", spec.Type))
typ := spec.Type
if typ == "" && checkModel {
if inferred := modelCols[key]; inferred != "" && inferred != "text" {
typ = inferred
}
}
if typ != "" {
if _, known := listColumnTypes[typ]; !known {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("unsupported column type %s", typ))
}
}
if spec.Relation != "" || spec.Select != "" {
@@ -250,7 +256,7 @@ func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPa
Label: spec.Label,
Searchable: spec.Searchable,
Sortable: sortable,
Type: spec.Type,
Type: typ,
Relation: spec.Relation,
Select: spec.Select,
})
@@ -379,7 +385,7 @@ func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort,
return &ListSort{Column: doc.Column, Direction: doc.Direction}, nil
}
func listModelContract(ctl pact.AdminController) (map[string]struct{}, map[string]struct{}, bool) {
func listModelContract(ctl pact.AdminController) (map[string]string, map[string]struct{}, bool) {
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil {
return nil, nil, false
@@ -395,7 +401,7 @@ func listModelContract(ctl pact.AdminController) (map[string]struct{}, map[strin
if t == nil || t.Kind() != reflect.Struct {
return nil, nil, false
}
cols := map[string]struct{}{}
cols := map[string]string{}
rels := map[string]struct{}{}
for _, mf := range modelFields(t) {
field := mf.Field
@@ -410,11 +416,30 @@ func listModelContract(ctl pact.AdminController) (map[string]struct{}, map[strin
if name == "" {
name = field.Name
}
cols[name] = struct{}{}
cols[name] = listColumnKind(field.Type)
}
return cols, rels, true
}
// listColumnKind is the Winter list type of a model field when columns.yaml
// omits type: datetime for time.Time, date for lagoon.Date, time for
// lagoon.TimeOfDay. Everything else stays text (the admin's default).
func listColumnKind(t reflect.Type) string {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
switch t {
case reflect.TypeOf(time.Time{}):
return "datetime"
case dateGoType:
return "date"
case timeOfDayGoType:
return "time"
default:
return "text"
}
}
func isListRelation(t reflect.Type) bool {
for t.Kind() == reflect.Pointer {
t = t.Elem()

View File

@@ -9,6 +9,7 @@ import (
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
@@ -96,6 +97,55 @@ toolbar:
})
}
func TestListColumnTypeInferred(t *testing.T) {
const columns = `columns:
name:
label: Name
searchable: true
created_at:
label: Created
released_on:
label: Released
opens_at:
label: Opens
active:
label: Active
type: switch
`
type row struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
CreatedAt time.Time `gorm:"column:created_at"`
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
OpensAt lagoon.TimeOfDay `gorm:"column:opens_at;type:time without time zone"`
Active bool `gorm:"column:active"`
}
ctl := filterController{schemaController: schemaController{model: "Widget"}, rec: &row{}}
schema, err := CompileList("acme.demo", ctl, listFS(allColumnsConfig, columns))
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, col := range schema.Columns {
got[col.Key] = col.Type
}
want := map[string]string{
"name": "",
"created_at": "datetime",
"released_on": "date",
"opens_at": "time",
"active": "switch",
}
if len(got) != len(want) {
t.Fatalf("columns = %v, want %v", got, want)
}
for key, typ := range want {
if got[key] != typ {
t.Fatalf("column %s type = %q, want %q (%v)", key, got[key], typ, got)
}
}
}
func TestListSchemaEmpty(t *testing.T) {
const config = `list: ~/plugins/acme/demo/models/widget/columns.yaml
modelClass: Widget