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:
1
admin/package-lock.json
generated
1
admin/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
300
admin/src/app/dateFormat.ts
Normal 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
|
||||||
|
}
|
||||||
316
admin/src/components/form/fields/DatepickerField.vue
Normal file
316
admin/src/components/form/fields/DatepickerField.vue
Normal 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>
|
||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
14
admin/tests/fixtures/deferred.form-schema.json
vendored
14
admin/tests/fixtures/deferred.form-schema.json
vendored
@@ -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",
|
||||||
|
|||||||
@@ -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
9
modules/boardwalk/dist/assets/index-DcD36JxU.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-DcD36JxU.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-DdPWaFzL.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-DdPWaFzL.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="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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user