feat(12.2-04): add the datepicker field and stored date and time list cells

- pin @internationalized/date 3.12.4 as a direct admin dependency (approved)
- dateFormat.ts parses and emits date, datetime (local display, UTC emit,
  ignoreTimezone wall clock) and time values without the global Date
- DatepickerField on Reka DatePicker and TimeField with locale segments,
  calendar popover, clear button, min/max and yearRange bounds
- list cells of type date and time render the stored string
- datepicker lang keys in en and pl; rebuilt boardwalk dist
This commit is contained in:
Jakub Zych
2026-10-02 19:45:43 +02:00
parent d66812caef
commit a0c182745e
15 changed files with 797 additions and 17 deletions

View File

@@ -8,6 +8,7 @@
"dependencies": { "dependencies": {
"@fontsource/dm-mono": "5.3.0", "@fontsource/dm-mono": "5.3.0",
"@fontsource/dm-sans": "5.3.0", "@fontsource/dm-sans": "5.3.0",
"@internationalized/date": "3.12.4",
"@lucide/vue": "1.17.0", "@lucide/vue": "1.17.0",
"openapi-fetch": "0.17.0", "openapi-fetch": "0.17.0",
"reka-ui": "2.9.10", "reka-ui": "2.9.10",

View File

@@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@fontsource/dm-mono": "5.3.0", "@fontsource/dm-mono": "5.3.0",
"@fontsource/dm-sans": "5.3.0", "@fontsource/dm-sans": "5.3.0",
"@internationalized/date": "3.12.4",
"@lucide/vue": "1.17.0", "@lucide/vue": "1.17.0",
"openapi-fetch": "0.17.0", "openapi-fetch": "0.17.0",
"reka-ui": "2.9.10", "reka-ui": "2.9.10",

300
admin/src/app/dateFormat.ts Normal file
View File

@@ -0,0 +1,300 @@
// Datepicker values (Phase 12.2, D-18 to D-21). The server stores a `date`
// as YYYY-MM-DD, a `time` as HH:MM:SS and a `datetime` as an RFC 3339
// instant; the picker edits @internationalized/date values. This module is
// the only place that converts between the two, and it never builds a
// global Date from a stored string: a date-only value has no time zone, so
// the browser's Date would shift it (RESEARCH Pitfall 12).
import {
CalendarDate,
CalendarDateTime,
DateFormatter,
Time,
ZonedDateTime,
getDayOfWeek,
getLocalTimeZone,
parseAbsolute,
parseDate,
parseTime,
toCalendarDateTime,
toTimeZone,
toZoned,
today,
} from '@internationalized/date'
/** A datepicker field's mode; the server defaults it to datetime. */
export type DateMode = 'date' | 'datetime' | 'time'
/** The picker's value: a calendar date, a date and time, or a time of day. */
export type FieldDateValue = CalendarDate | CalendarDateTime | ZonedDateTime | Time
/** The mode of a field's `mode` key: date, time, or datetime by default. */
export function dateMode(mode: string | null | undefined): DateMode {
return mode === 'date' || mode === 'time' ? mode : 'datetime'
}
function pad(value: number, length = 2): string {
return String(value).padStart(length, '0')
}
const ZONE_SUFFIX = /(Z|[+-]\d{2}:?\d{2})$/i
/**
* The picker value of a stored string, or null when it is empty or cannot
* be read. A datetime is shown in the browser's time zone; with
* ignoreTimezone its UTC wall clock is shown unchanged.
*/
export function parseFieldValue(mode: DateMode, raw: unknown, ignoreTimezone = false): FieldDateValue | null {
if (typeof raw !== 'string' || raw.trim() === '') {
return null
}
const text = raw.trim()
try {
if (mode === 'date') {
return parseDate(text.slice(0, 10))
}
if (mode === 'time') {
const clock = /(\d{2}:\d{2}(:\d{2})?)/.exec(text.includes('T') ? text.slice(text.indexOf('T')) : text)
return clock ? parseTime(clock[1]!) : null
}
// A datetime without a zone designator is read as UTC, like the server.
const absolute = ZONE_SUFFIX.test(text) ? text.replace(' ', 'T') : `${text.replace(' ', 'T')}Z`
if (ignoreTimezone) {
return toCalendarDateTime(parseAbsolute(absolute, 'UTC'))
}
return parseAbsolute(absolute, getLocalTimeZone())
} catch {
return null
}
}
function dateText(value: { year: number; month: number; day: number }): string {
return `${pad(value.year, 4)}-${pad(value.month)}-${pad(value.day)}`
}
function timeText(value: { hour: number; minute: number; second: number }): string {
return `${pad(value.hour)}:${pad(value.minute)}:${pad(value.second)}`
}
/**
* The stored string of a picker value: YYYY-MM-DD for a date, HH:MM:SS for
* a time, an RFC 3339 UTC instant for a datetime, and with ignoreTimezone
* the wall clock unchanged as YYYY-MM-DDTHH:MM:SSZ. An empty value is null.
*/
export function emitFieldValue(
mode: DateMode,
value: FieldDateValue | null | undefined,
ignoreTimezone = false,
): string | null {
if (!value) {
return null
}
if (mode === 'time') {
return 'hour' in value ? timeText(value) : null
}
if (!('year' in value)) {
return null
}
if (mode === 'date') {
return dateText(value)
}
if (ignoreTimezone) {
const wall = value instanceof ZonedDateTime ? toCalendarDateTime(value) : value
const clock = 'hour' in wall ? timeText(wall) : '00:00:00'
return `${dateText(wall)}T${clock}Z`
}
const zoned =
value instanceof ZonedDateTime
? value
: toZoned(value instanceof CalendarDate ? toCalendarDateTime(value) : value, getLocalTimeZone())
const utc = toTimeZone(zoned, 'UTC')
return `${dateText(utc)}T${timeText(utc)}Z`
}
/** The default display format of each mode (the list's datetime cell shapes). */
export const DEFAULT_DISPLAY: Record<DateMode, string> = {
date: 'YYYY-MM-DD',
datetime: 'YYYY-MM-DD HH:mm',
time: 'HH:mm',
}
const TOKENS = /\[([^\]]*)\]|YYYY|YY|MMMM|MMM|MM|M|dddd|ddd|DD|D|HH|H|hh|h|mm|ss|A|a/g
function calendarDay(value: FieldDateValue): CalendarDate | null {
return 'year' in value ? new CalendarDate(value.year, value.month, value.day) : null
}
function namePart(
locale: string,
date: CalendarDate,
options: Intl.DateTimeFormatOptions,
part: Intl.DateTimeFormatPartTypes,
): string {
// The formatter sees the calendar day at UTC midnight and formats it in
// UTC, so no zone can move it to another day.
const formatter = new DateFormatter(locale, { ...options, timeZone: 'UTC' })
return formatter.formatToParts(date.toDate('UTC')).find((item) => item.type === part)?.value ?? ''
}
/**
* A picker value as text in the server's displayFormat (moment-style tokens
* DD D MM M MMM MMMM ddd dddd YYYY YY HH H hh h mm ss A a; [text] is
* literal), or in the mode's default format. Day and month names follow the
* admin locale. Empty values give an empty string.
*/
export function formatDisplay(
value: FieldDateValue | null | undefined,
mode: DateMode,
displayFormat: string | null | undefined,
locale: string,
): string {
if (!value) {
return ''
}
const format = displayFormat || DEFAULT_DISPLAY[mode]
const day = calendarDay(value)
const clock = 'hour' in value ? value : null
// A month name next to a day number takes the form a date uses (Polish
// genitive, "2 października"); alone it is the standalone name.
const withDay = /D/.test(format.replace(/\[[^\]]*\]/g, ''))
return format.replace(TOKENS, (token: string, literal: string | undefined) => {
if (literal !== undefined) {
return literal
}
switch (token) {
case 'YYYY':
return day ? pad(day.year, 4) : ''
case 'YY':
return day ? pad(day.year % 100) : ''
case 'MMMM':
case 'MMM': {
if (!day) {
return ''
}
const month = token === 'MMMM' ? 'long' : 'short'
return withDay
? namePart(locale, day, { day: 'numeric', month }, 'month')
: namePart(locale, day, { month }, 'month')
}
case 'MM':
return day ? pad(day.month) : ''
case 'M':
return day ? String(day.month) : ''
case 'dddd':
case 'ddd':
return day ? namePart(locale, day, { weekday: token === 'dddd' ? 'long' : 'short' }, 'weekday') : ''
case 'DD':
return day ? pad(day.day) : ''
case 'D':
return day ? String(day.day) : ''
case 'HH':
return clock ? pad(clock.hour) : ''
case 'H':
return clock ? String(clock.hour) : ''
case 'hh':
return clock ? pad(clock.hour % 12 || 12) : ''
case 'h':
return clock ? String(clock.hour % 12 || 12) : ''
case 'mm':
return clock ? pad(clock.minute) : ''
case 'ss':
return clock ? pad(clock.second) : ''
case 'A':
return clock ? (clock.hour < 12 ? 'AM' : 'PM') : ''
case 'a':
return clock ? (clock.hour < 12 ? 'am' : 'pm') : ''
default:
return token
}
})
}
/** A known Sunday: its weekday index in a locale gives that locale's first day. */
const SUNDAY = new CalendarDate(2026, 10, 4)
/**
* The calendar's first day of the week, 0 (Sunday) to 6: the field's
* firstDay when set, else the locale's own (Monday for pl).
*/
export function weekStart(locale: string, firstDay?: number | null): 0 | 1 | 2 | 3 | 4 | 5 | 6 {
if (typeof firstDay === 'number' && Number.isInteger(firstDay) && firstDay >= 0 && firstDay <= 6) {
return firstDay as 0 | 1 | 2 | 3 | 4 | 5 | 6
}
try {
// getDayOfWeek counts from the locale's first day, so Sunday's index
// tells how far that first day is from Sunday.
return ((7 - getDayOfWeek(SUNDAY, locale)) % 7) as 0 | 1 | 2 | 3 | 4 | 5 | 6
} catch {
return 1
}
}
/** The calendar bounds of a field, as plain calendar days. */
export interface DayBounds {
min?: CalendarDate
max?: CalendarDate
}
function boundDay(text: string | null | undefined): CalendarDate | undefined {
if (!text) {
return undefined
}
try {
return parseDate(text.slice(0, 10))
} catch {
return undefined
}
}
/**
* The selectable days of a field: minDate and maxDate when either is set,
* else the yearRange around today ([n] years either side, or [from, to]).
* The server checks minDate and maxDate again on save.
*/
export function dayBounds(
field: { minDate?: string; maxDate?: string; yearRange?: number[] },
now: CalendarDate = today(getLocalTimeZone()),
): DayBounds {
const min = boundDay(field.minDate)
const max = boundDay(field.maxDate)
if (min || max) {
return { min, max }
}
const range = field.yearRange ?? []
if (range.length === 1 && typeof range[0] === 'number') {
return { min: new CalendarDate(now.year - range[0], 1, 1), max: new CalendarDate(now.year + range[0], 12, 31) }
}
if (range.length === 2 && typeof range[0] === 'number' && typeof range[1] === 'number') {
return { min: new CalendarDate(range[0], 1, 1), max: new CalendarDate(range[1], 12, 31) }
}
return {}
}
/**
* A day bound in the picker's value type: the start of the first allowed
* day and the last second of the last one, so a datetime on the bound day
* itself stays valid.
*/
export function boundValue(
day: CalendarDate | undefined,
mode: DateMode,
ignoreTimezone: boolean,
end: boolean,
): CalendarDate | CalendarDateTime | ZonedDateTime | undefined {
if (!day || mode === 'time') {
return undefined
}
if (mode === 'date') {
return day
}
const wall = new CalendarDateTime(day.year, day.month, day.day, end ? 23 : 0, end ? 59 : 0, end ? 59 : 0)
return ignoreTimezone ? wall : toZoned(wall, getLocalTimeZone())
}
/**
* The value a fresh picker starts from (segments show placeholders): today
* at midnight in the picker's value type.
*/
export function startValue(mode: DateMode, ignoreTimezone: boolean): CalendarDate | CalendarDateTime | ZonedDateTime {
const day = today(getLocalTimeZone())
return boundValue(day, mode === 'time' ? 'datetime' : mode, ignoreTimezone, false) ?? day
}

View File

@@ -0,0 +1,316 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import {
DatePickerCalendar,
DatePickerCell,
DatePickerCellTrigger,
DatePickerContent,
DatePickerField,
DatePickerGrid,
DatePickerGridBody,
DatePickerGridHead,
DatePickerGridRow,
DatePickerHeadCell,
DatePickerHeader,
DatePickerHeading,
DatePickerInput,
DatePickerNext,
DatePickerPrev,
DatePickerRoot,
DatePickerTrigger,
TimeFieldInput,
TimeFieldRoot,
} from 'reka-ui'
import type { DateValue, Time } from '@internationalized/date'
import { Calendar, ChevronLeft, ChevronRight, Clock, X } from '@lucide/vue'
import { currentLocale, t } from '../../../app/i18n'
import {
boundValue,
dateMode,
dayBounds,
emitFieldValue,
formatDisplay,
parseFieldValue,
startValue,
weekStart,
type FieldDateValue,
} from '../../../app/dateFormat'
import { controlClass, type FieldControlProps } from '../control'
// `type: datepicker` (Phase 12.2, D-18 to D-21; UI-SPEC section 1). Date and
// datetime modes are a Reka DatePicker (segments plus a calendar popover),
// time mode a Reka TimeField. Values are @internationalized/date objects in
// the picker and plain strings in the form: a date is YYYY-MM-DD, a time
// HH:MM:SS, a datetime an RFC 3339 UTC instant shown in the browser's time
// zone (or, with ignoreTimezone, its wall clock unchanged). Segment order
// follows the admin locale; the field's display format only shapes the
// read-only text. No native date input and no global Date are used.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
const mode = computed(() => dateMode(props.field.mode))
const ignoreTimezone = computed(() => mode.value === 'datetime' && !!props.field.ignoreTimezone)
const locale = computed(() => currentLocale.value || 'pl')
const readOnly = computed(() => {
const attribute = props.field.attributes?.readonly
return !!props.field.readOnly || (attribute !== undefined && attribute !== null && attribute !== false)
})
const value = computed<FieldDateValue | null>(() =>
parseFieldValue(mode.value, props.modelValue, ignoreTimezone.value),
)
const dateValue = computed(() => (mode.value === 'time' ? null : (value.value as DateValue | null)))
const timeValue = computed(() => (mode.value === 'time' ? (value.value as Time | null) : null))
const bounds = computed(() => dayBounds(props.field))
const minValue = computed(() => boundValue(bounds.value.min, mode.value, ignoreTimezone.value, false))
const maxValue = computed(() => boundValue(bounds.value.max, mode.value, ignoreTimezone.value, true))
const placeholderValue = startValue(mode.value, ignoreTimezone.value)
const weekStartsOn = computed(() => weekStart(locale.value, props.field.firstDay))
const hourCycle = computed(() => (props.field.twelveHour ? 12 : 24))
const granularity = computed(() => (mode.value === 'date' ? 'day' : 'minute'))
const display = computed(() => formatDisplay(value.value, mode.value, props.field.displayFormat, locale.value))
const showClear = computed(() => !props.field.required && value.value !== null && !readOnly.value)
// A value outside minDate/maxDate is flagged before the save (the server
// refuses it too).
const outOfRange = computed(() => {
const current = dateValue.value
if (!current) {
return false
}
return (!!minValue.value && current.compare(minValue.value) < 0) || (!!maxValue.value && current.compare(maxValue.value) > 0)
})
// Some segments typed and others still empty: Reka holds no value then, the
// form gets null, and the field stays marked invalid until every segment is
// filled or all are cleared.
const NUMBER_PARTS = new Set(['day', 'month', 'year', 'hour', 'minute', 'second'])
const container = ref<HTMLElement | null>(null)
const partial = ref(false)
function refreshPartial(): void {
const root = container.value
if (!root) {
partial.value = false
return
}
// Only the editable number segments count: literals, the zone name, the
// day period and the calendar trigger carry the same data attribute.
const segments = Array.from(root.querySelectorAll<HTMLElement>('[data-reka-date-field-segment]')).filter((el) =>
NUMBER_PARTS.has(el.dataset.rekaDateFieldSegment ?? ''),
)
const filled = segments.filter((el) => !el.hasAttribute('data-placeholder')).length
partial.value = filled > 0 && filled < segments.length
}
function scheduleRefresh(): void {
void nextTick(refreshPartial)
}
watch(value, scheduleRefresh)
const markedInvalid = computed(() => !!props.invalid || outOfRange.value || partial.value)
/** The stored string of the current form value, normalised like an emit. */
const currentText = computed(() => emitFieldValue(mode.value, value.value, ignoreTimezone.value))
function onUpdate(next: DateValue | Time | undefined | null): void {
const text = emitFieldValue(mode.value, (next ?? null) as FieldDateValue | null, ignoreTimezone.value)
scheduleRefresh()
if (text === currentText.value && (text !== null || props.modelValue === null)) {
return
}
emit('update:modelValue', text)
}
function clear(): void {
emit('update:modelValue', null)
scheduleRefresh()
}
function ucfirst(text: string): string {
return text.charAt(0).toLocaleUpperCase(locale.value) + text.slice(1)
}
const containerClass = computed(() => [
controlClass(markedInvalid.value),
'h-input flex min-w-0 items-center gap-1 pl-3.5 pr-1.5',
'focus-within:border-primary focus-within:shadow-[0_0_0_3px_var(--c-ring)]',
'has-[[data-invalid]]:border-danger',
])
function segmentClass(part: string): string {
if (part === 'literal') {
return 'text-muted'
}
if (part === 'timeZoneName') {
return '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'
}
const iconButton =
'flex size-8 shrink-0 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text'
</script>
<template>
<!-- Read-only: the value as text in the display format, or a muted dash. -->
<p
v-if="readOnly"
:id="controlId"
data-datepicker-readonly
:aria-describedby="describedBy || undefined"
class="flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5 tabular-nums"
>
<span v-if="display !== ''">{{ display }}</span>
<span v-else class="text-muted" data-empty>—</span>
</p>
<!-- Time of day: segments only, a clock icon where the trigger would be. -->
<div
v-else-if="mode === 'time'"
ref="container"
data-datepicker
data-mode="time"
:class="containerClass"
@keyup="scheduleRefresh"
@focusout="scheduleRefresh"
>
<TimeFieldRoot
:id="controlId"
v-slot="{ segments }"
:model-value="timeValue"
:locale="locale"
:hour-cycle="hourCycle"
granularity="minute"
:name="field.name"
:required="field.required || undefined"
:aria-invalid="markedInvalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
class="flex min-w-0 flex-1 items-center"
@update:model-value="onUpdate"
>
<TimeFieldInput
v-for="item in segments"
:key="item.part"
:part="item.part"
:class="segmentClass(item.part)"
>
{{ item.value }}
</TimeFieldInput>
</TimeFieldRoot>
<button v-if="showClear" type="button" data-action="clear" :aria-label="t('backend::lang.datepicker.clear')" :class="iconButton" @click="clear">
<X :size="16" aria-hidden="true" />
</button>
<span class="flex size-8 shrink-0 items-center justify-center text-muted" aria-hidden="true">
<Clock :size="16" />
</span>
</div>
<!-- Date and datetime: segments plus the calendar popover. -->
<DatePickerRoot
v-else
:id="controlId"
:model-value="dateValue"
:default-placeholder="placeholderValue"
:locale="locale"
:granularity="granularity"
:hour-cycle="hourCycle"
:hide-time-zone="mode !== 'datetime' || ignoreTimezone"
:min-value="minValue"
:max-value="maxValue"
:week-starts-on="weekStartsOn"
:name="field.name"
:required="field.required || undefined"
close-on-select
prevent-deselect
@update:model-value="onUpdate"
>
<div
ref="container"
data-datepicker
:data-mode="mode"
:class="containerClass"
@keyup="scheduleRefresh"
@focusout="scheduleRefresh"
>
<DatePickerField
v-slot="{ segments }"
:aria-invalid="markedInvalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
class="flex min-w-0 flex-1 items-center overflow-hidden"
>
<DatePickerInput
v-for="item in segments"
:key="item.part"
:part="item.part"
:class="segmentClass(item.part)"
>
{{ item.value }}
</DatePickerInput>
</DatePickerField>
<button v-if="showClear" type="button" data-action="clear" :aria-label="t('backend::lang.datepicker.clear')" :class="iconButton" @click="clear">
<X :size="16" aria-hidden="true" />
</button>
<DatePickerTrigger data-action="open-calendar" :aria-label="t('backend::lang.datepicker.open_calendar')" :class="iconButton">
<Calendar :size="16" aria-hidden="true" />
</DatePickerTrigger>
</div>
<DatePickerContent
data-datepicker-calendar
:side-offset="4"
align="end"
class="z-50 w-[284px] rounded-inner border border-border bg-surface p-4 text-text shadow-menu"
>
<DatePickerCalendar v-slot="{ weekDays, grid }">
<DatePickerHeader class="flex h-9 items-center justify-between">
<DatePickerPrev :aria-label="t('backend::lang.datepicker.prev_month')" :class="iconButton">
<ChevronLeft :size="16" aria-hidden="true" />
</DatePickerPrev>
<DatePickerHeading v-slot="{ headingValue }" class="font-semibold">
{{ ucfirst(headingValue) }}
</DatePickerHeading>
<DatePickerNext :aria-label="t('backend::lang.datepicker.next_month')" :class="iconButton">
<ChevronRight :size="16" aria-hidden="true" />
</DatePickerNext>
</DatePickerHeader>
<DatePickerGrid
v-for="month in grid"
:key="month.value.toString()"
class="mt-2 w-full border-collapse select-none"
>
<DatePickerGridHead>
<DatePickerGridRow class="flex">
<DatePickerHeadCell
v-for="day in weekDays"
:key="day"
class="flex h-9 w-9 items-center justify-center text-[13px] font-normal text-muted"
>
{{ day }}
</DatePickerHeadCell>
</DatePickerGridRow>
</DatePickerGridHead>
<DatePickerGridBody>
<DatePickerGridRow
v-for="(weekDates, index) in month.rows"
:key="`week-${index}`"
class="flex"
>
<DatePickerCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate" class="p-0">
<DatePickerCellTrigger
:day="weekDate"
:month="month.value"
class="flex size-9 items-center justify-center rounded-pager tabular-nums text-text transition-colors duration-150 ease-out hover:bg-hover data-[outside-view]:text-placeholder data-[today]:font-semibold data-[today]:shadow-[inset_0_0_0_1px_var(--c-border-strong)] data-[selected]:bg-primary data-[selected]:font-semibold data-[selected]:text-on-primary data-[disabled]:cursor-not-allowed data-[disabled]:text-placeholder data-[disabled]:opacity-40 data-[disabled]:hover:bg-transparent data-[unavailable]:cursor-not-allowed data-[unavailable]:opacity-40"
/>
</DatePickerCell>
</DatePickerGridRow>
</DatePickerGridBody>
</DatePickerGrid>
</DatePickerCalendar>
</DatePickerContent>
</DatePickerRoot>
</template>

View File

@@ -8,9 +8,11 @@
// hold no form value either, but they render on create and update. // hold no form value either, but they render on create and update.
// Phase 12.2 adds the fileupload control: it edits its files through the file // Phase 12.2 adds the fileupload control: it edits its files through the file
// routes against the form's session key (D-02, D-03), so it holds no form // routes against the form's session key (D-02, D-03), so it holds no form
// value either and renders on create and update. // value either and renders on create and update. The datepicker control is
// a plain value field: its string value is part of the save body (D-18).
import type { Component } from 'vue' import type { Component } from 'vue'
import CheckboxField from './fields/CheckboxField.vue' import CheckboxField from './fields/CheckboxField.vue'
import DatepickerField from './fields/DatepickerField.vue'
import DropdownField from './fields/DropdownField.vue' import DropdownField from './fields/DropdownField.vue'
import FileuploadField from './fields/FileuploadField.vue' import FileuploadField from './fields/FileuploadField.vue'
import NumberField from './fields/NumberField.vue' import NumberField from './fields/NumberField.vue'
@@ -44,6 +46,7 @@ const renderers = new Map<string, Component>([
['widget', WidgetField], ['widget', WidgetField],
['partial', PartialField], ['partial', PartialField],
['fileupload', FileuploadField], ['fileupload', FileuploadField],
['datepicker', DatepickerField],
]) ])
/** Types whose control shows the label itself (toggle cards, relation manager). */ /** Types whose control shows the label itself (toggle cards, relation manager). */

View File

@@ -7,6 +7,9 @@ import { t } from '../../app/i18n'
// One cell rendered by column type (D-12): text by default (arrays // One cell rendered by column type (D-12): text by default (arrays
// comma-joined, a muted dash when empty), datetime as YYYY-MM-DD HH:mm with // comma-joined, a muted dash when empty), datetime as YYYY-MM-DD HH:mm with
// tabular numbers, switch as the Tak/Nie pills. No per-value icon pills. // tabular numbers, switch as the Tak/Nie pills. No per-value icon pills.
// Phase 12.2: a `date` column shows the stored YYYY-MM-DD and a `time`
// column the stored HH:mm, both as strings: a calendar date or a time of day
// has no zone, so it never goes through the global Date (Pitfall 12).
const props = defineProps<{ column: ListColumn; value: unknown }>() const props = defineProps<{ column: ListColumn; value: unknown }>()
function isEmpty(value: unknown): boolean { function isEmpty(value: unknown): boolean {
@@ -44,6 +47,24 @@ function datetime(value: unknown): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
} }
/** A stored date as-is (YYYY-MM-DD); anything after the day is dropped. */
function storedDate(value: unknown): string {
if (typeof value !== 'string') {
return ''
}
const match = /^\d{4}-\d{2}-\d{2}/.exec(value.trim())
return match ? match[0] : value.trim()
}
/** A stored time of day as HH:mm (seconds dropped). */
function storedTime(value: unknown): string {
if (typeof value !== 'string') {
return ''
}
const match = /(\d{2}):(\d{2})/.exec(value)
return match ? `${match[1]}:${match[2]}` : value.trim()
}
function truthy(value: unknown): boolean { function truthy(value: unknown): boolean {
if (typeof value === 'string') { if (typeof value === 'string') {
return value !== '' && value !== '0' && value.toLowerCase() !== 'false' return value !== '' && value !== '0' && value.toLowerCase() !== 'false'
@@ -58,10 +79,27 @@ const kind = computed(() => {
if (props.column.type === 'datetime') { if (props.column.type === 'datetime') {
return 'datetime' return 'datetime'
} }
if (props.column.type === 'date') {
return 'date'
}
if (props.column.type === 'time') {
return 'time'
}
return 'text' return 'text'
}) })
const display = computed(() => (kind.value === 'datetime' ? datetime(props.value) : text(props.value))) const display = computed(() => {
switch (kind.value) {
case 'datetime':
return datetime(props.value)
case 'date':
return storedDate(props.value)
case 'time':
return storedTime(props.value)
default:
return text(props.value)
}
})
</script> </script>
<template> <template>
@@ -81,6 +119,6 @@ const display = computed(() => (kind.value === 'datetime' ? datetime(props.value
> >
</template> </template>
<span v-else-if="display === ''" class="text-muted" data-empty>—</span> <span v-else-if="display === ''" class="text-muted" data-empty>—</span>
<span v-else-if="kind === 'datetime'" class="text-[13px] text-muted tabular-nums">{{ display }}</span> <span v-else-if="kind === 'datetime' || kind === 'date' || kind === 'time'" class="text-[13px] text-muted tabular-nums">{{ display }}</span>
<template v-else>{{ display }}</template> <template v-else>{{ display }}</template>
</template> </template>

View File

@@ -8,6 +8,20 @@
"type": "text", "type": "text",
"required": true "required": true
}, },
{
"name": "starts_at",
"label": "Starts at",
"type": "datepicker",
"mode": "datetime"
},
{
"name": "released_on",
"label": "Released on",
"type": "datepicker",
"mode": "date",
"minDate": "2026-01-01",
"displayFormat": "D MMMM YYYY"
},
{ {
"name": "photos", "name": "photos",
"label": "Photos", "label": "Photos",

View File

@@ -2,9 +2,12 @@
// record's form makes one session key, uploads go to record id 0 with that // record's form makes one session key, uploads go to record id 0 with that
// key in a header, the form turns dirty, and the create save sends the same // key in a header, the form turns dirty, and the create save sends the same
// key so the server can attach the files. The upload XMLHttpRequest is // key so the server can attach the files. The upload XMLHttpRequest is
// replaced by a fake the test answers by hand. // replaced by a fake the test answers by hand. The datepicker part runs in a
// fixed non-UTC zone: a stored UTC instant shows as local time and goes back
// unchanged, a date goes out as YYYY-MM-DD.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils' import { flushPromises, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
import { resetLocalTimeZone, setLocalTimeZone } from '@internationalized/date'
import { setBundle } from '../../src/app/i18n' import { setBundle } from '../../src/app/i18n'
import { SESSION_HEADER } from '../../src/app/sessionKey' import { SESSION_HEADER } from '../../src/app/sessionKey'
import { clearUser } from '../../src/state/useAuth' import { clearUser } from '../../src/state/useAuth'
@@ -210,3 +213,97 @@ describe('fileupload on a new record (tracer)', () => {
expect((FakeXHR.sent[1]!.body!.get('file_data') as File).name).toBe('b.png') expect((FakeXHR.sent[1]!.body!.get('file_data') as File).name).toBe('b.png')
}) })
}) })
const saved = {
data: { id: 7, name: 'Box', starts_at: '2026-10-02T10:30:00Z', released_on: null },
meta: { labels: {} },
}
function editRoutes(): Record<string, Route> {
return routes({
[`GET ${BASE}/7`]: { body: saved },
[`GET ${BASE}/7/files/photos`]: { body: { data: [], meta: {} } },
[`PUT ${BASE}/7`]: { body: saved },
})
}
function segment(field: DOMWrapper<Element>, part: string): DOMWrapper<Element> {
return field.find(`[data-reka-date-field-segment="${part}"]`)
}
async function typeInto(field: DOMWrapper<Element>, part: string, digits: string): Promise<void> {
const target = segment(field, part)
for (const key of digits) {
await target.trigger('keydown', { key })
}
}
describe('datepicker values in a fixed time zone', () => {
beforeEach(() => setLocalTimeZone('Europe/Warsaw'))
afterEach(() => resetLocalTimeZone())
it('shows a stored datetime in local time, saves it unchanged and sends a typed date as YYYY-MM-DD', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/gadgets/7', editRoutes())
const starts = wrapper.find('[data-field="starts_at"]')
expect(segment(starts, 'hour').text()).toBe('12')
expect(segment(starts, 'minute').text()).toBe('30')
expect(segment(starts, 'timeZoneName').exists()).toBe(true)
// Not required and set: the clear button is offered next to the trigger.
expect(starts.find('[data-action="clear"]').attributes('aria-label')).toBe('backend::lang.datepicker.clear')
expect(starts.find('[data-action="open-calendar"]').attributes('aria-label')).toBe(
'backend::lang.datepicker.open_calendar',
)
// The label points at the field's id; no native date input is rendered by the field itself.
expect(wrapper.find('label[for="field-starts_at"]').exists()).toBe(true)
const released = wrapper.find('[data-field="released_on"]')
expect(segment(released, 'day').attributes('data-placeholder')).toBeDefined()
// Polish segment order: day, month, year.
const parts = released
.findAll('[data-reka-date-field-segment]')
.map((el) => el.attributes('data-reka-date-field-segment'))
.filter((part) => part !== 'literal' && part !== 'trigger')
expect(parts).toEqual(['day', 'month', 'year'])
await typeInto(released, 'day', '02')
await typeInto(released, 'month', '10')
await typeInto(released, 'year', '2026')
await flushPromises()
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const [put] = requestsTo(calls, 'PUT', `${BASE}/7`)
expect(put).toBeDefined()
const body = await put!.clone().json()
expect(body.starts_at).toBe('2026-10-02T10:30:00Z')
expect(body.released_on).toBe('2026-10-02')
})
it('converts an edited local time back to UTC', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/gadgets/7', editRoutes())
const starts = wrapper.find('[data-field="starts_at"]')
await segment(starts, 'minute').trigger('keydown', { key: 'ArrowUp' })
await flushPromises()
expect(segment(starts, 'minute').text()).toBe('31')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const [put] = requestsTo(calls, 'PUT', `${BASE}/7`)
expect((await put!.clone().json()).starts_at).toBe('2026-10-02T10:31:00Z')
})
it('clears an optional value to null', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/gadgets/7', editRoutes())
await wrapper.find('[data-field="starts_at"] [data-action="clear"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-field="starts_at"] [data-action="clear"]').exists()).toBe(false)
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const [put] = requestsTo(calls, 'PUT', `${BASE}/7`)
expect((await put!.clone().json()).starts_at).toBeNull()
})
})

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="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" /> <meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title> <title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BPPmlB5x.js"></script> <script type="module" crossorigin src="./assets/index-DcD36JxU.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-C8GNzEPt.css"> <link rel="stylesheet" crossorigin href="./assets/index-DdPWaFzL.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -104,6 +104,11 @@ extension:
widget_failed: This control could not be loaded. Refresh the page; if it happens again, check the plugin's assets. widget_failed: This control could not be loaded. Refresh the page; if it happens again, check the plugin's assets.
partial_failed: This section could not be loaded. Refresh the page to try again. partial_failed: This section could not be loaded. Refresh the page to try again.
action_failed: The action could not be completed. Please try again. action_failed: The action could not be completed. Please try again.
datepicker:
open_calendar: Open calendar
clear: Clear value
prev_month: Previous month
next_month: Next month
fileupload: fileupload:
attachment: Attachment details attachment: Attachment details
help: Add a title and description for this attachment. help: Add a title and description for this attachment.

View File

@@ -112,6 +112,11 @@ extension:
widget_failed: Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki. widget_failed: Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki.
partial_failed: Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie. partial_failed: Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie.
action_failed: Nie udało się wykonać akcji. Spróbuj ponownie. action_failed: Nie udało się wykonać akcji. Spróbuj ponownie.
datepicker:
open_calendar: Otwórz kalendarz
clear: Wyczyść wartość
prev_month: Poprzedni miesiąc
next_month: Następny miesiąc
fileupload: fileupload:
attachment: Szczegóły załącznika attachment: Szczegóły załącznika
help: Dodaj tytuł oraz opis załącznika. help: Dodaj tytuł oraz opis załącznika.