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:
14
admin/tests/fixtures/deferred.form-schema.json
vendored
14
admin/tests/fixtures/deferred.form-schema.json
vendored
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user