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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
modules/boardwalk/dist/assets/index-C1hSuckg.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-C1hSuckg.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-Cr97p7-8.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-Cr97p7-8.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user