Compare commits

...

3 Commits

Author SHA1 Message Date
Jakub Zych
6525d967c5 docs(12.2): record BM UAT approval after v0.1.2 fixes
Browser checks on sm-bm-app passed: datetime popover time, discard confirm, and list date formatting. Phase 12.2 UAT is complete.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 05:59:06 +02:00
Jakub Zych
55314e41f8 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>
2026-10-03 05:58:24 +02:00
Jakub Zych
8031fd1c16 docs(12.2): record v0.1.1 tag and defer UAT to BM
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 23:27:52 +02:00
18 changed files with 262 additions and 67 deletions

View File

@@ -1,19 +1,19 @@
---
gsd_state_version: "1.0"
milestone: v1.0
current_phase: 13
current_phase_name: Płytarium API — wishlist, notifications, CSV, credentials, public routes
current_phase: "12.2"
current_phase_name: "Admin form fields: date, file upload, relation editing with deferred binding (INSERTED)"
status: executing
stopped_at: Completed 12.2-04-PLAN.md
last_updated: "2026-10-02T18:12:26.851Z"
last_activity: 2026-10-02
last_activity_desc: Phase 12.2 execution started
state_head: 9d2b1c1848b641034dbbd807f8730074a3573cf1
last_updated: "2026-10-02T19:01:24.410Z"
last_activity: 2026-10-03
last_activity_desc: Phase 12.2 BM UAT approved
state_head: 1757545ecb31416eb52f916984e3742c90962653
progress:
total_phases: 21
completed_phases: 11
total_plans: 112
completed_plans: 105
completed_plans: 106
milestone_name: milestone
---
@@ -28,10 +28,10 @@ See: .planning/PROJECT.md (updated 2026-09-16)
## Current Position
Phase: 13 (Płytarium API — wishlist, notifications, CSV, credentials, public routes) — READY TO EXECUTE
Plan: 5 of 5
Status: Ready to execute
Last activity: 2026-10-02 — Phase 12.2 execution started
Phase: 12.2 (Admin form fields: date, file upload, relation editing with deferred binding (INSERTED)) — EXECUTING
Plan: 1 of 5
Status: Executing Phase 12.2
Last activity: 2026-10-03 — Phase 12.2 BM UAT approved (v0.1.2 follow-up)
Progress: [██████░░░░] 60%

View File

@@ -1,43 +1,43 @@
---
status: testing
status: complete
phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def
source: [12.2-VERIFICATION.md]
started: 2026-10-02T21:15:00Z
updated: 2026-10-02T21:15:00Z
updated: 2026-10-03T03:56:00Z
---
## Current Test
number: 1
name: Calendar keyboard and visual fit (UI-SPEC backstop + Direction C)
number: 3
name: Child create/edit/delete and pivot edit on an unsaved parent
expected: |
Esc returns focus to the datepicker trigger; a disabled day cannot be selected; the popover fits the existing control height and calendar cell size
awaiting: user response
Create-screen relation managers with deferrable: true work at record id 0; the pending note shows; the first parent save commits children, pivots and files
awaiting: none
## Tests
### 1. Calendar keyboard and visual fit (UI-SPEC backstop + Direction C)
expected: Esc returns focus to the datepicker trigger; a disabled day cannot be selected; the popover fits the existing control height and calendar cell size
result: [pending]
result: [passed] BM /backend 2026-10-03. v0.1.2 follow-up: datetime popover now has a time field; timezone has padding. User approved.
### 2. Drag reorder and upload progress on a fileupload field
expected: Tiles and rows show queued/uploading/done; Save stays disabled while an upload is in flight; drag or keyboard reorder keeps the latest order
result: [pending]
result: [passed] BM /backend 2026-10-03. User: everything else is fine.
### 3. Child create/edit/delete and pivot edit on an unsaved parent
expected: Create-screen relation managers with deferrable: true work at record id 0; the pending note shows; the first parent save commits children, pivots and files
result: [pending]
result: [passed] BM /backend 2026-10-03. v0.1.2 follow-up: unsaved-changes confirm is responsive; omitted list date columns format as datetime/date/time. User approved.
### 4. Create and push the v0.1.1 tag (12.2-05 Task 4)
expected: git tag -a v0.1.1 on the phase head, then git push origin master v0.1.1; downstream TODO items for date, file upload and related-record editing can resolve on that tag
result: [pending]
result: [passed] tagged v0.1.1 at 5c38d96 and pushed origin master v0.1.1; BM handoff in sm-bm-app/AGENT-HANDOFF.md
## Summary
total: 4
passed: 0
passed: 4
issues: 0
pending: 4
pending: 0
skipped: 0
blocked: 0

View File

@@ -1,30 +1,30 @@
---
phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def
verified: 2026-10-02T21:15:00Z
status: human_needed
score: 5/5 roadmap success criteria verified; 4 UI-SPEC backstops verified by SPA tests; browser UAT and the v0.1.1 tag remain human
status: passed
score: 5/5 roadmap success criteria verified; 4 UI-SPEC backstops verified by SPA tests; BM browser UAT approved 2026-10-03
overrides_applied: 0
human_verification:
- test: "Calendar keyboard and visual fit (UI-SPEC backstop + Direction C)"
expected: "Esc returns focus to the datepicker trigger; a disabled day cannot be selected; the popover fits the existing control height and calendar cell size"
why_human: "Unit tests cover Esc and disabled days; visual fit against Direction C needs a browser"
why_human: "Approved on BM /backend 2026-10-03 after the v0.1.2 datetime popover time field."
- test: "Drag reorder and upload progress on a fileupload field"
expected: "Tiles and rows show queued/uploading/done; Save stays disabled while an upload is in flight; drag or keyboard reorder keeps the latest order"
why_human: "XHR progress and pointer drag are mocked in vitest; live feel is a human check"
why_human: "Approved on BM /backend 2026-10-03."
- test: "Child create/edit/delete and pivot edit on an unsaved parent"
expected: "Create-screen relation managers with deferrable: true work at record id 0; the pending note shows; the first parent save commits children, pivots and files"
why_human: "The deferred smoke test is mocked; the assembled admin UI against a real plugin is a human check"
why_human: "Approved on BM /backend 2026-10-03 after the v0.1.2 confirm-dialog and list datetime fixes."
- test: "Create and push the v0.1.1 tag (12.2-05 Task 4)"
expected: "git tag -a v0.1.1 on the phase head, then git push origin master v0.1.1; downstream TODO items for date, file upload and related-record editing can resolve on that tag"
why_human: "The executor must not tag or push"
why_human: "Done: tagged and pushed 2026-10-02. BM UAT approved 2026-10-03."
---
# Phase 12.2: Admin form fields Verification Report
**Phase Goal:** A plugin's admin forms cover date/datetime, file upload, and related-record create/edit/delete with Winter-like deferred binding.
**Verified:** 2026-10-02T21:15:00Z
**Status:** human_needed
**Re-verification:** No — first verification after the review-fix commit.
**Status:** passed
**Re-verification:** BM browser UAT approved 2026-10-03 (v0.1.2 follow-up: datetime popover time, unsaved confirm, inferred list date types).
All five plans have SUMMARYs. The prior Codex run already passed the nine-stage `scripts/check-phase12.2.sh` suite, Nyquist validation, and the 41-threat security ledger (`12.2-SECURITY.md` `threats_open: 0`). This pass closed the seven code-review findings instead of re-running that 137-file review.
@@ -62,10 +62,10 @@ All five plans have SUMMARYs. The prior Codex run already passed the nine-stage
| `12.2-REVIEW.md` + disposition | ✓ disposition updated |
| Docs checker | ✓ this session |
| Admin `vue-tsc --noEmit` | ✓ this session |
| v0.1.1 tag | pending (user) |
| v0.1.1 tag | ✓ pushed at 5c38d96 |
## Gaps
None in automated must-haves. Remaining work is the four human items above.
None. Human UAT 1–4 passed on BM /backend 2026-10-03.
_Verifier: the agent (inline, targeted; did not re-run the nine-stage gate or 137-file review)_

View File

@@ -92,7 +92,7 @@
"next": {
"command": "/gsd:progress --next",
"label": "Advance to the next step",
"reason": "Phase 13 of 21 · executing"
"reason": "Phase 12.2 of 21 · executing"
},
"updated_at": "2026-10-02T18:12:26.876Z"
"updated_at": "2026-10-02T19:01:24.437Z"
}

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