diff --git a/index.js b/index.js index 9c28917fa..97495a28e 100644 --- a/index.js +++ b/index.js @@ -8,9 +8,16 @@ export { default as CurrencySelect } from './lib/CurrencySelect'; export { default as CountrySelection } from './lib/CountrySelection'; export { default as Datepicker, + AppValidatedDatepicker, Calendar, staticFirstWeekDay, - staticLangCountryCodes + staticLangCountryCodes, + defaultOutputFormatter, + defaultParser, + defaultInputValidator, + passThroughOutputFormatter, + passThroughParser, + datePickerAppValidationProps } from './lib/Datepicker'; export { default as DateRangeWrapper } from './lib/DateRangeWrapper'; export { default as FormattedDate } from './lib/FormattedDate'; diff --git a/lib/Datepicker/AppValidatedDatepicker.js b/lib/Datepicker/AppValidatedDatepicker.js new file mode 100644 index 000000000..d4eb743dc --- /dev/null +++ b/lib/Datepicker/AppValidatedDatepicker.js @@ -0,0 +1,12 @@ +/** AppValidatedDatepicker + * Exports a pre-wrapped Datepicker instance that applies the datePickerAppValidationProps. + */ + +import Datepicker from './Datepicker'; +import { + datePickerAppValidationProps +} from './datepicker-util'; + +export default (props) => ( + +); diff --git a/lib/Datepicker/Datepicker.js b/lib/Datepicker/Datepicker.js index 50d6b74c5..2dd0aea1c 100644 --- a/lib/Datepicker/Datepicker.js +++ b/lib/Datepicker/Datepicker.js @@ -1,7 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import { FormattedMessage, injectIntl } from 'react-intl'; import PropTypes from 'prop-types'; -import moment from 'moment-timezone'; import uniqueId from 'lodash/uniqueId'; import pick from 'lodash/pick'; import RootCloseWrapper from '../../util/RootCloseWrapper'; @@ -12,79 +11,16 @@ import IconButton from '../IconButton'; import TextField from '../TextField'; import Calendar from './Calendar'; import css from './Calendar.css'; +import { + defaultParser, + defaultInputValidator, + defaultOutputFormatter, + getBackendDateStandard +} from './datepicker-util'; import { getLocaleDateFormat } from '../../util/dateTimeUtils'; const pickDataProps = (props) => pick(props, (v, key) => key.indexOf('data-test') !== -1); -// Controls the formatting from the value prop to what displays in the UI. -// need to judge the breakage factor in adopting a spread syntax for these parameters... -const defaultParser = (value, timeZone, uiFormat, outputFormats) => { - if (!value || value === '') { return value; } - - const offsetRegex = /T[\d.:]+[+-][\d]+$/; - const offsetRE2 = /T[\d:]+[-+][\d:]+\d{2}$/; // sans milliseconds - let inputMoment; - // if date string contains a utc offset, we can parse it as utc time and convert it to selected timezone. - if (offsetRegex.test(value) || offsetRE2.test(value)) { - inputMoment = moment.tz(value, timeZone); - } else { - inputMoment = moment.tz(value, [uiFormat, ...outputFormats], timeZone); - } - const inputValue = inputMoment.format(uiFormat); - return inputValue; -}; - -/** - * defaultOutputFormatter - * Controls the formatting from the value prop/input to what is relayed in the onChange event. - * This function has two responsibilities: - * 1. use `backendDateStandard` to format `value` - * 2. convert value to Arabic/Latn digits (0-9) - * - * The first responsibility is pretty obvious, but the second one is subtle, - * implied but never clearly stated in API documentation. Dates are passed - * as strings in API requests and are then interpreted by the backend as Dates. - * To be so interpretable, they must conform to the expected formatted AND use - * the expected numeral system. - * - * This function allows the format to be changed with `backendDateStandard`. - * To change the numeral system, pass a function as `outputFormatter`, which - * gives you control over both the format and the numeral system. - * - * @returns {string} 7-bit ASCII - */ -export const defaultOutputFormatter = ({ backendDateStandard, value, uiFormat, outputFormats, timeZone }) => { - if (!value || value === '') { return value; } - const parsed = new moment.tz(value, [uiFormat, ...outputFormats], timeZone); // eslint-disable-line - - if (/8601/.test(backendDateStandard)) { - return parsed.toISOString(); - } - - // Use `.locale('en')` before `.format(...)` to get Arabic/"Latn" numerals. - // otherwise, a locale like ar-SA or any locale with a "-u-nu-..." subtag - // can give us non-Arabic (non-"Latn") numerals, and in such a locale the - // formatter "YYYY-MM-DD" can give us output like this: ١٦‏/٠٧‏/٢٠٢١ - // i.e. we get year-month-day but in non-Arabic numerals. - // - // Additional details about numbering systems at - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/numberingSystem - // and about how the locale string may be parsed at - // https://www.rfc-editor.org/rfc/rfc5646.html - - // for support of the RFC2822 format (rare thus far and support may soon be deprecated.) - if (/2822/.test(backendDateStandard)) { - const DATE_RFC2822 = 'ddd, DD MMM YYYY HH:mm:ss ZZ'; - return parsed.locale('en').format(DATE_RFC2822); - } - - // if a localized string dateformat has been passed, normalize the date first... - // otherwise, localized strings could be submitted to the backend. - const normalizedDate = moment.utc(value, [uiFormat, ...outputFormats]); - - return new moment(normalizedDate, 'YYYY-MM-DD').locale('en').format(backendDateStandard); // eslint-disable-line -}; - const propTypes = { autoFocus: PropTypes.bool, backendDateStandard: PropTypes.string, @@ -96,6 +32,7 @@ const propTypes = { id: PropTypes.string, input: PropTypes.object, inputRef: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), + inputValidator: PropTypes.func, intl: PropTypes.object, label: PropTypes.node, locale: PropTypes.string, @@ -120,23 +57,15 @@ const propTypes = { value: PropTypes.string, }; -const getBackendDateStandard = (standard, use) => { - if (!use) return undefined; - if (standard === 'ISO8601') return ['YYYY-MM-DDTHH:mm:ss.sssZ', 'YYYY-MM-DDTHH:mm:ssZ']; - if (standard === 'RFC2822') return ['ddd, DD MMM YYYY HH:mm:ss ZZ']; - return [standard, 'YYYY-MM-DDTHH:mm:ss.sssZ', 'ddd, DD MMM YYYY HH:mm:ss ZZ']; -}; - - const Datepicker = ( - { - autoFocus = false, + { autoFocus = false, backendDateStandard = 'ISO8601', disabled, dateFormat, exclude, hideOnChoose = true, id, + inputValidator = defaultInputValidator, intl, locale, modifiers = {}, @@ -212,15 +141,10 @@ const Datepicker = ( return blankDates; } - // use strict mode to check validity - incomplete dates, anything not conforming to the format will be invalid + // if we output the value according to backendDateStandard, we will probably get it back + // in that format, so include that format in validation. const backendStandard = getBackendDateStandard(backendDateStandard, outputBackendValue); - const valueMoment = new moment(// eslint-disable-line new-cap - value, - [format, ...backendStandard], // pass array of possible formats () - true - ); - const isValid = valueMoment.isValid(); - + const isValid = inputValidator({ value, format, backendStandard }); let dates; // otherwise parse the value and update the datestring and the formatted date... diff --git a/lib/Datepicker/datepicker-util.js b/lib/Datepicker/datepicker-util.js new file mode 100644 index 000000000..af2daae62 --- /dev/null +++ b/lib/Datepicker/datepicker-util.js @@ -0,0 +1,130 @@ +import moment from 'moment-timezone'; + +export const getBackendDateStandard = (standard, use) => { + if (!use) return []; + if (standard === 'ISO8601') return ['YYYY-MM-DDTHH:mm:ss.sssZ', 'YYYY-MM-DDTHH:mm:ssZ']; + if (standard === 'RFC2822') return ['ddd, DD MMM YYYY HH:mm:ss ZZ']; + return [standard, 'YYYY-MM-DDTHH:mm:ss.sssZ', 'ddd, DD MMM YYYY HH:mm:ss ZZ']; +}; + +// Controls the formatting from the value prop to what displays in the UI. +// need to judge the breakage factor in adopting a spread syntax for these parameters... +export const defaultParser = (value, timeZone, uiFormat, outputFormats) => { + if (!value || value === '') { return value; } + + const offsetRegex = /T[\d.:]+[+-][\d]+$/; + const offsetRE2 = /T[\d:]+[-+][\d:]+\d{2}$/; // sans milliseconds + let inputMoment; + // if date string contains a utc offset, we can parse it as utc time and convert it to selected timezone. + if (offsetRegex.test(value) || offsetRE2.test(value)) { + inputMoment = moment.tz(value, timeZone); + } else { + inputMoment = moment.tz(value, [uiFormat, ...outputFormats], timeZone); + } + const inputValue = inputMoment.format(uiFormat); + return inputValue; +}; + + +// if the input isn't the same as the date output by the parser, pass it through as-is. +// this will accept partial dates through the value prop and +// render them in the input field as-is. +export const passThroughParser = (value, timeZone, uiFormat, outputFormats) => { + const candidate = defaultParser(value, timeZone, uiFormat, outputFormats); + if (candidate !== value) { + // check if the value is in the backendDateStandard format. If so, return the candidate... + const inputMoment = moment.tz(value, outputFormats, true, timeZone); + if (inputMoment.isValid()) return candidate; + return value + } + return candidate; +} + +/** defaultValidator + * validates user input to determine whether the value is processed for output. + * if this function _always_ returns true, the value will always be output, + * leaving it up to the application to validate. + */ +export const defaultInputValidator = ({ value, format, backendStandard }) => { + // use strict mode to check validity - incomplete dates, anything not conforming to the format will be invalid + const valueMoment = new moment(// eslint-disable-line new-cap + value, + [format, ...backendStandard], // pass array of possible formats () + true + ); + return valueMoment.isValid(); +} + +/** + * defaultOutputFormatter + * Controls the formatting from the value prop/input to what is relayed in the onChange event. + * This function has two responsibilities: + * 1. use `backendDateStandard` to format `value` + * 2. convert value to Arabic/Latn digits (0-9) + * + * The first responsibility is pretty obvious, but the second one is subtle, + * implied but never clearly stated in API documentation. Dates are passed + * as strings in API requests and are then interpreted by the backend as Dates. + * To be so interpretable, they must conform to the expected formatted AND use + * the expected numeral system. + * + * This function allows the format to be changed with `backendDateStandard`. + * To change the numeral system, pass a function as `outputFormatter`, which + * gives you control over both the format and the numeral system. + * + * @returns {string} 7-bit ASCII + */ +export const defaultOutputFormatter = ({ backendDateStandard, value, uiFormat, outputFormats, timeZone }) => { + if (!value || value === '') { return value; } + const parsed = new moment.tz(value, [uiFormat, ...outputFormats], timeZone); // eslint-disable-line + + if (/8601/.test(backendDateStandard)) { + return parsed.toISOString(); + } + + // Use `.locale('en')` before `.format(...)` to get Arabic/"Latn" numerals. + // otherwise, a locale like ar-SA or any locale with a "-u-nu-..." subtag + // can give us non-Arabic (non-"Latn") numerals, and in such a locale the + // formatter "YYYY-MM-DD" can give us output like this: ١٦‏/٠٧‏/٢٠٢١ + // i.e. we get year-month-day but in non-Arabic numerals. + // + // Additional details about numbering systems at + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/numberingSystem + // and about how the locale string may be parsed at + // https://www.rfc-editor.org/rfc/rfc5646.html + + // for support of the RFC2822 format (rare thus far and support may soon be deprecated.) + if (/2822/.test(backendDateStandard)) { + const DATE_RFC2822 = 'ddd, DD MMM YYYY HH:mm:ss ZZ'; + return parsed.locale('en').format(DATE_RFC2822); + } + + // if a localized string dateformat has been passed, normalize the date first... + // otherwise, localized strings could be submitted to the backend. + const normalizedDate = moment.utc(value, [uiFormat, ...outputFormats]); + + return new moment(normalizedDate, 'YYYY-MM-DD').locale('en').format(backendDateStandard); // eslint-disable-line +}; + +// validates potential output values against the format prop (uiFormat) prior to outputting them. +// passes invalid input (value) directly through, leaving validation up to the consuming app. +export const passThroughOutputFormatter = ({ backendDateStandard, value, uiFormat, outputFormats, timeZone }) => { + if (!value || value === '') { return value; } + if (defaultInputValidator( + { + value, + format: uiFormat, + backendStandard: getBackendDateStandard(backendDateStandard, true) + } + )) { + return defaultOutputFormatter({ backendDateStandard, value, uiFormat, outputFormats, timeZone }); + } else { + return value; + } +}; + +export const datePickerAppValidationProps = { + outputFormatter: passThroughOutputFormatter, + parser: passThroughParser, + inputValidator : () => true, +}; diff --git a/lib/Datepicker/index.js b/lib/Datepicker/index.js index a774a7830..a3031955d 100644 --- a/lib/Datepicker/index.js +++ b/lib/Datepicker/index.js @@ -2,3 +2,12 @@ export { default as Calendar } from './Calendar'; export { default as staticFirstWeekDay } from './staticFirstWeekDay'; export { default as staticLangCountryCodes } from './staticLangCountryCodes'; export { default } from './Datepicker'; +export { + defaultOutputFormatter, + defaultParser, + defaultInputValidator, + passThroughOutputFormatter, + passThroughParser, + datePickerAppValidationProps +} from './datepicker-util'; +export { default as AppValidatedDatepicker } from './AppValidatedDatepicker'; diff --git a/lib/Datepicker/readme.md b/lib/Datepicker/readme.md index f60c90dfb..eab148220 100644 --- a/lib/Datepicker/readme.md +++ b/lib/Datepicker/readme.md @@ -16,6 +16,7 @@ Name | type | description | default | required `backendDateStandard` | string | parses to/from ISO 8601 standard, with Arabic (0-9) digits, by default before committing value. | "ISO 8601" | false `disabled` | bool | if true, field will be disabled for focus or entry. | false | false `id` | string | id for date field - used in the "id" attribute of the text input | | false +`inputValidator` | func | Function that receives the value (value prop or user input), the provided format prop and the backend format to determine if the value is passed on through advanced stages of the value lifecycle (formatting for output). Returns a boolean. | | `defaultInputValidator` `label` | string | visible field label | | false `locale` | string | Overrides the locale provided by context. | "en" | false `onChange` | func | Event handler to handle updates to the datefield text. | | false @@ -97,7 +98,8 @@ The value flow happens in 3 stages - timeZone - the timezone prop. - uiFormat - the localized format or `dateFormat` prop. - outputFormat - the ISO-string literal format derived from the `backendDateStandard` prop -3. output formatting - when the input is changed by the user, its value is formatted again to work with the backend using the `outputFormatter` function. This function is provided with **a parameter object** holding the following values: +3. Input validity. The value is checked to be sure it's a parsible 'valid' date using the `inputValidator` prop. It is provided the parameters `value`, `format`, `backendStandard` - the backendStandard is an alpha-numeric formatting string, similar to `"YYYY-MM-DD"`... +4. output formatting - when the input is changed by the user, its value is formatted again to work with the backend using the `outputFormatter` function. This function is provided with **a parameter object** holding the following values: - backendDateStandard - the prop of the same name. - value - the value prop. - uiFormat - the localized format or `dateFormat` prop for displaying in the textfield. @@ -113,6 +115,34 @@ The value flow happens in 3 stages * **Enter** - Select date at cursor * **Esc** - Close calendar +## Fully controlled version. + +By default, `` will only emit empty strings or fully formed date strings formatted to the specifics of the `backendDateStandard`. If the application requires a fully controlled set-up, where incomplete and possibly invalid values can pass through form state and be validated by the consuming app itself, we export a set of bundle of props that can be applied via `datePickerAppValidationProps` like so... + +``` +import { datepickerAppValidationProps, Datepicker } from '@folio/stripes/components'; + + +``` + +`datePickerAppValidationProps` supplies modified versions of the `outputFormatter`, `parser` and `inputValidator` props that conform to the use-case of app-level validation. + +We also export `` - a component which applies the props of `datePickerAppValidationProps` to a wrapped `` instance. Similar to above: + +``` + +``` + + ## Custom Circumstances with RFF If the provided defaults and base behaviors don't quite cover your requirements, you may need write an additional function in order to wrap the datepicker and modify props from ``. Simply supplying a function that accepts the input, and meta props that components usually receive from RFF. In this example, we want validation errors to diff --git a/lib/Datepicker/tests/Datepicker-test.js b/lib/Datepicker/tests/Datepicker-test.js index a87e15991..c3914b59e 100644 --- a/lib/Datepicker/tests/Datepicker-test.js +++ b/lib/Datepicker/tests/Datepicker-test.js @@ -1,6 +1,7 @@ import React from 'react'; import { describe, beforeEach, afterEach, it } from 'mocha'; import { expect } from 'chai'; +import sinon from 'sinon'; import moment from 'moment'; import 'moment/locale/ar'; import 'moment/locale/fr'; @@ -14,10 +15,18 @@ import { HTML, IconButton, runAxeTest, + converge, } from '@folio/stripes-testing'; import { mountWithContext, focusPrevious, focusNext } from '../../../tests/helpers'; -import Datepicker, { defaultOutputFormatter } from '../Datepicker'; +import Datepicker from '../Datepicker'; +import { + defaultOutputFormatter, + passThroughParser, + passThroughOutputFormatter, + datePickerAppValidationProps +} from '../datepicker-util'; +import AppValidatedDatepicker from '../AppValidatedDatepicker'; import DatepickerAppHarness from './DatepickerAppHarness'; const Weekday = HTML.extend('weekday') @@ -645,4 +654,86 @@ describe('Datepicker', () => { await CalendarDay('20', { rowIndex: 3, colIndex: 3 }).exists(); }); }); + + describe('custom validator', () => { + const customChange = sinon.spy(); + beforeEach(async () => { + customChange.resetHistory(); + await (mountWithContext( + true} + onChange={customChange} + outputFormatter={passThroughOutputFormatter} + outputBackendValue={false} + parser={passThroughParser} + /> + )); + await datepicker.fillIn('03'); + }); + + it('calls the onChange handler multiple times', () => { + return converge(() => { + if ( + !customChange.calledWith('0') || + !customChange.calledWith('03') + ) { throw new Error('expected changeHandler to be called with incomplete values') } + }); + }); + }); + + describe('validation props bundle', () => { + const customChange = sinon.spy(); + beforeEach(async () => { + customChange.resetHistory(); + await (mountWithContext( + + )); + await datepicker.fillIn('03'); + }); + + it('calls the onChange handler multiple times', () => { + return converge(() => { + if ( + !customChange.calledWith('0') || + !customChange.calledWith('03') + ) { throw new Error('expected changeHandler to be called with incomplete values') } + }); + }); + }); + + describe('AppValidatedDatepicker', () => { + const customChange = sinon.spy(); + beforeEach(async () => { + customChange.resetHistory(); + await (mountWithContext( + + {(props) => } + + )); + await datepicker.fillIn('03'); + }); + + it('calls the onChange handler multiple times', () => { + return converge(() => { + if ( + !customChange.calledWith('0') || + !customChange.calledWith('03') + ) { throw new Error('expected changeHandler to be called with incomplete values') } + }); + }); + }); }); diff --git a/lib/Datepicker/tests/DatepickerAppHarness.js b/lib/Datepicker/tests/DatepickerAppHarness.js index de4830d36..2ec27c3bb 100644 --- a/lib/Datepicker/tests/DatepickerAppHarness.js +++ b/lib/Datepicker/tests/DatepickerAppHarness.js @@ -1,10 +1,11 @@ /* a basic harness meant to set up scenarios for datepicker within an actual application -* where its props are fed to it by potentially dynamic sources. +* where its props are fed to it by potentially dynamic sources within a stateful structure. */ import React, { useState } from 'react'; import Datepicker from '../Datepicker'; const DatepickerAppHarness = ({ + children, lateValue = '04/01/2019', ...props }) => { @@ -14,12 +15,28 @@ const DatepickerAppHarness = ({ updateValue(lateValue); }; + const internalOnchange = (...args) => { + if (props.onChange) props.onChange(args[1]); + updateValue(args[1]); + }; + return (
- + { children ? children({ + value, + ...props, + onChange: internalOnchange, + }) : ( + ) + } +
value:{value}
);