Skip to content

Commit

Permalink
STCOM-1119 Timepicker vs React-fin- er, um Redux-form (aka stsmacom's…
Browse files Browse the repository at this point in the history
… ChangeDueDateDialog) (#1988)

* use current time in timedropdown if no time is selected - also address overuse of padZero

* add test for bug fix

* if only I had a dime for every time I used only

* add mapping for onBlur, onFocus props to timepicker

* add onBlur/onFocus handlers to Timepicker

* correctly apply props- useInput and outputBackendValue for react-final-form usage in timepicker

* lint and wash code

* update timepicker test

* mild timepicker test refactor

* split out timepicker redux-form tests/interactor

* maybe not use *actual redux-form

* Conform to the ICU update in node/chrome

* add to comment regarding onBlur
JohnC-80 authored and zburke committed Feb 21, 2023
1 parent b8ea3ea commit aec0db9
Showing 7 changed files with 210 additions and 98 deletions.
3 changes: 0 additions & 3 deletions lib/Datepicker/readme.md
Original file line number Diff line number Diff line change
@@ -8,9 +8,6 @@ import { Datepicker } from '@folio/stripes/components';
//or pass as component within a form...
<Field component={Datepicker} />
```
outputBackendValue: PropTypes.bool,
outputFormatter: PropTypes.func,
parser: PropTypes.func,

## Props
Name | type | description | default | required
12 changes: 8 additions & 4 deletions lib/Timepicker/TimeDropdown.js
Original file line number Diff line number Diff line change
@@ -129,6 +129,7 @@ const propTypes = {
mainControl: deprecated(PropTypes.object, 'no longer needed - handle focus management in onSetTime'), // eslint-disable-line
minuteIncrement: PropTypes.number,
onBlur: deprecated(PropTypes.func, 'focus trapped automatically'), //eslint-disable-line
onFocus: PropTypes.func,
onHide: PropTypes.func,
onSetTime: PropTypes.func,
rootRef: PropTypes.oneOfType([
@@ -146,21 +147,23 @@ const TimeDropdown = ({
id,
intl: intlProp,
minuteIncrement = 1,
onHide = ()=>{}, // eslint-disable-line
onHide = () => {}, // eslint-disable-line
onSetTime,
rootRef,
selectedTime,
timeFormat,
onFocus = () => {},
}) => {
const intlContext = useIntl();
const intl = intlProp || intlContext;
const hourMax = useRef(hoursFormat === '24' ? 23 : 12).current;
const hourMin = useRef(hoursFormat === '24' ? 0 : 1).current;
const dayPeriods = hoursFormat === '12' && getListofDayPeriods(intl.locale);
const hoursFormatCharacter = useRef(`${hoursFormat === '24' ? 'HH' : 'h'}`).current;
const [hour, setHour] = useState(
selectedTime ?
moment(selectedTime, timeFormat).format(`${hoursFormat === '24' ? 'HH' : 'h'}`) :
moment().format(`${hoursFormat === '24' ? 'HH' : 'h'}`)
moment(selectedTime, timeFormat).format(hoursFormatCharacter) :
moment().format(hoursFormatCharacter)
);
const [minute, setMinute] = useState(
selectedTime ?
@@ -191,7 +194,7 @@ const TimeDropdown = ({
return newMinute;
});
}
}, [hourMax, hourMin]);
}, [hourMax, hourMin, hoursFormat]);

useEffect(() => () => onHide(), []); // eslint-disable-line

@@ -254,6 +257,7 @@ const TimeDropdown = ({
data-test-timepicker-dropdown
onSubmit={confirmTime}
onKeyDown={handleKeyDown}
onFocus={onFocus}
>
<>
<FocusLink
61 changes: 57 additions & 4 deletions lib/Timepicker/Timepicker.js
Original file line number Diff line number Diff line change
@@ -70,7 +70,10 @@ const propTypes = {
locale: PropTypes.string,
marginBottom0: PropTypes.bool,
modifiers: PropTypes.object,
onBlur: PropTypes.func,
onChange: PropTypes.func,
onFocus: PropTypes.func,
outputBackendValue: PropTypes.bool,
outputFormatter: PropTypes.func,
parser: PropTypes.func,
placement: PropTypes.oneOf(AVAILABLE_PLACEMENTS),
@@ -92,6 +95,7 @@ const Timepicker = ({
modifiers,
onChange,
outputFormatter = defaultOutputFormatter,
outputBackendValue = true,
parser = defaultParser,
placement,
screenReaderMessage,
@@ -101,12 +105,15 @@ const Timepicker = ({
useInput,
usePortal,
value: valueProp,
onBlur,
onFocus,
...inputProps
}) => {
const input = useRef(null);
const hiddenInput = useRef(null);
const srStatus = useRef(null);
const container = useRef(null);
const blurTimeout = useRef(null);
const dropdownRef = useRef(null);
const testId = useRef(uniqueId('-timepicker')).current;
const intlContext = useIntl();
@@ -255,7 +262,7 @@ const Timepicker = ({
*/
const handleChange = (e) => {
candidate.current = Object.assign(candidate.current, maybeUpdateValue(e.target.value));
if (useInput && onChange) {
if ((!useInput || !outputBackendValue) && onChange) {
onChange(e, e.target.value, candidate.current.timeString);
} else if (typeof candidate.current.formatted === 'string' &&
candidate.current.formatted !== hiddenInput.current.value) {
@@ -265,7 +272,7 @@ const Timepicker = ({

// for final-form so it can have a native change event rather than a fabricated thing...
const onChangeFormatted = (e) => {
if (!useInput && onChange) {
if (useInput && onChange) {
const { timeString, formatted } = candidate.current;
onChange(e, formatted, timeString);
}
@@ -292,6 +299,43 @@ const Timepicker = ({
const { readOnly, disabled, label } = inputProps;
const screenReaderFormat = timeFormat.split('').join(' ');

// the way that redux-form treats blurs is to trigger an onChange event on the target.
// this holds onto the event until blur has passed out of the component/dropdown, controls.
// without this treatment, the time can end up blank.
// the timeout is used to ensure the hidden input's value is updated. This is candidate for refactor...
const queueBlur = (e) => {
blurTimeout.current = setTimeout(() => {
if (onBlur) {
if (useInput) {
onBlur({
target: outputBackendValue ? hiddenInput.current : input.current,
stopPropagation: () => {},
preventDefault: () => {},
defaultPrevented: true,
});
} else {
onBlur(e);
}
}
});
};

const cancelBlur = () => {
clearTimeout(blurTimeout.current);
};

const handleInternalBlur = (e) => {
e.preventDefault();
queueBlur(e);
};

const handleInternalFocus = (e) => {
cancelBlur();
if (onFocus) {
onFocus(e);
}
};

let ariaLabel;
if (readOnly || disabled) {
ariaLabel = `${label}`;
@@ -317,7 +361,12 @@ const Timepicker = ({

return (
<>
<div style={{ position: 'relative', width: '100%' }} ref={container}>
<div
style={{ position: 'relative', width: '100%' }}
ref={container}
onFocus={handleInternalFocus}
onBlur={handleInternalBlur}
>
<SRStatus ref={srStatus} />
<TextField
{...inputProps}
@@ -359,6 +408,7 @@ const Timepicker = ({
onHide={hideTimepicker}
selectedTime={timePair.timeString}
timeFormat={timeFormat}
onFocus={handleInternalFocus}
rootRef={dropdownRef}
id={testId}
onClose={() => setShowDropdown(false)}
@@ -373,10 +423,13 @@ Timepicker.propTypes = propTypes;

export default FormField(
Timepicker,
({ meta }) => ({
({ input, meta }) => ({
onBlur: input?.onBlur,
onFocus: input?.onFocus,
dirty: meta?.dirty,
error: (meta?.touched && meta?.error ? meta.error : ''),
valid: meta?.valid,
warning: (meta?.touched ? parseMeta(meta, 'warning') : ''),
useInput: true,
})
);
56 changes: 56 additions & 0 deletions lib/Timepicker/tests/Timepicker-reduxform-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';
import { describe, beforeEach, it } from 'mocha';
import sinon from 'sinon';
import { Field } from 'redux-form';
import {
converge,
} from '@folio/stripes-testing';

import { mountWithContext } from '../../../tests/helpers';
import TestForm from '../../../tests/TestForm';

import Timepicker from '../Timepicker';

import {
Timepicker as TimepickerInteractor,
TimeDropdown as TimepickerDropdownInteractor
} from './timepicker-interactor-bt';

describe('Timepicker with redux form', () => {
const timepicker = TimepickerInteractor({ id: 'test-time' });
const timedropdown = TimepickerDropdownInteractor();
describe('selecting a time', () => {
let timeOutput;
const handleChange = sinon.spy((e) => {
timeOutput = e.target.value;
});
beforeEach(async () => {
timeOutput = '';
await handleChange.resetHistory();
const input = {
onChange: (e) => handleChange(e),
onBlur: (e) => handleChange(e)
}
await mountWithContext(
<Timepicker
input={input}
id="test-time"
/>
);

await timepicker.fillIn('5:00 PM');
});

it('returns an ISO 8601 time string at UTC', () => converge(() => timeOutput === '17:00:00.000Z'));
it('calls the handler multiple times - on change and onBlur', () => converge(() => handleChange.calledTwice));

describe('opening the dropdown', () => {
beforeEach(async () => {
await timepicker.clickDropdownToggle();
});

it('focuses the timeDropdown', () => timedropdown.has({ focused: true }));
it('retains the correct value in the input', () => timepicker.has({ value: '5:00 PM' }));
});
});
});
103 changes: 19 additions & 84 deletions lib/Timepicker/tests/Timepicker-test.js
Original file line number Diff line number Diff line change
@@ -3,9 +3,6 @@ import { describe, beforeEach, it } from 'mocha';
import moment from 'moment-timezone';
import {
runAxeTest,
HTML,
TextField,
IconButton,
Button,
including,
converge,
@@ -15,53 +12,13 @@ import {
import translationsDE from '../../../translations/stripes-components/de';

import { mountWithContext } from '../../../tests/helpers';

import Timepicker from '../Timepicker';
import TimepickerHarness from './TimepickerHarness';
import {
Timepicker as TimepickerInteractor,
TimeDropdown as TimepickerDropdownInteractor
} from './timepicker-interactor-bt';

const TimepickerDropdownInteractor = HTML.extend('time dropdown')
.selector('[data-test-timepicker-dropdown]')
.filters({
periodToggle: Button({ id: including('period-toggle') }).exists(),
hour: (el) => {
const node = el.querySelector('input[data-test-timepicker-dropdown-hours-input]');
return node.value ? parseInt(node.value, 10) : '';
},
minute: (el) => {
const node = el.querySelector('input[data-test-timepicker-dropdown-minutes-input]');
return node.value ? parseInt(node.value, 10) : '';
}
})
.actions({
clickPeriodToggle: ({ find }) => find(Button({ id: including('period-toggle') })).click(),
fillHour: ({ find }, value) => find(TextField({ id: including('hour-input') }))
.perform((el) => {
const node = el.querySelector('input');
const property = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), 'value');
property.set.call(node, value);
node.dispatchEvent(new InputEvent('input', { inputType: 'insertFromPaste', bubbles: true, cancelable: false }));
}),
fillMinute: ({ find }, value) => find(TextField({ id: including('minute-input') }))
.perform((el) => {
const node = el.querySelector('input');
const property = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), 'value');
property.set.call(node, value);
node.dispatchEvent(new InputEvent('input', { inputType: 'insertFromPaste', bubbles: true, cancelable: false }));
}),
clickConfirm: ({ find }) => find(Button({ id: including('set-time') })).click(),
clickAddMinute: ({ find }) => find(IconButton({ id: including('next-minute') })).click(),
clickAddHour: ({ find }) => find(IconButton({ id: including('next-hour') })).click(),
clickDecrementMinute: ({ find }) => find(IconButton({ id: including('prev-minute') })).click(),
clickDecrementHour: ({ find }) => find(IconButton({ id: including('prev-hour') })).click(),
});

const TimepickerInteractor = TextField.extend('timepicker')
.actions({
clickDropdownToggle: ({ find }) => find(IconButton({ icon: 'clock' })).click(),
clickInput: ({ perform }) => perform((el) => el.querySelector('input').click()),
focusDropdownButton: ({ find }) => find(IconButton({ icon: 'clock' })).focus(),
clickClear: ({ find }) => find(IconButton({ icon: 'times-circle-solid' })).click()
});

describe('Timepicker', () => {
const timepicker = TimepickerInteractor();
@@ -240,48 +197,26 @@ describe('Timepicker', () => {
it('emits an event with the time formatted as displayed', () => converge(() => timeOutput === '05:00 PM'));
});

describe('coupled to redux form', () => {
describe('selecting a time', () => {
let timeOutput;

beforeEach(async () => {
timeOutput = '';
describe('selecting a time with timeZone prop', () => {
let timeOutput;
const tz = 'America/Los_Angeles';

await mountWithContext(
<Timepicker
input={{
onChange: (value) => { timeOutput = value; },
}}
/>
);
beforeEach(async () => {
timeOutput = '';

await timepicker.fillIn('05:00 PM');
});
await mountWithContext(
<Timepicker
input={{
onChange: (value) => { timeOutput = value; },
}}
timeZone={tz}
/>
);

it('returns an ISO 8601 time string at UTC', () => converge(() => timeOutput === '17:00:00.000Z'));
await timepicker.fillIn('05:00 PM');
});

describe('selecting a time with timeZone prop', () => {
let timeOutput;
const tz = 'America/Los_Angeles';

beforeEach(async () => {
timeOutput = '';

await mountWithContext(
<Timepicker
input={{
onChange: (value) => { timeOutput = value; },
}}
timeZone={tz}
/>
);

await timepicker.fillIn('05:00 PM');
});

it('returns an ISO 8601 time string for specific time zone', () => converge(() => (timeOutput === moment().tz(tz).isDST() ? '00:00:00.000Z' : '01:00:00.000Z')));
});
it('returns an ISO 8601 time string for specific time zone', () => converge(() => (timeOutput === moment().tz(tz).isDST() ? '00:00:00.000Z' : '01:00:00.000Z')));
});

describe('Timedropdown', () => {
52 changes: 52 additions & 0 deletions lib/Timepicker/tests/timepicker-interactor-bt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
HTML,
TextField,
IconButton,
Button,
including,
} from '@folio/stripes-testing';

export const TimeDropdown = HTML.extend('time dropdown')
.selector('[data-test-timepicker-dropdown]')
.filters({
periodToggle: Button({ id: including('period-toggle') }).exists(),
hour: (el) => {
const node = el.querySelector('input[data-test-timepicker-dropdown-hours-input]');
return node.value ? parseInt(node.value, 10) : '';
},
minute: (el) => {
const node = el.querySelector('input[data-test-timepicker-dropdown-minutes-input]');
return node.value ? parseInt(node.value, 10) : '';
},
focused: (el) => el.contains(document.activeElement)
})
.actions({
clickPeriodToggle: ({ find }) => find(Button({ id: including('period-toggle') })).click(),
fillHour: ({ find }, value) => find(TextField({ id: including('hour-input') }))
.perform((el) => {
const node = el.querySelector('input');
const property = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), 'value');
property.set.call(node, value);
node.dispatchEvent(new InputEvent('input', { inputType: 'insertFromPaste', bubbles: true, cancelable: false }));
}),
fillMinute: ({ find }, value) => find(TextField({ id: including('minute-input') }))
.perform((el) => {
const node = el.querySelector('input');
const property = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), 'value');
property.set.call(node, value);
node.dispatchEvent(new InputEvent('input', { inputType: 'insertFromPaste', bubbles: true, cancelable: false }));
}),
clickConfirm: ({ find }) => find(Button({ id: including('set-time') })).click(),
clickAddMinute: ({ find }) => find(IconButton({ id: including('next-minute') })).click(),
clickAddHour: ({ find }) => find(IconButton({ id: including('next-hour') })).click(),
clickDecrementMinute: ({ find }) => find(IconButton({ id: including('prev-minute') })).click(),
clickDecrementHour: ({ find }) => find(IconButton({ id: including('prev-hour') })).click(),
});

export const Timepicker = TextField.extend('timepicker')
.actions({
clickDropdownToggle: ({ find }) => find(IconButton({ icon: 'clock' })).click(),
clickInput: ({ perform }) => perform((el) => el.querySelector('input').click()),
focusDropdownButton: ({ find }) => find(IconButton({ icon: 'clock' })).focus(),
clickClear: ({ find }) => find(IconButton({ icon: 'times-circle-solid' })).click(),
});
21 changes: 18 additions & 3 deletions util/dateTimeUtils.js
Original file line number Diff line number Diff line change
@@ -47,7 +47,14 @@ export const getLocaleDateFormat = ({ intl, config }) => {
format += 'YYYY';
break;
case 'literal':
format += p.value;
// An ICU 72 update places a unicode character \u202f (non-breaking space) before day period (am/pm).
// This can make for differences that are imperceptible to humans, but automated tests know!
// the \u202f character is best detected via its charCode... 8239
if (p.value.charCodeAt(0) === 8239) {
format += ' ';
} else {
format += p.value;
}
break;
default:
break;
@@ -109,6 +116,7 @@ export function getLocalizedTimeFormatInfo(locale) {
// something in the form of HH:mm or HH:mm A that could be fed to a library like moment.
if (i === dateArray.length - 1) {
dateFields.forEach((p) => {
let adjustedValue;
switch (p.type) {
case 'hour':
timeFormat += 'HH';
@@ -117,8 +125,15 @@ export function getLocalizedTimeFormatInfo(locale) {
timeFormat += 'mm';
break;
case 'literal':
timeFormat += p.value;
if (p.value !== ' ' && !formatInfo.separator) {
adjustedValue = p.value;
// An ICU 72 update places a unicode character \u202f (non-breaking space) before day period (am/pm).
// This can make for differences that are imperceptible to humans, but automated tests know!
// the \u202f character is best detected via its charCode... 8239
if (adjustedValue.charCodeAt(0) === 8239) {
adjustedValue = ' ';
}
timeFormat += adjustedValue;
if (adjustedValue !== ' ' && !formatInfo.separator) {
formatInfo.separator = p.value;
}
break;

0 comments on commit aec0db9

Please sign in to comment.