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:
@@ -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>
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
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