Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add parse, format, formatOnBlur to getFieldProps() #2255

Merged
merged 12 commits into from
Oct 27, 2020
5 changes: 5 additions & 0 deletions examples/format-string-by-pattern/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Formik Formating Strings by Pattern Example

This example demonstrates how to format a string by a pattern with Formik. We use the [format-string-by-pattern](https://github.com/arthurdenner/format-string-by-pattern) package to help with parsing.

[![Edit formik-example-format-string-by-patter](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/jaredpalmer/formik/tree/master/examples/format-string-by-patter?fontsize=14&hidenavigation=1&theme=dark)
50 changes: 50 additions & 0 deletions examples/format-string-by-pattern/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Formik, Field, Form } from 'formik';
import formatString from 'format-string-by-pattern';

const masks = [
{ name: 'phone-1', parse: '999-999-9999' },
{ name: 'phone-2', parse: '(999) 999-9999' },
{ name: 'phone-3', parse: '+49 (AAAA) BBBBBB' },
];

const sleep = ms => new Promise(r => setTimeout(r, ms));

const Example = () => {
return (
<div>
<Formik
initialValues={masks.reduce((prev, curr) => {
prev[curr.name] = '';
return prev;
}, {})}
onSubmit={async values => {
await sleep(500);
alert(JSON.stringify(values, null, 2));
}}
render={({ values }) => (
<Form>
{masks.map(mask => (
<div key={mask.name}>
<label>
{mask.name}
<Field
name={mask.name}
parse={formatString(mask.parse)}
placeholder={mask.parse}
/>
</label>
</div>
))}

<button type="submit">Submit</button>
<pre>{JSON.stringify(values, null, 2)}</pre>
</Form>
)}
/>
</div>
);
};

ReactDOM.render(<Example />, document.getElementById('root'));
18 changes: 18 additions & 0 deletions examples/format-string-by-pattern/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "formik-example-format-string-by-pattern",
"version": "0.1.0",
"description": "This example demonstrates how to format a string by a pattern with Formik",
"main": "index.js",
"dependencies": {
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-scripts": "3.3.0",
"format-string-by-pattern": "1.1.1",
"formik": "latest"
},
"prettier": {
"trailingComma": "es5",
"singleQuote": true,
"semi": true
}
}
5 changes: 5 additions & 0 deletions examples/parse-format/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Formik Basic Example

This example demonstrates how to use `parse` and `format` to create an input mask with Formik.

[![Edit formik-example-parse-format](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/jaredpalmer/formik/tree/master/examples/parse-format?fontsize=14&hidenavigation=1&theme=dark)
38 changes: 38 additions & 0 deletions examples/parse-format/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Formik, Field, Form } from 'formik';

const sleep = ms => new Promise(r => setTimeout(r, ms));

const Example = () => {
return (
<div>
<Formik
initialValues={{
username: '',
}}
onSubmit={async values => {
await sleep(500);
alert(JSON.stringify(values, null, 2));
}}
>
{({ values }) => (
<Form>
<label htmlFor="username">Username</label>
<Field
id="username"
name="username"
parse={value => value && value.toUpperCase()}
format={value => (value ? value.toLowerCase() : '')}
/>

<button type="submit">Submit</button>
<pre>{JSON.stringify(values, null, 2)}</pre>
</Form>
)}
</Formik>
</div>
);
};

ReactDOM.render(<Example />, document.getElementById('root'));
17 changes: 17 additions & 0 deletions examples/parse-format/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "formik-example-parse-format",
"version": "0.1.0",
"description": "This example demonstrates how to use parse and format to create an input mask with Formik",
"main": "index.js",
"dependencies": {
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-scripts": "3.3.0",
"formik": "latest"
},
"prettier": {
"trailingComma": "es5",
"singleQuote": true,
"semi": true
}
}
20 changes: 18 additions & 2 deletions packages/formik/src/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ export interface FieldConfig<V = any> {
*/
validate?: FieldValidator;

/**
* Function to parse raw input value before setting it to state
*/
parse?: (value: unknown, name: string) => any;

/**
* Function to transform value passed to input
*/
format?: (value: any, name: string) => any;
johnrom marked this conversation as resolved.
Show resolved Hide resolved

/**
* Wait until blur event before formatting input value?
* @default false
*/
formatOnBlur?: boolean;

/**
* Field name
*/
Expand Down Expand Up @@ -192,7 +208,7 @@ export function Field({
if (component) {
// This behavior is backwards compat with earlier Formik 0.9 to 1.x
if (typeof component === 'string') {
const { innerRef, ...rest } = props;
const { innerRef, parse, format, ...rest } = props;
return React.createElement(
component,
{ ref: innerRef, ...field, ...rest },
Expand All @@ -211,7 +227,7 @@ export function Field({
const asElement = is || 'input';

if (typeof asElement === 'string') {
const { innerRef, ...rest } = props;
const { innerRef, parse, format, ...rest } = props;
return React.createElement(
asElement,
{ ref: innerRef, ...field, ...rest },
Expand Down
102 changes: 94 additions & 8 deletions packages/formik/src/Formik.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
getActiveElement,
getIn,
isObject,
isInputEvent,
isReactNative,
} from './utils';
import { FormikProvider } from './FormikContext';
import invariant from 'tiny-warning';
Expand Down Expand Up @@ -51,6 +53,16 @@ type FormikMessage<Values> =
payload: FormikState<Values>;
};

const defaultParseFn = (value: unknown, _name: string) => value;
const numberParseFn = (value: any, _name: string) => {
const parsed = parseFloat(value);

return isNaN(parsed) ? '' : parsed;
};

const defaultFormatFn = (value: unknown, _name: string) =>
johnrom marked this conversation as resolved.
Show resolved Hide resolved
value === undefined ? '' : value;

// State reducer
function formikReducer<Values>(
state: FormikState<Values>,
Expand Down Expand Up @@ -892,22 +904,53 @@ export function useFormik<Values extends FormikValues = FormikValues>({
[state.errors, state.touched, state.values]
);

const getFieldHelpers = React.useCallback(
const getFieldHelpers = useEventCallback(
(name: string): FieldHelperProps<any> => {
return {
setValue: (value: any) => setFieldValue(name, value),
setTouched: (value: boolean) => setFieldTouched(name, value),
setError: (value: any) => setFieldError(name, value),
};
},
[setFieldValue, setFieldTouched, setFieldError]
}
);

const getValueFromEvent = useEventCallback(
(event: React.SyntheticEvent<any>, fieldName: string) => {
// React Native/Expo Web/maybe other render envs
if (
!isReactNative &&
event.nativeEvent &&
(event.nativeEvent as any).text !== undefined
) {
return (event.nativeEvent as any).text;
}

// React Native
if (isReactNative && event.nativeEvent) {
return (event.nativeEvent as any).text;
}

const target = event.target ? event.target : event.currentTarget;
const { type, value, checked, options, multiple } = target;

return /checkbox/.test(type) // checkboxes
? getValueForCheckbox(getIn(state.values, fieldName!), checked, value)
: !!multiple // <select multiple>
? getSelectedValues(options)
: value;
}
);

const getFieldProps = React.useCallback(
(nameOrOptions): FieldInputProps<any> => {
const isAnObject = isObject(nameOrOptions);
const name = isAnObject ? nameOrOptions.name : nameOrOptions;
const name = isAnObject
? nameOrOptions.name
? nameOrOptions.name
: nameOrOptions.id
: nameOrOptions;
const valueState = getIn(state.values, name);
const touchedState = getIn(state.touched, name);

const field: FieldInputProps<any> = {
name,
Expand All @@ -921,6 +964,9 @@ export function useFormik<Values extends FormikValues = FormikValues>({
value: valueProp, // value is special for checkboxes
as: is,
multiple,
parse = /number|range/.test(type) ? numberParseFn : defaultParseFn,
format = defaultFormatFn,
formatOnBlur = false,
} = nameOrOptions;

if (type === 'checkbox') {
Expand All @@ -939,10 +985,43 @@ export function useFormik<Values extends FormikValues = FormikValues>({
field.value = field.value || [];
johnrom marked this conversation as resolved.
Show resolved Hide resolved
field.multiple = true;
}

if (type !== 'radio' && type !== 'checkbox' && !!format) {
if (formatOnBlur === true) {
if (touchedState === true) {
field.value = format(field.value);
}
} else {
field.value = format(field.value);
}
}

// We incorporate the fact that we know the `name` prop by scoping `onChange`.
// In addition, to support `parse` fn, we can't just re-use the OG `handleChange`, but
// instead re-implement it's guts.
if (type !== 'radio' && type !== 'checkbox') {
field.onChange = (eventOrValue: React.ChangeEvent<any> | any) => {
Copy link
Collaborator

@johnrom johnrom Jan 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the parse function provides the perfect opportunity to decouple the checkbox and radio parsing from handleChange, converting value: unknown to a custom shape. Then if a user wants to override this logic, they can easily.

// imagine I usedCallback() and stuff
const handleChange = (value: any, field: FieldProps<any>) =>
   setFieldValue(field.name, field.parse(field.value, field);

// note this function explicitly allows `any` value, not necessary to use unknown
const defaultParseFn = (value: any, field: FieldProps<any>) => {
    /number|range/.test(field.type)
        ? ((parsed = parseFloat(value)), isNaN(parsed) ? '' : parsed)
        // checkboxes
        : /checkbox/.test(type) 
            ? getValueForCheckbox(getIn(state.values, fieldName!), checked, value)
            // <select multiple>
            : !!multiple
                ? getSelectedValues(options)
                : value;
}

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Problem is that checkbox relies on current state for getValuesForCheckbox(). Technically we would need to provide that. What do you think?

Copy link
Collaborator

@johnrom johnrom Jan 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If that's the case, I think we can provide field.defaultParseFn or something.

Copy link
Collaborator

@johnrom johnrom Jan 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, you mean in the event the user wants to getValuesForCheckbox in userland? hmm.. maybe we'll need to pass formikBag as a third argument, or expose the field's complete value in some way.

If you can figure out a way to use parse from outside of Formik (with as little Formik surface area as possible...) to update a multi checkbox field, I think that's something that would be required of the parse function. I use that in my onParse function for a custom CheckboxGroup that I use.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've revised my thoughts here, and I agree that onChange should handle the array parsing for checkboxes and multi-selects. But getValueOrEvent should return an unparsed single value, with the default parse function for a number or range field running parseFloat for backwards compatibility.

Copy link

@lifeiscontent lifeiscontent Mar 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TLDR: keep values as strings for the default.

FWIW currently, in the app, I'm working on I have to both serialize and parse numbers to make sure they're expected values when Formik is transforming them correctly and GraphQL knows its an Int.

I get initialValues from query parameters in the URL, which are all strings. Then I parse them into Numbers if they're defined, and if they're not, I need to cast them to ''. Next, I need to make sure they're numbers, to then tell GraphQL this is an int or fallback to null to not use the variable. This would have been a lot easier had Formik not changed the values to a number. because then I could just cast them when making the query to GraphQL.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lifeiscontent you can override this functionality yourself with parse={value => value}

we need to keep the default parsing ints for backwards compatibility for now.

if (isInputEvent(eventOrValue)) {
if (eventOrValue.persist) {
eventOrValue.persist();
}
setFieldValue(name, parse(getValueFromEvent(eventOrValue, name)));
} else {
setFieldValue(name, parse(eventOrValue));
}
};
}
}
return field;
},
[handleBlur, handleChange, state.values]
[
getValueFromEvent,
handleBlur,
handleChange,
setFieldValue,
state.touched,
state.values,
]
);

const dirty = React.useMemo(
Expand Down Expand Up @@ -1142,9 +1221,16 @@ function arrayMerge(target: any[], source: any[], options: any): any[] {

/** Return multi select values based on an array of options */
function getSelectedValues(options: any[]) {
return Array.from(options)
.filter(el => el.selected)
.map(el => el.value);
const result = [];
if (options) {
for (let index = 0; index < options.length; index++) {
const option = options[index];
if (option.selected) {
result.push(option.value);
}
}
}
return result;
}

/** Return the next value for a checkbox */
Expand Down
7 changes: 7 additions & 0 deletions packages/formik/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ export const isPromise = (value: any): value is PromiseLike<any> =>
export const isInputEvent = (value: any): value is React.SyntheticEvent<any> =>
value && isObject(value) && isObject(value.target);

/** @private Are we in RN? */
export const isReactNative =
typeof window !== 'undefined' &&
window.navigator &&
window.navigator.product &&
window.navigator.product === 'ReactNative';

/**
* Same as document.activeElement but wraps in a try-catch block. In IE it is
* not safe to call document.activeElement if there is nothing focused.
Expand Down
12 changes: 6 additions & 6 deletions packages/formik/test/Field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,11 @@ describe('Field / FastField', () => {
</>
);

const { handleBlur, handleChange } = getFormProps();
const { handleBlur } = getFormProps();
injected.forEach((props, idx) => {
expect(props.field.name).toBe('name');
expect(props.field.value).toBe('jared');
expect(props.field.onChange).toBe(handleChange);
expect(props.field.onChange).toEqual(expect.any(Function));
expect(props.field.onBlur).toBe(handleBlur);
expect(props.form).toEqual(getFormProps());
if (idx !== 2) {
Expand All @@ -145,7 +145,7 @@ describe('Field / FastField', () => {

expect(asInjectedProps.name).toBe('name');
expect(asInjectedProps.value).toBe('jared');
expect(asInjectedProps.onChange).toBe(handleChange);
expect(asInjectedProps.onChange).toEqual(expect.any(Function));
expect(asInjectedProps.onBlur).toBe(handleBlur);

expect(queryAllByText(TEXT)).toHaveLength(4);
Expand All @@ -170,11 +170,11 @@ describe('Field / FastField', () => {
</>
);

const { handleBlur, handleChange } = getFormProps();
const { handleBlur } = getFormProps();
injected.forEach((props, idx) => {
expect(props.field.name).toBe('name');
expect(props.field.value).toBe('jared');
expect(props.field.onChange).toBe(handleChange);
expect(props.field.onChange).toEqual(expect.any(Function));
expect(props.field.onBlur).toBe(handleBlur);
expect(props.form).toEqual(getFormProps());
if (idx !== 2) {
Expand All @@ -191,7 +191,7 @@ describe('Field / FastField', () => {

expect(asInjectedProps.name).toBe('name');
expect(asInjectedProps.value).toBe('jared');
expect(asInjectedProps.onChange).toBe(handleChange);
expect(asInjectedProps.onChange).toEqual(expect.any(Function));
expect(asInjectedProps.onBlur).toBe(handleBlur);
expect(queryAllByText(TEXT)).toHaveLength(4);
});
Expand Down