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,20 @@
"type": "text",
"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",
"label": "Photos",

View File

@@ -2,9 +2,12 @@
// 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 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 { 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 { SESSION_HEADER } from '../../src/app/sessionKey'
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')
})
})
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()
})
})