Skip to content

Commit 861ed8e

Browse files
authored
Merge pull request #881 from rvsia/focus
feat(manager): focus on error input
2 parents 2ae7a57 + 8b7a2de commit 861ed8e

File tree

7 files changed

+157
-17
lines changed

7 files changed

+157
-17
lines changed

packages/form-state-manager/demo/index.tsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,38 @@ import React from 'react';
22
import ReactDOM from 'react-dom';
33

44
import FormStateManager from '../src/files/form-state-manager';
5+
import FormSpy from '../src/files/form-spy';
6+
57
import TextField from '../src/tests/helpers/text-field';
68
import { Validator } from '../src/types/validate';
79

8-
const asyncValidator: Validator = (value) => new Promise((res, rej) => setTimeout(() => (value === 'foo' ? rej('No async foo') : res()), 250));
10+
const asyncValidator: Validator = (value) => new Promise((res, rej) => setTimeout(() => (value === 'foo' ? rej('No async foo') : res()), 1000));
11+
const asyncValidator1: Validator = (value) => new Promise((res, rej) => setTimeout(() => (value === 'foo' ? rej('No async foo') : res()), 3000));
912

1013
const App = () => {
1114
return (
1215
<div style={{ padding: 20 }}>
1316
<FormStateManager onSubmit={console.log}>
1417
{({ handleSubmit, ...state }) => {
1518
return (
16-
<form onSubmit={handleSubmit}>
19+
<form onSubmit={handleSubmit} name="field-2">
1720
<h1>There will be children</h1>
18-
<TextField
19-
validate={(value) => (value === 'foo' ? 'no-foo-allowed' : undefined)}
20-
label="Field 1"
21-
name="nested.field-1"
22-
id="field-1"
23-
type="text"
24-
/>
25-
<TextField validate={asyncValidator} label="Field 2" name="field-2" id="field-2" type="text" />
21+
<TextField initialValue="foo" autocomplete="off" validate={asyncValidator} label="Field 2" name="field-2-23" id="field-2" type="text" />
22+
<TextField autocomplete="off" validate={asyncValidator} label="Field 2" name="field-2" id="field-2" type="text" />
23+
<FormSpy>
24+
{(props) => (
25+
<pre>
26+
{JSON.stringify(
27+
{
28+
validate: props.valid,
29+
validating: props.validating
30+
},
31+
null,
32+
2
33+
)}
34+
</pre>
35+
)}
36+
</FormSpy>
2637
<div style={{ margin: 16 }}>
2738
<button type="submit">Submit</button>
2839
</div>

packages/form-state-manager/src/tests/helpers/text-field.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ export interface TextFieldProps {
1010
const TextField: React.ComponentType<React.HTMLProps<HTMLInputElement> & TextFieldProps & UseFieldConfig> = ({ label, id, ...props }) => {
1111
const {
1212
input,
13-
meta: { valid, error },
13+
meta: { valid, error, validating, touched },
1414
...rest
1515
} = useField(props);
1616
return (
1717
<div style={{ display: 'flex', flexDirection: 'column', margin: 16 }}>
1818
<label htmlFor={id}>{label}</label>
1919
<input id={id} {...input} {...rest} />
20-
{!valid && <p style={{ color: 'red' }}>{error}</p>}
20+
{validating && <div>I am being validated</div>}
21+
{touched && <p style={{ color: 'red' }}>{error}</p>}
2122
</div>
2223
);
2324
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import focusError from '../../utils/focus-error';
2+
3+
describe('focusError', () => {
4+
it('focus first error element', async () => {
5+
document.body.innerHTML = `<form name="foo">
6+
<input name="field-1" />
7+
<input name="field-2" />
8+
<input name="field-3" />
9+
</form>`;
10+
11+
const listener = jest.fn();
12+
13+
document.querySelector('[name="field-2"]').addEventListener('focus', listener);
14+
15+
expect(listener).not.toHaveBeenCalled();
16+
17+
focusError({ 'field-3': 'error', 'field-2': 'error' });
18+
19+
expect(listener).toHaveBeenCalled();
20+
});
21+
22+
it('focus first error element in the right form', async () => {
23+
document.body.innerHTML = `<form name="not-error">
24+
<input name="field-1" />
25+
<input id="not-error" name="this-has-error" />
26+
<input name="field-3" />
27+
</form>
28+
<form name="this-has-error">
29+
<input name="field-1" />
30+
<input id="this-has-error" name="this-has-error" />
31+
<input name="field-3" />
32+
</form>`;
33+
34+
const listenerNotError = jest.fn();
35+
const listenerError = jest.fn();
36+
37+
document.querySelector('#not-error').addEventListener('focus', listenerNotError);
38+
document.querySelector('#this-has-error').addEventListener('focus', listenerError);
39+
40+
expect(listenerNotError).not.toHaveBeenCalled();
41+
expect(listenerError).not.toHaveBeenCalled();
42+
43+
focusError({ 'field-3': 'error', 'this-has-error': 'error' }, 'this-has-error');
44+
45+
expect(listenerNotError).not.toHaveBeenCalled();
46+
expect(listenerError).toHaveBeenCalled();
47+
});
48+
});

packages/form-state-manager/src/tests/utils/manager-api.test.js

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import createManagerApi, { initialMeta, flatObject } from '../../utils/manager-api';
22
import FORM_ERROR from '../../files/form-error';
3+
import * as focusError from '../../utils/focus-error';
34

45
describe('managerApi', () => {
6+
let focusErrorSpy;
7+
8+
beforeEach(() => {
9+
focusErrorSpy = jest.spyOn(focusError, 'default');
10+
});
11+
12+
afterEach(() => {
13+
focusErrorSpy.mockClear();
14+
});
15+
516
it('should create managerApi getter', () => {
617
const managerApi = createManagerApi({});
718
expect(managerApi).toEqual(expect.any(Function));
@@ -583,7 +594,8 @@ describe('managerApi', () => {
583594
});
584595

585596
it('onsubmit receives an error', () => {
586-
const error = 'some-error';
597+
const focusErrorSpy = jest.spyOn(focusError, 'default');
598+
const error = { field: 'some-error' };
587599
const onSubmit = jest.fn().mockImplementation(() => error);
588600
const managerApi = createManagerApi({ onSubmit });
589601
const { registerField } = managerApi();
@@ -603,6 +615,23 @@ describe('managerApi', () => {
603615
expect(managerApi().submitErrors).toEqual(error);
604616
expect(managerApi().hasSubmitErrors).toEqual(true);
605617
expect(managerApi().hasValidationErrors).toEqual(false);
618+
expect(focusErrorSpy).toHaveBeenCalledWith({ field: 'some-error' }, undefined);
619+
});
620+
621+
it('onsubmit receives an error - named form', () => {
622+
const name = 'i-am-form';
623+
const focusErrorSpy = jest.spyOn(focusError, 'default');
624+
const error = { field: 'some-error' };
625+
const onSubmit = jest.fn().mockImplementation(() => error);
626+
const managerApi = createManagerApi({ onSubmit, name });
627+
const { registerField } = managerApi();
628+
629+
const render = jest.fn();
630+
registerField({ name: 'field', render });
631+
632+
managerApi().handleSubmit();
633+
634+
expect(focusErrorSpy).toHaveBeenCalledWith({ field: 'some-error' }, name);
606635
});
607636

608637
it('onsubmit receives an error - form level', () => {
@@ -2071,6 +2100,7 @@ describe('managerApi', () => {
20712100
expect(managerApi().submitSucceeded).toEqual(false);
20722101
expect(managerApi().submitErrors).toEqual(error);
20732102
expect(managerApi().hasSubmitErrors).toEqual(true);
2103+
expect(focusErrorSpy).toHaveBeenCalledWith({ 'nested.field': 'some evil error' }, undefined);
20742104

20752105
expect(managerApi().getFieldState('nested.field').submitError).toEqual('some evil error');
20762106
expect(managerApi().getFieldState('nested.field').submitting).toEqual(false);
@@ -2194,14 +2224,32 @@ describe('managerApi', () => {
21942224

21952225
expect(managerApi().getFieldState('field').touched).toEqual(false);
21962226
expect(managerApi().getFieldState('field').touched).toEqual(false);
2227+
expect(focusErrorSpy).not.toHaveBeenCalled();
21972228

21982229
managerApi().handleSubmit();
21992230

2231+
expect(focusErrorSpy).toHaveBeenCalledWith({ field: 'error' }, undefined);
22002232
expect(managerApi().getFieldState('field').touched).toEqual(true);
22012233
expect(managerApi().getFieldState('field').touched).toEqual(true);
22022234

22032235
expect(onSubmit).not.toHaveBeenCalled();
22042236
});
2237+
2238+
it('should not call submit action when invalid + all fields are touched - named field', () => {
2239+
const onSubmit = jest.fn();
2240+
const name = 'form-name';
2241+
2242+
const managerApi = createManagerApi({ onSubmit, name });
2243+
2244+
managerApi().registerField({ name: 'field', validate: () => 'error', render: jest.fn(), internalId: 1 });
2245+
managerApi().registerField({ name: 'field2', render: jest.fn(), internalId: 1 });
2246+
2247+
expect(focusErrorSpy).not.toHaveBeenCalled();
2248+
2249+
managerApi().handleSubmit();
2250+
2251+
expect(focusErrorSpy).toHaveBeenCalledWith({ field: 'error' }, name);
2252+
});
22052253
});
22062254

22072255
it('set form dirty and pristine according to fields', () => {

packages/form-state-manager/src/types/manager-api.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ export interface CreateManagerApiConfig {
201201
debug?: Debug;
202202
keepDirtyOnReinitialize?: boolean;
203203
destroyOnUnregister?: boolean;
204+
name?: string;
204205
}
205206

206207
declare type CreateManagerApi = (CreateManagerApiConfig: CreateManagerApiConfig) => ManagerApi;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import AnyObject from '../types/any-object';
2+
3+
const justFocusable = (el: any) => el?.focus;
4+
5+
const focusError = (errors: AnyObject, name?: string): void => {
6+
if (document) {
7+
const formInputs = name && document.forms[name as any]?.elements;
8+
9+
const allInputs = formInputs
10+
? Array.from(formInputs).filter(justFocusable)
11+
: Array.from(document.forms)
12+
.reduce((acc: any, curr: any) => [...acc, ...curr.elements], [])
13+
.filter(justFocusable);
14+
15+
const errorKeys = Object.keys(errors);
16+
17+
const firstError = allInputs.find((inp) => errorKeys.includes(inp.name));
18+
19+
firstError && firstError.focus();
20+
}
21+
};
22+
23+
export default focusError;

packages/form-state-manager/src/utils/manager-api.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { formLevelValidator, isPromise } from './validate';
2929
import { FormValidator, FormLevelError, Validator } from '../types/validate';
3030
import findDifference from './find-difference';
3131
import FORM_ERROR from '../files/form-error';
32+
import focusError from './focus-error';
3233

3334
export const defaultIsEqual = (a: any, b: any) => a === b;
3435

@@ -193,7 +194,8 @@ const createManagerApi: CreateManagerApi = ({
193194
initialValues,
194195
debug,
195196
keepDirtyOnReinitialize,
196-
destroyOnUnregister
197+
destroyOnUnregister,
198+
name
197199
}) => {
198200
const config: CreateManagerApiConfig = {
199201
onSubmit,
@@ -203,7 +205,8 @@ const createManagerApi: CreateManagerApi = ({
203205
subscription,
204206
debug,
205207
keepDirtyOnReinitialize,
206-
destroyOnUnregister
208+
destroyOnUnregister,
209+
name
207210
};
208211

209212
let state: ManagerState = {
@@ -264,8 +267,8 @@ const createManagerApi: CreateManagerApi = ({
264267

265268
const managerApi: ManagerApi = () => state;
266269

267-
function setConfig(attribute: keyof CreateManagerApiConfig, value: any) {
268-
config[attribute] = value;
270+
function setConfig(attribute: keyof CreateManagerApiConfig, value: CreateManagerApiConfig[keyof CreateManagerApiConfig]) {
271+
(config as AnyObject)[attribute] = value;
269272
}
270273

271274
function isValidationPaused() {
@@ -611,6 +614,8 @@ const createManagerApi: CreateManagerApi = ({
611614
}));
612615
});
613616

617+
focusError(state.errors, config.name);
618+
614619
return;
615620
}
616621

@@ -636,6 +641,7 @@ const createManagerApi: CreateManagerApi = ({
636641
handleSubmitError(errors);
637642
updateFieldSubmitMeta();
638643
render();
644+
focusError(flatSubmitErrors, config.name);
639645

640646
runAfterSubmit();
641647
})
@@ -652,6 +658,8 @@ const createManagerApi: CreateManagerApi = ({
652658

653659
render();
654660

661+
focusError(flatSubmitErrors, config.name);
662+
655663
runAfterSubmit();
656664
}
657665
}

0 commit comments

Comments
 (0)