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')
|
||||
|
||||
Reference in New Issue
Block a user