Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
## [`master`](https://github.com/elastic/eui/tree/master)

No public interface changes since `28.4.0`.
**Bug fixes**

- Fixed ref not being handled properly in `EuiValidatableControl` when used with [react-hook-form](https://react-hook-form.com/) ([#4001](https://github.com/elastic/eui/pull/4001))

## [`28.4.0`](https://github.com/elastic/eui/tree/v28.4.0)

Expand Down
102 changes: 102 additions & 0 deletions src/components/form/validatable_control/validatable_control.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,107 @@ describe('EuiValidatableControl', () => {
expect(ref.current).not.toBeNull();
expect(ref.current!.getAttribute('id')).toBe('testInput');
});

it('calls stable ref function only once on re-render', async () => {
const ref = jest.fn();

const Component = () => (
<EuiValidatableControl>
<input id="testInput" ref={ref} />
</EuiValidatableControl>
);

const wrapper = mount(<Component />);

expect(ref).toHaveBeenCalledTimes(1);
expect(ref.mock.calls[0][0].getAttribute('id')).toBe('testInput');

// Force re-render
wrapper.setProps({});

expect(ref).toHaveBeenCalledTimes(1);
expect(ref.mock.calls[0][0].getAttribute('id')).toBe('testInput');
});

it('calls unstable ref function again on re-render', async () => {
const ref = jest.fn();

const Component = () => (
<EuiValidatableControl>
<input id="testInput" ref={el => ref(el)} />
</EuiValidatableControl>
);

const wrapper = mount(<Component />);

expect(ref).toHaveBeenCalledTimes(1);
expect(ref.mock.calls[0][0].getAttribute('id')).toBe('testInput');

// Force re-render
wrapper.setProps({});

expect(ref).toHaveBeenCalledTimes(3);

expect(ref.mock.calls[1][0]).toBe(null);
expect(ref.mock.calls[2][0].getAttribute('id')).toBe('testInput');
});

it('calls a ref function again when the child element changes', () => {
const ref = jest.fn();

const Component = ({ change }: { change: boolean }) => (
<EuiValidatableControl>
{!change ? (
<input key="1" id="testInput" ref={ref} />
) : (
<input key="2" id="testInput2" ref={ref} />
)}
</EuiValidatableControl>
);

const wrapper = mount(<Component change={false} />);

expect(ref).toHaveBeenCalledTimes(1);
expect(ref.mock.calls[0][0].getAttribute('id')).toBe('testInput');

wrapper.setProps({ change: true });

expect(ref).toHaveBeenCalledTimes(3);

expect(ref.mock.calls[1][0]).toBe(null);
expect(ref.mock.calls[2][0].getAttribute('id')).toBe('testInput2');

// Ensure that the child element has changed
expect(ref.mock.calls[0][0]).not.toBe(ref.mock.calls[2][0]);
});

it('sets a ref object\'s "current" property when the child element changes', () => {
const ref = React.createRef<HTMLInputElement>();

const Component = ({ change }: { change: boolean }) => (
<EuiValidatableControl>
{!change ? (
<input key="1" id="testInput" ref={ref} />
) : (
<input key="2" id="testInput2" ref={ref} />
)}
</EuiValidatableControl>
);

const wrapper = mount(<Component change={false} />);

expect(ref.current).not.toBeNull();
expect(ref.current!.getAttribute('id')).toBe('testInput');

const prevRef = ref.current;

wrapper.setProps({ change: true });

expect(ref.current).not.toBeNull();
expect(ref.current!.getAttribute('id')).toBe('testInput2');

// Ensure that the child element has changed
expect(ref.current).not.toBe(prevRef);
});
});
});
74 changes: 35 additions & 39 deletions src/components/form/validatable_control/validatable_control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@
import {
Children,
cloneElement,
Component,
MutableRefObject,
ReactElement,
Ref,
FunctionComponent,
useRef,
useEffect,
useCallback,
} from 'react';
import { CommonProps } from '../../common';

Expand All @@ -49,50 +52,43 @@ export interface EuiValidatableControlProps {
children: ReactElementWithRef;
}

export class EuiValidatableControl extends Component<
CommonProps & EuiValidatableControlProps
> {
private control?: HTMLConstraintValidityElement;
export const EuiValidatableControl: FunctionComponent<CommonProps &
EuiValidatableControlProps> = ({ isInvalid, children }) => {
const control = useRef<HTMLConstraintValidityElement | null>(null);

updateValidity() {
const child = Children.only(children);
const childRef = child.ref;

const replacedRef = useCallback(
(element: HTMLConstraintValidityElement) => {
control.current = element;

// Call the original ref, if any
if (typeof childRef === 'function') {
childRef(element);
} else if (isMutableRef(childRef)) {
childRef.current = element;
}
},
[childRef]
);

useEffect(() => {
if (
this.control == null ||
typeof this.control.setCustomValidity !== 'function'
control.current === null ||
typeof control.current.setCustomValidity !== 'function'
) {
return; // jsdom doesn't polyfill this for the server-side
}

if (this.props.isInvalid) {
this.control.setCustomValidity('Invalid');
if (isInvalid) {
control.current.setCustomValidity('Invalid');
} else {
this.control.setCustomValidity('');
control.current.setCustomValidity('');
}
}

componentDidMount() {
this.updateValidity();
}

componentDidUpdate() {
this.updateValidity();
}
});

setRef = (element: HTMLConstraintValidityElement) => {
this.control = element;

// Call the original ref, if any
const { ref } = this.props.children;
if (typeof ref === 'function') {
ref(element);
} else if (isMutableRef(ref)) {
ref.current = element;
}
};

render() {
const child = Children.only(this.props.children);
return cloneElement(child, {
ref: this.setRef,
});
}
}
return cloneElement(child, {
ref: replacedRef,
});
};