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

Overhaul Venues components #3038

Open
wants to merge 32 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
7948eb8
Don't memoize `VenueDetails` as it should be cheap to render
taneliang Dec 6, 2020
1b81be4
Inline single-use `pageHead`
taneliang Dec 6, 2020
f573658
Memoize expensive VenueList variable computation
taneliang Dec 6, 2020
564e2e1
window.location -> useLocation in VenueList
taneliang Dec 6, 2020
689b03a
Improve AvailabilitySearch memoization
taneliang Dec 6, 2020
0284997
Roughly functionalize VenuesContainer
taneliang Dec 6, 2020
4d4c5a3
Cache sorted venues in LoadableVenuesContainer to prevent memo invali…
taneliang Dec 6, 2020
100433e
Fix updateURL (and inline it into a useEffect)
taneliang Dec 6, 2020
26790bc
Functionalize FeedbackModal
taneliang Dec 7, 2020
c732513
Functionalize VenueLocation
taneliang Dec 7, 2020
806c77e
Inline + extract AddLocationModal from VenueLocation renderFeedbackMe…
taneliang Dec 7, 2020
ee6266a
Reduce VenueList renders as they are very expensive
taneliang Dec 7, 2020
9c14cef
Extract SecondaryPane into VenueDetailsPane.tsx
taneliang Dec 7, 2020
fe07f0b
Rewrite VenuesContainer tests
taneliang Dec 7, 2020
aa79d23
Remove unstable_batchedUpdates as React already batches updates withi…
taneliang Dec 8, 2020
2238100
SearchBox: Remove useInstantSearch as it's always true
taneliang Dec 8, 2020
8e2b0be
SearchBox: Rewrite test for comprehensiveness
taneliang Dec 8, 2020
bce257b
Functionalize SearchBox
taneliang Dec 8, 2020
fc989e7
SearchBox: Replace hasChanges state with mutable ref
taneliang Dec 8, 2020
9cb8c72
SearchBox: Inline search callback into debouncedSearch
taneliang Dec 8, 2020
76b03a3
Merge branch 'master' into eliang/overhaul-venues-components
taneliang Dec 18, 2020
4d234e8
Fix review comment: hasChanges -> isDirty
taneliang Dec 18, 2020
d5b270e
Fix review comment: inline search
taneliang Dec 18, 2020
73f8c96
Fix review comment: improve clear button accessibility
taneliang Dec 18, 2020
768fa79
Fix review comment: remove unsafe casting
taneliang Dec 18, 2020
46ab5bd
Remove unnecessary type cast
taneliang Dec 18, 2020
4427239
Fix review comment: remove handleInput instanceof check
taneliang Dec 18, 2020
98b62af
Fix review comment: remove special modal element in favor of view.con…
taneliang Dec 18, 2020
db261b5
Add SearchBox debouncedSearch FIXME note
taneliang Dec 18, 2020
9d72b35
Split VenuesContainer modal test into 2
taneliang Dec 18, 2020
701099a
Merge branch 'master' into eliang/overhaul-venues-components
taneliang Dec 19, 2020
381ced3
Add comment explaining why we don't flush in clearInput
taneliang Dec 19, 2020
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 website/src/__mocks__/react-feather.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import type { ComponentType } from 'react';
import * as feather from 'react-feather';

module.exports = mapValues(feather, (_component, name) => {
const MockComponent = jest.fn(() => <div data-testid={`react-feather ${name} icon`} />);
const MockComponent = jest.fn((props) => (
<div data-testid={`react-feather ${name} icon`} {...props} />
));
(MockComponent as ComponentType).displayName = name;
return MockComponent;
});
3 changes: 3 additions & 0 deletions website/src/test-utils/mockDom.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
const nativeScroll = window.scroll;
const nativeScrollTo = window.scrollTo;
const nativePerformance = window.performance;
const nativeMatchMedia = window.matchMedia;
const nativeScrollIntoView = Element.prototype.scrollIntoView;

export function mockDom() {
// Mock some of the DOM environment functions that are missing from JSDom
window.scroll = jest.fn();
window.scrollTo = jest.fn();

if (!window.performance) {
Expand All @@ -23,6 +25,7 @@ export function mockDom() {
}

export function mockDomReset() {
window.scroll = nativeScroll;
window.scrollTo = nativeScrollTo;

// @ts-expect-error We insist
Expand Down
194 changes: 150 additions & 44 deletions website/src/views/components/SearchBox.test.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,154 @@
import { shallow } from 'enzyme';
import SearchBox from './SearchBox';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchBox, { Props } from './SearchBox';

const SOME_TEXT = 'existing';

const defaultProps: Props = {
throttle: 0,
isLoading: false,
value: SOME_TEXT,
onChange: jest.fn(),
onSearch: jest.fn(),
};

describe(SearchBox, () => {
test('should match snapshot', () => {
expect(
shallow(
<SearchBox
throttle={0}
useInstantSearch={false}
isLoading={false}
value=""
placeholder=""
onChange={jest.fn()}
onSearch={jest.fn()}
/>,
),
).toMatchSnapshot();

expect(
shallow(
<SearchBox
throttle={0}
useInstantSearch={false}
isLoading
value=""
placeholder=""
onChange={jest.fn()}
onSearch={jest.fn()}
/>,
),
).toMatchSnapshot();

expect(
shallow(
<SearchBox
throttle={0}
useInstantSearch={false}
isLoading={false}
value="Hello world"
placeholder="Testing testing 123"
onChange={jest.fn()}
onSearch={jest.fn()}
/>,
),
).toMatchSnapshot();
test('should display loading indicator if isLoading', () => {
render(<SearchBox {...defaultProps} isLoading />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});

test('should not display loading indicator if not isLoading', () => {
render(<SearchBox {...defaultProps} isLoading={false} />);
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

test('should display value', () => {
render(<SearchBox {...defaultProps} value={SOME_TEXT} />);
expect(screen.getByRole('searchbox')).toHaveValue(SOME_TEXT);
});

test('should display placeholder', () => {
render(<SearchBox {...defaultProps} value="" placeholder="abc" />);
expect(screen.getByRole('searchbox')).toHaveAttribute('placeholder', 'abc');
expect(screen.getByRole('searchbox')).toHaveValue('');
});

test('should call onChange and onSearch when user types into the input', async () => {
const mockOnChange = jest.fn();
const mockOnSearch = jest.fn();
render(<SearchBox {...defaultProps} onChange={mockOnChange} onSearch={mockOnSearch} />);

userEvent.type(screen.getByRole('searchbox'), 'ing');

expect(mockOnChange).toHaveBeenCalledTimes(3);
// `SearchBox` is a fully controlled component, so we expect the input to be
// appended to `value`.
expect(mockOnChange).toHaveBeenNthCalledWith(1, `${SOME_TEXT}i`);
expect(mockOnChange).toHaveBeenNthCalledWith(2, `${SOME_TEXT}n`);
expect(mockOnChange).toHaveBeenNthCalledWith(3, `${SOME_TEXT}g`);

await waitFor(() => expect(mockOnSearch).toHaveBeenCalledTimes(1));
});

test('should handle blur correctly', async () => {
const mockHandlers = [jest.fn(), jest.fn(), jest.fn()];
const [mockOnBlur, mockOnChange, mockOnSearch] = mockHandlers;
render(
<SearchBox
{...defaultProps}
onBlur={mockOnBlur}
onChange={mockOnChange}
onSearch={mockOnSearch}
/>,
);

const box = screen.getByRole('searchbox');

// Test blur with no changes
userEvent.click(box);
fireEvent.blur(box);
expect(mockOnBlur).toHaveBeenCalledTimes(1);
expect(mockOnChange).not.toHaveBeenCalled();
expect(mockOnSearch).not.toHaveBeenCalled();
mockHandlers.forEach((m) => m.mockClear());

// Test blur with changes
userEvent.click(box);
userEvent.type(screen.getByRole('searchbox'), '.');
fireEvent.blur(box);
expect(mockOnBlur).toHaveBeenCalledTimes(1);
expect(mockOnChange).toHaveBeenCalledTimes(2); // 1 call when character typed, 1 call on blur
expect(mockOnSearch).toHaveBeenCalledTimes(1); // No `waitFor` as we expect onSearch to be called immediately on blur
mockHandlers.forEach((m) => m.mockClear());

// Test blur with no changes *after* changes
userEvent.click(box);
fireEvent.blur(box);
expect(mockOnBlur).toHaveBeenCalledTimes(1);
expect(mockOnChange).not.toHaveBeenCalled();
expect(mockOnSearch).not.toHaveBeenCalled();
mockHandlers.forEach((m) => m.mockClear());
});

test('should handle form submit correctly', () => {
const mockHandlers = [jest.fn(), jest.fn(), jest.fn()];
const [mockOnBlur, mockOnChange, mockOnSearch] = mockHandlers;
const { container } = render(
<SearchBox
{...defaultProps}
onBlur={mockOnBlur}
onChange={mockOnChange}
onSearch={mockOnSearch}
/>,
);
const box = screen.getByRole('searchbox');
const form = container.getElementsByTagName('form')[0];

// Test submit with no changes
userEvent.click(box);
fireEvent.submit(form);
expect(box).not.toHaveFocus();
expect(mockOnBlur).toHaveBeenCalledTimes(1);
expect(mockOnChange).not.toHaveBeenCalled();
expect(mockOnSearch).not.toHaveBeenCalled();
Copy link
Member

Choose a reason for hiding this comment

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

onSearch is debounced, so testing by counting calls might not be accurate. Not sure if there is a better way though, maybe add some mock timer waits to force for debounce to clear?

Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe we could do something like await expect(waitFor(() => expect(mockOnSearch).toHaveBeenCalled())).rejects.toThrowError()? Hahaha

Copy link
Member

Choose a reason for hiding this comment

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

We could mock debounce instead to immediately invoke the function.

Copy link
Member Author

Choose a reason for hiding this comment

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

Not a bad idea, but this will couple our test to an implementation detail of SearchBox :(

Tbh I think this debouncing/throttling logic should be moved into SearchkitSearchBox instead since it's now only relevant for module search. I think we should just file an issue for this and defer this discussion to another time?

Copy link
Member

Choose a reason for hiding this comment

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

I don't buy into the implementation detail test article as much. It is useful to keep in mind, but patterns like the page object pattern (why I asked if we should extract some of the queries into constants or functions) are more useful IMO to serve the goal of improving test robustness and reducing breakage when implementation changes.

Here specifically we want to test implementation details, because testing exactly what the user sees would be kind of ridiculous - the goal of the debounce is to improve rendering performance, and to test that in a concrete manner would probably involve, what, rendering this with some arbitrary CPU throttling to prove it is fast enough?

Copy link
Member Author

@taneliang taneliang Dec 20, 2020

Choose a reason for hiding this comment

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

the goal of the debounce is to improve rendering performance

I don't think this is true anymore. The venues page previously used this for performance, but now that the venues search uses useDeferredValue for perf (at least after #3040), this is only used to throttle our module search so that we don't make too many requests. Venues search no longer uses onSearch.

That's why this throttling/debouncing stuff should be moved into SearchkitSearchBox imo, and we can lazily sidestep this discussion entirely since SearchkitSearchBox doesn't have tests 😆

mockHandlers.forEach((m) => m.mockClear());

// Test submit with changes
userEvent.click(box);
userEvent.type(screen.getByRole('searchbox'), '.');
fireEvent.submit(form);
expect(mockOnBlur).toHaveBeenCalledTimes(1);
expect(mockOnChange).toHaveBeenCalledTimes(2); // 1 call when character typed, 1 call on blur
expect(mockOnSearch).toHaveBeenCalledTimes(1); // No `waitFor` as we expect onSearch to be called immediately on blur
mockHandlers.forEach((m) => m.mockClear());
});

test('should have a clear button', async () => {
const mockOnChange = jest.fn();
const mockOnSearch = jest.fn();

render(
<SearchBox
{...defaultProps}
value={SOME_TEXT}
onChange={mockOnChange}
onSearch={mockOnSearch}
/>,
);
const clearButton = screen.getByRole('button', { name: 'Clear search' });
expect(clearButton).toBeInTheDocument();

userEvent.click(clearButton);

expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange).toHaveBeenCalledWith('');

await waitFor(() => expect(mockOnSearch).toHaveBeenCalledTimes(1));
});

test('should not have a clear button if the box is empty', async () => {
render(<SearchBox {...defaultProps} value="" />);
expect(screen.queryByTestId('react-feather X icon')).not.toBeInTheDocument();
});
});
Loading