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

useControlledValue updates [LG-3608] #1953

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
753ba16
Extends `useControlledValue` to accept any type
TheSonOfThomp Aug 24, 2023
eec25b7
Merge branch 'main' into adam/generic-useControlledValue
TheSonOfThomp Aug 24, 2023
c565f26
Adds more explicit tests
TheSonOfThomp Aug 24, 2023
b612835
adds test component tests
TheSonOfThomp Aug 24, 2023
f4e1c61
adds value tests
TheSonOfThomp Aug 24, 2023
bbb1832
isControlled never changes from initial render
TheSonOfThomp Aug 24, 2023
af6cc48
adds synthetic events
TheSonOfThomp Aug 24, 2023
f6751e8
adds initialValue & warning
TheSonOfThomp Aug 24, 2023
4f37e4b
Merge branch 'main' into adam/generic-useControlledValue
TheSonOfThomp Aug 24, 2023
0fd6d6b
Update useControlledValue.spec.tsx
TheSonOfThomp Aug 25, 2023
405229a
resolves circular dependency in lib
TheSonOfThomp Aug 25, 2023
152db57
expect no console
TheSonOfThomp Aug 25, 2023
3d0abb5
Merge branch 'main' into adam/generic-useControlledValue
TheSonOfThomp Aug 25, 2023
50a45ae
Adds NextLink legacyBehavior test
TheSonOfThomp Aug 25, 2023
d2377e7
Merge branch 'fix-button-test-next' into adam/generic-useControlledValue
TheSonOfThomp Aug 25, 2023
80dc30c
rm lib from dev dep
TheSonOfThomp Aug 25, 2023
a1c34e9
Merge branch 'main' into adam/generic-useControlledValue
TheSonOfThomp Aug 25, 2023
a697215
Update useControlledValue.spec.tsx
TheSonOfThomp Sep 15, 2023
7a833ae
updates useControlled value to be more flexible
TheSonOfThomp Sep 18, 2023
cf61a27
add warnings
TheSonOfThomp Sep 18, 2023
d4af1bd
Update soft-berries-obey.md
TheSonOfThomp Sep 18, 2023
799a672
Merge branch 'main' into adam/generic-useControlledValue
TheSonOfThomp Sep 18, 2023
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
8 changes: 8 additions & 0 deletions .changeset/soft-berries-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@leafygreen-ui/hooks': minor
---

- Extends `useControlledValue` to accept any type.
- Adds `updateValue` function in return value. This method triggers a synthetic event to update the value of a controlled or uncontrolled component.
TheSonOfThomp marked this conversation as resolved.
Show resolved Hide resolved
- Adds `initialValue` argument. Used for setting the initial value for uncontrolled components. Without this we may encounter a React error for switching between controlled/uncontrolled inputs
- The value of `isControlled` is now immutable after the first render
4 changes: 2 additions & 2 deletions packages/hooks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"access": "public"
},
"dependencies": {
"@leafygreen-ui/lib": "^11.0.0",
"lodash": "^4.17.21"
},
"gitHead": "dd71a2d404218ccec2e657df9c0263dc1c15b9e0",
Expand All @@ -34,7 +35,6 @@
"url": "https://jira.mongodb.org/projects/PD/summary"
},
"devDependencies": {
"@leafygreen-ui/emotion": "^4.0.7",
"@leafygreen-ui/lib": "^11.0.0"
"@leafygreen-ui/emotion": "^4.0.7"
}
}

This file was deleted.

288 changes: 288 additions & 0 deletions packages/hooks/src/useControlledValue/useControlledValue.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
import React from 'react';
import { ChangeEventHandler } from 'react';
import { act } from 'react-test-renderer';
import { render } from '@testing-library/react';
import { renderHook, RenderHookResult } from '@testing-library/react-hooks';
import userEvent from '@testing-library/user-event';

import { useControlledValue } from './useControlledValue';

const errorSpy = jest.spyOn(console, 'error');

const renderUseControlledValueHook = <T extends any>(
...[valueProp, callback, initial]: Parameters<typeof useControlledValue<T>>
): RenderHookResult<T, ReturnType<typeof useControlledValue<T>>> => {
const result = renderHook(v => useControlledValue(v, callback, initial), {
initialProps: valueProp,
});

return { ...result };
};

describe('packages/hooks/useControlledValue', () => {
beforeEach(() => {
errorSpy.mockImplementation(() => {});
});

afterEach(() => {
errorSpy.mockReset();
});

test('rendering without any arguments sets hook to uncontrolled', () => {
const {
result: { current },
} = renderUseControlledValueHook();
expect(current.isControlled).toEqual(false);
});

describe('accepts various value types', () => {
test('accepts number values', () => {
const {
result: { current },
} = renderUseControlledValueHook(5);
expect(current.value).toBe(5);
});

test('accepts boolean values', () => {
const {
result: { current },
} = renderUseControlledValueHook(false);
expect(current.value).toBe(false);
});

test('accepts array values', () => {
const arr = ['foo', 'bar'];
const {
result: { current },
} = renderUseControlledValueHook(arr);
expect(current.value).toBe(arr);
});

test('accepts object values', () => {
const obj = { foo: 'foo', bar: 'bar' };
const {
result: { current },
} = renderUseControlledValueHook(obj);
expect(current.value).toBe(obj);
});

test('accepts date values', () => {
const date = new Date('2023-08-23');
const {
result: { current },
} = renderUseControlledValueHook(date);
expect(current.value).toBe(date);
});

test('accepts multiple/union types', () => {
const { result, rerender } = renderHook(
v => useControlledValue<string | number>(v),
{
initialProps: 5 as string | number,
},
);
expect(result.current.value).toBe(5);
act(() => {
rerender('foo');
});
expect(result.current.value).toBe('foo');
});
});

describe('Controlled', () => {
test('rendering with a value sets value and isControlled', () => {
const {
result: { current },
} = renderUseControlledValueHook('apple');
expect(current.isControlled).toBe(true);
expect(current.value).toBe('apple');
});

test('rerendering from initial undefined sets value and isControlled', async () => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Do we want this case to log an error?

const { rerender, result } = renderUseControlledValueHook();
rerender('apple');

expect(result.current.isControlled).toBe(true);
expect(result.current.value).toEqual('apple');
});

test('rerendering with a new value changes the value', () => {
const { rerender, result } = renderUseControlledValueHook('apple');

expect(result.current.value).toBe('apple');

rerender('banana');
expect(result.current.value).toBe('banana');
});

test('provided handler is called within `updateValue`', () => {
const handler = jest.fn();
const {
result: { current },
} = renderUseControlledValueHook<string>('apple', handler);

current.updateValue('banana');

expect(handler).toHaveBeenCalledWith('banana');
});

test('hook value does not change when `updateValue` is called', () => {
const {
result: { current },
} = renderUseControlledValueHook<string>('apple');

current.updateValue('banana');

// value doesn't change unless we explicitly change it
expect(current.value).toBe('apple');
});

test('setting value to undefined should keep the component controlled', () => {
const {
rerender,
result: { current },
} = renderUseControlledValueHook<string>('apple');
expect(current.isControlled).toBe(true);
act(() => rerender(undefined));
expect(current.isControlled).toBe(true);
});

test('initial value is ignored when controlled', () => {
const {
result: { current },
} = renderUseControlledValueHook<string>('apple', () => {}, 'banana');
expect(current.value).toBe('apple');
});
});

describe('Uncontrolled', () => {
test('calling without a value sets value to `initialValue`', () => {
const {
result: { current },
} = renderUseControlledValueHook(undefined, () => {}, 'apple');

expect(current.isControlled).toBe(false);
expect(current.value).toBe('apple');
});

test('provided handler is called within `updateValue`', () => {
const handler = jest.fn();
const {
result: { current },
} = renderUseControlledValueHook(undefined, handler);

current.updateValue('apple');
expect(handler).toHaveBeenCalledWith('apple');
});

test('updateValue updates the value', () => {
const { result } = renderUseControlledValueHook<string>(undefined);
result.current.updateValue('banana');
expect(result.current.value).toBe('banana');
});
});

describe('Within test component', () => {
const TestComponent = ({
valueProp,
handlerProp,
}: {
valueProp?: string;
handlerProp?: (val?: string) => void;
}) => {
const initialVal = '';
// eslint-disable-next-line react-hooks/rules-of-hooks
const { value, updateValue } = useControlledValue(
valueProp,
handlerProp,
initialVal,
);

const handleChange: ChangeEventHandler<HTMLInputElement> = e => {
updateValue(e.target.value);
};

return (
<>
<input
data-testid="test-input"
value={value}
onChange={handleChange}
/>
<button
data-testid="test-button"
onClick={() => updateValue('carrot')}
/>
</>
);
};

describe('Controlled test component', () => {
test('initially renders with a value', () => {
const result = render(<TestComponent valueProp="apple" />);
const input = result.getByTestId('test-input');
expect(input).toHaveValue('apple');
});

test('responds to value changes', () => {
const result = render(<TestComponent valueProp="apple" />);
const input = result.getByTestId('test-input');
result.rerender(<TestComponent valueProp="banana" />);
expect(input).toHaveValue('banana');
});

test('user interaction triggers handler', () => {
const handler = jest.fn();
const result = render(
<TestComponent valueProp="apple" handlerProp={handler} />,
);
const input = result.getByTestId('test-input');
userEvent.type(input, 'b');
expect(handler).toHaveBeenCalledWith(expect.stringContaining('b'));
});

test('user interaction does not change the element value', () => {
const handler = jest.fn();
const result = render(
<TestComponent valueProp="apple" handlerProp={handler} />,
);
const input = result.getByTestId('test-input');
userEvent.type(input, 'b');
expect(input).toHaveValue('apple');
});
});

describe('Uncontrolled test component', () => {
test('initially renders without a value', () => {
const result = render(<TestComponent />);
const input = result.getByTestId('test-input');
expect(input).toHaveValue('');
expect(errorSpy).not.toHaveBeenCalled();
});

test('user interaction triggers handler', () => {
const handler = jest.fn();
const result = render(<TestComponent handlerProp={handler} />);
const input = result.getByTestId('test-input');
userEvent.type(input, 'b');
expect(handler).toHaveBeenCalled();
});

test('user interaction does not change the element value', () => {
TheSonOfThomp marked this conversation as resolved.
Show resolved Hide resolved
const handler = jest.fn();
const result = render(<TestComponent handlerProp={handler} />);
const input = result.getByTestId('test-input');
userEvent.type(input, 'banana');
expect(input).toHaveValue('banana');
});

test('clicking the button updates the value', () => {
const result = render(<TestComponent />);
const input = result.getByTestId('test-input');
const button = result.getByTestId('test-button');
userEvent.click(button);
expect(input).toHaveValue('carrot');
});
});
});
});
Loading