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')