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

View File

@@ -21,7 +21,7 @@ import {
TimeFieldInput,
TimeFieldRoot,
} from 'reka-ui'
import type { DateValue, Time } from '@internationalized/date'
import { CalendarDateTime, Time, type DateValue } from '@internationalized/date'
import { Calendar, ChevronLeft, ChevronRight, Clock, X } from '@lucide/vue'
import { currentLocale, t } from '../../../app/i18n'
import {
@@ -127,11 +127,64 @@ function onUpdate(next: DateValue | Time | undefined | null): void {
emit('update:modelValue', text)
}
/** A calendar day click replaces the instant; keep the time already in the field. */
function onDatePickerUpdate(next: DateValue | undefined | null): void {
if (!next || mode.value !== 'datetime') {
onUpdate(next)
return
}
const previous = dateValue.value
const clock = popoverTime.value
const dateChanged =
!previous ||
previous.year !== next.year ||
previous.month !== next.month ||
previous.day !== next.day
const timeDiffers = !('hour' in next) || next.hour !== clock.hour || next.minute !== clock.minute
if (dateChanged && timeDiffers) {
next = withClock(next, clock)
}
pickedDate.value = next
onUpdate(next)
}
function clear(): void {
emit('update:modelValue', null)
scheduleRefresh()
}
/** The clock shown in the datetime popover, or midnight when the day is empty. */
const pickedDate = ref<DateValue | null>(null)
watch(dateValue, (value) => {
pickedDate.value = value
}, { immediate: true })
const popoverTime = computed((): Time => {
const current = pickedDate.value ?? dateValue.value
if (current && 'hour' in current) {
return new Time(current.hour, current.minute)
}
return new Time(0, 0)
})
function withClock(value: DateValue, clock: Time): DateValue {
if ('hour' in value) {
return (value as CalendarDateTime).set({ hour: clock.hour, minute: clock.minute, second: 0, millisecond: 0 })
}
return new CalendarDateTime(value.year, value.month, value.day, clock.hour, clock.minute)
}
function onPopoverTime(next: { hour: number; minute: number } | undefined | null): void {
if (!next) {
return
}
const clock = next instanceof Time ? next : new Time(next.hour, next.minute)
const current = (pickedDate.value ?? dateValue.value ?? placeholderValue) as DateValue
const updated = withClock(current, clock)
pickedDate.value = updated
onUpdate(updated)
}
function ucfirst(text: string): string {
return text.charAt(0).toLocaleUpperCase(locale.value) + text.slice(1)
}
@@ -148,7 +201,7 @@ function segmentClass(part: string): string {
return 'text-muted'
}
if (part === 'timeZoneName') {
return 'min-w-0 truncate text-[13px] text-muted'
return 'ml-2 min-w-0 truncate text-[13px] text-muted'
}
return 'rounded-[4px] px-0.5 tabular-nums outline-none focus:bg-sel focus:text-text focus-visible:outline-none data-[placeholder]:text-placeholder focus:data-[placeholder]:text-text'
}
@@ -226,9 +279,9 @@ const iconButton =
:week-starts-on="weekStartsOn"
:name="field.name"
:required="field.required || undefined"
close-on-select
:close-on-select="mode === 'date'"
prevent-deselect
@update:model-value="onUpdate"
@update:model-value="onDatePickerUpdate"
>
<div
ref="container"
@@ -312,6 +365,32 @@ const iconButton =
</DatePickerGridBody>
</DatePickerGrid>
</DatePickerCalendar>
<div
v-if="mode === 'datetime'"
data-datepicker-time
class="mt-3 flex items-center gap-2 border-t border-border pt-3"
>
<Clock :size="16" class="shrink-0 text-muted" aria-hidden="true" />
<TimeFieldRoot
v-slot="{ segments }"
:model-value="popoverTime"
:locale="locale"
:hour-cycle="hourCycle"
granularity="minute"
hide-time-zone
class="flex min-w-0 items-center"
@update:model-value="onPopoverTime"
>
<TimeFieldInput
v-for="item in segments"
:key="item.part"
:part="item.part"
:class="segmentClass(item.part)"
>
{{ item.value }}
</TimeFieldInput>
</TimeFieldRoot>
</div>
</DatePickerContent>
</DatePickerRoot>
</template>

View File

@@ -29,7 +29,7 @@ const props = withDefaults(
)
const emit = defineEmits<{ 'update:open': [open: boolean]; confirm: []; cancel: [] }>()
// Closing any other way than the confirm button (Esc, Anuluj) cancels.
// Closing any other way than the confirm button (Esc, overlay, Anuluj) cancels.
function onOpenChange(open: boolean): void {
if (!open && props.busy) {
return
@@ -52,10 +52,10 @@ function onConfirm(): void {
<template>
<AlertDialogRoot :open="open" @update:open="onOpenChange">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-40 bg-overlay" />
<AlertDialogOverlay class="pointer-events-auto fixed inset-0 z-[80] bg-overlay" />
<AlertDialogContent
data-confirm-dialog
class="fixed top-1/2 left-1/2 z-50 flex w-[calc(100%-32px)] max-w-[440px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop"
class="pointer-events-auto fixed top-1/2 left-1/2 z-[90] flex w-[calc(100%-32px)] max-w-[440px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop"
>
<AlertDialogTitle class="text-[20px] font-bold tracking-[-0.01em]">
{{ title ?? t('backend::lang.form.confirm') }}
@@ -76,7 +76,7 @@ function onConfirm(): void {
:aria-busy="busy ? 'true' : undefined"
:class="danger ? 'bg-danger text-white' : 'bg-primary text-on-primary'"
class="inline-flex h-button flex-1 items-center justify-center gap-2 rounded-control font-semibold hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
@click="onConfirm"
@click.prevent="onConfirm"
>
<LoaderCircle v-if="busy" :size="16" class="animate-spin" aria-hidden="true" />
{{ confirmLabel ?? t('backend::lang.form.confirm') }}

View File

@@ -344,6 +344,21 @@ async function guard(): Promise<boolean> {
})
}
/** Anuluj / back: ask here so the confirm is not tied to a pending RouterLink. */
async function onLeave(): Promise<void> {
if (dirty.value) {
const ok = await confirm.ask({
message: t('backend::lang.form.unsaved_confirm'),
confirmLabel: t('backend::lang.form.discard'),
danger: true,
})
if (!ok) {
return
}
}
await go(listPath)
}
onBeforeRouteLeave(guard)
onBeforeRouteUpdate(guard)
@@ -370,9 +385,9 @@ void load()
<Button
variant="outline"
data-action="back"
:to="listPath"
:aria-label="t('backend::lang.form.return_to_list')"
class="size-10! px-0!"
@click="onLeave"
>
<ArrowLeft :size="18" aria-hidden="true" />
</Button>
@@ -424,7 +439,7 @@ void load()
{{ t('backend::lang.form.delete') }}
</Button>
<div class="ml-auto flex items-center gap-2.5">
<Button variant="ghost" data-action="cancel" :to="listPath">{{ t('backend::lang.form.cancel') }}</Button>
<Button variant="ghost" data-action="cancel" @click="onLeave">{{ t('backend::lang.form.cancel') }}</Button>
<Button variant="outline" data-action="save-close" :disabled="busy || loading || !schema || activeUploads > 0" @click="onSaveAndClose">
{{ t('backend::lang.form.save_and_close') }}
</Button>
@@ -441,6 +456,7 @@ void load()
:danger="confirm.request.value?.danger"
@confirm="confirm.confirm"
@cancel="confirm.cancel"
@update:open="(open) => { if (!open) confirm.cancel() }"
/>
</section>
</template>

View File

@@ -124,6 +124,31 @@ describe('calendar popover (backstop 1)', () => {
wrapper.unmount()
})
it('shows a time picker in the datetime popover and keeps the popover open after a day click', async () => {
const wrapper = mountField(field(), '2026-10-02T10:30:00Z')
await wrapper.find('[data-action="open-calendar"]').trigger('click')
await flushPromises()
const popover = document.body.querySelector<HTMLElement>('[data-datepicker-calendar]')
expect(popover).not.toBeNull()
const clock = popover!.querySelector<HTMLElement>('[data-datepicker-time]')
expect(clock).not.toBeNull()
expect(clock!.querySelector('[data-reka-time-field-segment="hour"]')?.textContent).toBe('12')
expect(clock!.querySelector('[data-reka-time-field-segment="minute"]')?.textContent).toBe('30')
const day = Array.from(popover!.querySelectorAll<HTMLElement>('[data-reka-calendar-cell-trigger]')).find(
(el) => el.textContent?.trim() === '3' && !el.hasAttribute('data-outside-view'),
)
day?.click()
await flushPromises()
expect(document.body.querySelector('[data-datepicker-calendar]')).not.toBeNull()
expect(emitted(wrapper).at(-1)).toBe('2026-10-03T10:30:00Z')
const minute = clock!.querySelector<HTMLElement>('[data-reka-time-field-segment="minute"]')!
minute.focus()
keydown(minute, 'ArrowUp')
await flushPromises()
expect(emitted(wrapper).at(-1)).toBe('2026-10-03T10:31:00Z')
wrapper.unmount()
})
it('starts the week on the field firstDay, else on the locale first day', async () => {
const heads = async (f: FormField) => {
const wrapper = mountField(f, '2026-10-10')

View File

@@ -142,7 +142,7 @@ The field takes the generic keys plus these WinterCMS keys:
`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.
`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. The datetime picker's calendar popover also has a time field, so the clock can be set without typing in the input.
## File uploads

View File

@@ -56,7 +56,7 @@ columns:
type: datetime
```
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:
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. When `type` is omitted, a `time.Time` column is `datetime`, a `lagoon.Date` column is `date` and a `lagoon.TimeOfDay` column is `time`; every other column stays untyped (the admin treats that as text). An explicit `type` always wins. 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

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