Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6525d967c5 | ||
|
|
55314e41f8 | ||
|
|
8031fd1c16 |
@@ -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%
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)_
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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