Skip to content

Commit

Permalink
Refactor validation callbacks handling and API
Browse files Browse the repository at this point in the history
- Implement a Promise-like validation API for more convenient
handling
- Slightly refactor demo app
- Improve readme
  • Loading branch information
akuzko committed Aug 8, 2019
1 parent a75cf4b commit 4e39576
Show file tree
Hide file tree
Showing 8 changed files with 155 additions and 50 deletions.
83 changes: 65 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@ function MyForm({onSave}) {

return (
<>
<TextField { ...input('email') } label="Email" />
<TextField { ...input('fullName') } label="Full Name" />
<TextField { ...input("email") } label="Email" />
<TextField { ...input("fullName") } label="Full Name" />

<Select { ...input('address.countryId') } options={ countryOptions } label="Country" />
<TextField { ...input('address.city') } label="City" />
<TextField { ...input('address.line') } label="Address" />
<Select { ...input("address.countryId") } options={ countryOptions } label="Country" />
<TextField { ...input("address.city") } label="City" />
<TextField { ...input("address.line") } label="Address" />

<button onClick={save}>Submit</button>
<button onClick={ save }>Submit</button>
</>
);
}
Expand Down Expand Up @@ -139,7 +139,7 @@ All of the helper functions returned by `useForm` hook, with the exception of
`get` and `getError` functions that depends on form attributes and errors whenever
they change, are persistent and do not change on per render basis. The same goes
for values returned by `$`/`input` helper - as long as on-change handler passed
to `$` function is persistent (and if it was omitted), it's `onChange` property
to `$` function is persistent (or if it was omitted), it's `onChange` property
will be persistent as well, i.e. pure input components that consume it won't be
re-rendered if other properties do not change too.

Expand Down Expand Up @@ -168,7 +168,7 @@ their business logic, the most common use case scenario is to allow user
to specify custom error message when validation is failed.

```js
import { defValidation } from 'ok-react-use-form';
import { defValidation } from "ok-react-use-form";

// Define very primitive validations for demonstration purposes.
// All validation rules should be defined only once on your app initialization.
Expand Down Expand Up @@ -223,16 +223,19 @@ function UserForm() {
}
});

const save = () => {
validate({
onValid() {
// do something on successfull validation
},
onError(errors) {
// do something if validation failed
}
});
};
const save = useCallback(() => {
validate()
.then((attrs) => {
// Do something on successful validation.
// `attrs` is identical to `get()` helper call
})
.catch((errors) {
// Do something if validation failed. At this moment
// errors are already rendered.
// It is safe to omit this `.catch` closure - no
// exception will be thrown.
});
});

return (
<>
Expand All @@ -253,6 +256,30 @@ It's up to you how to define validation rules. But as for suggested solution,
you might want to take a look at [`validate.js`](https://validatejs.org/) project
and adopt it's functionality for validation definitions.

#### `withValidation` Helper

It's pretty common to perform some action as soon as form has no errors and
validation passes. For such case there is `withValidation` helper that accepts
a callback and wraps it in validation routines. This callback will be called
only if for had no errors:

```jsx
const {$, withValidation} = useForm({}, {
name: "presence"
});

const save = (attrs) => {
// send `attrs` to server
};

return (
<>
<Input { ...$("name") } />
<button onClick={ withValidation(save) }>Submit</button>
</>
);
```

### Internationalized Validation Error Messages

Depending on adopted i18n solution in your application, there are different ways of
Expand Down Expand Up @@ -345,6 +372,26 @@ export function Form() {
- `reset(initial)` - clears form errors and sets form attributes provided value.
If no value provided, uses object that was passed to initial `useForm` hook call.

### Better Naming

Naming variables is hard. Naming npm packages is even harder. Especially considering
that names can be taken. Since `ok-react-use-form` is pretty cumbersome to write
over and over again, there are few options that can improve usage experience:

- Webpack users can use [`resolve.alias`](https://webpack.js.org/configuration/resolve/#resolvealias)
configuration option to set up an alias like `use-form` to use within application.
- The most generic solution would be to re-export package functionality from some
part of your application, alongside with your inputs. For instance, you might have
`/components/form/index.js` file with following content:
```js
export * from "ok-react-use-form";
export * from "./inputs";
```
And then in your logic components you might have:
```js
import { useForm, Input } from "components/form";
```

## License

MIT
40 changes: 19 additions & 21 deletions examples/src/Form.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useState, useMemo, useCallback } from "react";
import Input from "./Input";
import Checkbox from "./Checkbox";

import { useForm, defValidation } from "../../src";
import { useForm } from "../../src";

function useTranslation() {
return {t};
Expand All @@ -13,24 +13,6 @@ function useTranslation() {
}
}

defValidation("presence", (value, {t, message}) => {
if (!value) {
return message || t("form.validations.cant_be_blank");
}

if (Array.isArray(value) && value.length === 0) {
return t('form.validations.cant_be_blank');
}
});

defValidation("format", (value, {t, message, pattern}) => {
if (!value) return;

if (!pattern.test(value)) {
return message || t("form.validations.invalid_format");
}
});

const initialForm = {
username: "",
items: []
Expand All @@ -40,7 +22,16 @@ export default function Form() {
const [saving, setSaving] = useState(false);
const [validationEnabled, setValidationEnabled] = useState(true);
const {t} = useTranslation();
const {$, get, set, getError, setError, withValidation, reset: doReset} = useForm(initialForm, useMemo(() => ({
const {
$,
get,
set,
getError,
setError,
withValidation,
reset: doReset,
validate: doValidate
} = useForm(initialForm, useMemo(() => ({
useMemo: false,
validations: validationEnabled && {
defaultOptions: {t},
Expand Down Expand Up @@ -90,6 +81,12 @@ export default function Form() {
set("items", nextItems);
}, [items]);

const validate = useCallback(() => {
doValidate()
.then(attrs => console.log("Form is valid", attrs))
.catch(errors => console.log("Form has errors", errors));
});

const save = useCallback(() => {
setSaving(true);
setTimeout(() => {
Expand All @@ -110,7 +107,7 @@ export default function Form() {
<Checkbox value={ validationEnabled } onChange={ setValidationEnabled } label="Client Validation" />
</div>

<div>
<div className="username">
<Input { ...$("username", changeUsername) } placeholder="Username" />
</div>

Expand Down Expand Up @@ -140,6 +137,7 @@ export default function Form() {

<div>
<button onClick={ reset }>Reset</button>
<button onClick={ validate }>Validate</button>
<button onClick={ submit }>Submit</button>
</div>

Expand Down
2 changes: 2 additions & 0 deletions examples/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ import React from "react";
import { render } from "react-dom";
import Form from "./Form";

import "./validations";

render(<Form />, document.getElementById("root"));
19 changes: 19 additions & 0 deletions examples/src/validations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { defValidation } from "../../src";

defValidation("presence", (value, {t, message}) => {
if (!value) {
return message || t("form.validations.cant_be_blank");
}

if (Array.isArray(value) && value.length === 0) {
return t("form.validations.cant_be_blank");
}
});

defValidation("format", (value, {t, message, pattern}) => {
if (!value) return;

if (!pattern.test(value)) {
return message || t("form.validations.invalid_format");
}
});
14 changes: 7 additions & 7 deletions src/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default function reducer(state, action) {
return {...state, attrs: nextAttrs, errors: nextErrors};
}
case "validate": {
const {onValid, onError} = action;
const {resolve, reject} = action;
const nextErrors = {};

Object.keys(validations).forEach((rule) => {
Expand All @@ -59,10 +59,10 @@ export default function reducer(state, action) {

const isValid = Object.getOwnPropertyNames(nextErrors).length === 0;

if (isValid && onValid) {
requestAnimationFrame(() => onValid(attrs));
} else if (!isValid && onError) {
requestAnimationFrame(() => onError(nextErrors));
if (isValid) {
resolve(attrs);
} else {
reject(nextErrors);
}

return {
Expand Down Expand Up @@ -112,8 +112,8 @@ export function setAttrs(attrs) {
return {type: "setAttrs", attrs};
}

export function validate(callbacks) {
return {type: "validate", ...callbacks};
export function validate(resolve, reject) {
return {type: "validate", resolve, reject};
}

export function setError(name, error) {
Expand Down
11 changes: 8 additions & 3 deletions src/useForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import reducer, {
setErrors as doSetErrors,
reset as doReset
} from "./reducer";
import { ValidationPromise } from "./validations";
import HandlersCache from "./HandlersCache";

export function useForm(initialAttrs, config = {}) {
Expand All @@ -33,7 +34,11 @@ export function useForm(initialAttrs, config = {}) {
}
}, []);

const validate = useCallback((callbacks = {}) => dispatch(doValidate(callbacks)), []);
const validate = useCallback(() => {
return new ValidationPromise((resolve, reject) => {
dispatch(doValidate(resolve, reject));
});
}, []);

const getError = useCallback((path) => errors[path], [errors]);

Expand All @@ -43,14 +48,14 @@ export function useForm(initialAttrs, config = {}) {

const reset = useCallback((attrs) => dispatch(doReset(attrs)), []);

const withValidation = (callback) => () => validate({onValid: callback});
const withValidation = (callback) => () => validate().then(callback);

const defaultOnChange = useCallback((path, value) => set(path, value), []);

const input = (path, onChange = defaultOnChange) => {
return {
value: get(path),
onChange: handlersCache.fetch(path, onChange, () => value => onChange(path, value)),
onChange: handlersCache.fetch(path, onChange, () => (value, ...args) => onChange(path, value, ...args)),
error: errors[path],
name: path
};
Expand Down
32 changes: 32 additions & 0 deletions src/validations.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,35 @@ function stringToValidator(name) {
function wildcard(name) {
return name.replace(/\d+/g, "*");
}

export class ValidationPromise {
constructor(fn) {
this.promise = new Promise(fn)
.then((attrs) => {
this.success = true;
return attrs;
}, (errors) => {
this.errors = errors;
});
}

then(fn) {
this.promise = this.promise.then((result) => {
if (this.success) {
return fn(result);
}
});

return this;
}

catch(fn) {
this.promise = this.promise.then(() => {
if (this.errors) {
fn(this.errors);
}
});

return this;
}
}
4 changes: 3 additions & 1 deletion test/useForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,9 @@ describe("useForm", () => {
});

const save = () => {
validate({ onValid, onError });
validate()
.then(onValid)
.catch(onError);
};

return (
Expand Down

0 comments on commit 4e39576

Please sign in to comment.