Skip to content

Commit

Permalink
Datepicker - invert validation logic. Provide props for validation wo…
Browse files Browse the repository at this point in the history
…rkflow (#2331)

* datepicker - invert validation logic. Provide props for validation workflow

* adjust parser/output to account for receiving a backendDateStandard value

* AppValidatedDatepicker. Add comments and test to utility code.

* remove included change to filter AccordionHeader

* remove change from Accordion
  • Loading branch information
JohnC-80 authored Sep 3, 2024
1 parent f5d0dd6 commit 5093637
Show file tree
Hide file tree
Showing 8 changed files with 313 additions and 93 deletions.
9 changes: 8 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
12 changes: 12 additions & 0 deletions lib/Datepicker/AppValidatedDatepicker.js
Original file line number Diff line number Diff line change
@@ -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) => (
<Datepicker {...datePickerAppValidationProps} {...props} />
);
100 changes: 12 additions & 88 deletions lib/Datepicker/Datepicker.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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 = {},
Expand Down Expand Up @@ -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...
Expand Down
130 changes: 130 additions & 0 deletions lib/Datepicker/datepicker-util.js
Original file line number Diff line number Diff line change
@@ -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,
};
9 changes: 9 additions & 0 deletions lib/Datepicker/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
32 changes: 31 additions & 1 deletion lib/Datepicker/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -113,6 +115,34 @@ The value flow happens in 3 stages
* **Enter** - Select date at cursor
* **Esc** - Close calendar

## Fully controlled version.

By default, `<Datepicker>` 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';
<Field
component={Datepicker}
label="myDateField"
name={rfFieldState}
{...datePickerAppValidationProps }
/>
```

`datePickerAppValidationProps` supplies modified versions of the `outputFormatter`, `parser` and `inputValidator` props that conform to the use-case of app-level validation.

We also export `<AppValidatedDatepicker>` - a component which applies the props of `datePickerAppValidationProps` to a wrapped `<Datepicker>` instance. Similar to above:

```
<Field
component={AppValidatedDatepicker}
label="myDateField"
name={rfFieldState}
/>
```


## 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 `<Field>`. 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
Expand Down
Loading

0 comments on commit 5093637

Please sign in to comment.