Unverified Commit 2609cd11 authored by Andrey Azov's avatar Andrey Azov Committed by GitHub
Browse files

Add AutosuggestSearchField (#19)

Also add shared components that are building blocks for AutosuggestSearchField:
- Input
- SearchField
parent f97e7501
Pipeline #15785 failed with stage
in 2 minutes and 52 seconds
......@@ -81,6 +81,7 @@
"@types/classnames": "2.2.7",
"@types/enzyme": "3.1.17",
"@types/enzyme-adapter-react-16": "1.0.3",
"@types/faker": "4.1.5",
"@types/jest": "23.3.14",
"@types/lodash": "4.14.122",
"@types/node": "10.12.24",
......@@ -105,6 +106,7 @@
"enzyme": "3.8.0",
"enzyme-adapter-react-16": "1.9.1",
"enzyme-to-json": "3.3.5",
"faker": "4.1.0",
"file-loader": "1.1.11",
"fork-ts-checker-webpack-plugin": "0.4.15",
"gh-pages": "2.0.1",
......
@import 'src/styles/common';
.autosuggestionSearchField {
display: inline-block;
position: relative;
}
.autosuggestionPlate {
position: absolute;
left: 0;
width: 100%;
z-index: 1;
padding: 18px 24px;
background: white;
border: 1px solid $ens-grey;
box-shadow: 2px 2px 6px 0 $ens-grey;
}
.autosuggestionPlateMatchGroup {
&:not(:first-child) {
margin-top: 1em;
}
}
.autosuggestionPlateItem {
padding: 2px 24px;
margin: 0 -23px;
cursor: pointer;
}
.autosuggestionPlateHighlightedItem {
background-color: $ens-light-blue;
}
import React from 'react';
import { mount } from 'enzyme';
import faker from 'faker';
import times from 'lodash/times';
import random from 'lodash/random';
import * as keyCodes from 'src/shared/constants/keyCodes';
import AutosuggestSearchField from './AutosuggestSearchField';
import AutosuggestionPanel, { GroupOfMatchesType } from './AutosuggestionPanel';
import SearchField from 'src/shared/search-field/SearchField';
import Input from 'src/shared/input/Input';
const generateMatch = () => {
const text = faker.lorem.words();
return {
data: { text },
element: <div>{text}</div>
};
};
const generateGroupOfMatches = (numberOfMatches = 2, title?: string) => {
const result: GroupOfMatchesType = {
matches: times(numberOfMatches, generateMatch)
};
if (title) {
result.title = title;
}
return result;
};
describe('<AutosuggestSearchField />', () => {
const onChange = jest.fn();
const onSelect = jest.fn();
const onSubmit = jest.fn();
const search = faker.lorem.word();
const groupsOfMatches = times(2, () => generateGroupOfMatches());
afterEach(() => {
jest.resetAllMocks();
});
describe('appearance', () => {
test('renders only search field if no matches have been found', () => {
const mountedComponent = mount(
<AutosuggestSearchField
search={search}
onChange={onChange}
onSelect={onSelect}
matchGroups={[]}
/>
);
expect(mountedComponent.find(SearchField).length).toBe(1);
expect(mountedComponent.find(Input).prop('value')).toBe(search);
expect(mountedComponent.find(AutosuggestionPanel).length).toBe(0);
});
test('renders AutosuggestionPanel with matches if they are provided', () => {
const mountedComponent = mount(
<AutosuggestSearchField
search={search}
onChange={onChange}
onSelect={onSelect}
matchGroups={groupsOfMatches}
/>
);
const expectedNumberOfMatches = groupsOfMatches.reduce((sum, group) => {
const numberOfMatchesInGroup = group.matches.length;
return sum + numberOfMatchesInGroup;
}, 0);
expect(mountedComponent.find(SearchField).length).toBe(1);
expect(mountedComponent.find(Input).prop('value')).toBe(search);
expect(mountedComponent.find(AutosuggestionPanel).length).toBe(1);
expect(mountedComponent.find('.autosuggestionPlateItem').length).toBe(
expectedNumberOfMatches
);
});
});
describe('behaviour', () => {
describe('general behavour', () => {
let mountedComponent: any;
beforeEach(() => {
mountedComponent = mount(
<AutosuggestSearchField
search={search}
onChange={onChange}
onSelect={onSelect}
matchGroups={groupsOfMatches}
/>
);
});
test('clicking on a suggested match submits its data', () => {
const suggestedItems = mountedComponent.find(
'.autosuggestionPlateItem'
);
const randomItemIndex = random(0, suggestedItems.length - 1);
const randomItem = suggestedItems.at(randomItemIndex);
const itemsData = groupsOfMatches.reduce(
(result, group) => {
return [...result, ...group.matches.map(({ data }) => data)];
},
[] as any
);
const expectedItemData = itemsData[randomItemIndex];
randomItem.simulate('click');
expect(onSelect).toHaveBeenCalledWith(expectedItemData);
});
test('pressing the ArrowDown button selects next item', () => {
const searchField = mountedComponent.find('input');
searchField.simulate('keydown', { keyCode: keyCodes.DOWN });
const suggestedItems = mountedComponent.find(
'.autosuggestionPlateItem'
);
// initially, the first item was selected; so we now expect the second one to be
expect(
suggestedItems.at(1).hasClass('autosuggestionPlateHighlightedItem')
).toBe(true);
});
test('pressing the ArrowUp button selects previous item', () => {
const searchField = mountedComponent.find('input');
searchField.simulate('keydown', { keyCode: keyCodes.UP });
const suggestedItems = mountedComponent.find(
'.autosuggestionPlateItem'
);
const expectedIndex = suggestedItems.length - 1;
// initially, the first item was selected; so we now expect the last one to be
expect(
suggestedItems
.at(expectedIndex)
.hasClass('autosuggestionPlateHighlightedItem')
).toBe(true);
});
});
describe('when raw input submission is not allowed', () => {
let mountedComponent: any;
beforeEach(() => {
mountedComponent = mount(
<AutosuggestSearchField
search={search}
onChange={onChange}
onSelect={onSelect}
matchGroups={groupsOfMatches}
/>
);
});
test('first match in AutosuggestionPanel is pre-selected', () => {
const suggestedItems = mountedComponent.find(
'.autosuggestionPlateItem'
);
const highlightedItems = mountedComponent.find(
'.autosuggestionPlateHighlightedItem'
);
expect(highlightedItems.length).toBe(1);
expect(
suggestedItems.at(0).hasClass('autosuggestionPlateHighlightedItem')
).toBe(true);
});
test('triggering submit event confirms selection of a match', () => {
const searchField = mountedComponent.find(SearchField);
searchField.simulate('submit');
const firstMatchData = groupsOfMatches[0].matches[0].data;
expect(onSelect).toHaveBeenCalledWith(firstMatchData);
});
});
describe('when raw input submission is allowed', () => {
let mountedComponent: any;
beforeEach(() => {
mountedComponent = mount(
<AutosuggestSearchField
search={search}
onChange={onChange}
onSelect={onSelect}
matchGroups={groupsOfMatches}
allowRawInputSubmission={true}
onSubmit={onSubmit}
/>
);
});
test('first match in AutosuggestionPanel is not pre-selected', () => {
const highlightedItems = mountedComponent.find(
'.autosuggestionPlateHighlightedItem'
);
expect(highlightedItems.length).toBe(0);
});
test('triggering submit event submits current search value if no match is selected', () => {
const searchField = mountedComponent.find(SearchField);
searchField.simulate('submit');
expect(onSubmit).toHaveBeenCalledWith(search);
expect(onSelect).not.toHaveBeenCalled();
});
test('triggering submit event confirms selection of a match if the match is selected', () => {
// highlight the first match
const searchField = mountedComponent.find('input');
searchField.simulate('keydown', { keyCode: keyCodes.DOWN });
searchField.simulate('submit');
const firstMatchData = groupsOfMatches[0].matches[0].data;
expect(onSubmit).not.toHaveBeenCalled();
expect(onSelect).toHaveBeenCalledWith(firstMatchData);
});
});
});
});
import React, { useState, useEffect, useRef, ReactNode } from 'react';
import classNames from 'classnames';
import SearchField from 'src/shared/search-field/SearchField';
import AutosuggestionPanel, {
GroupOfMatchesType,
MatchIndex
} from './AutosuggestionPanel';
import * as keyCodes from 'src/shared/constants/keyCodes';
import styles from './AutosuggestSearchField.scss';
type CommonProps = {
search: string;
onChange: (value: string) => void;
onSelect: (match: any) => void;
matchGroups: GroupOfMatchesType[];
canShowSuggestions: boolean;
placeholder?: string;
onFocus?: () => void;
onBlur?: () => void;
rightCorner?: ReactNode;
className?: string;
searchFieldClassName?: string;
};
// with this set of props user can submit raw content of the search field
// (not just one of suggested matches)
type PropsAllowingRawDataSubmission = {
allowRawInputSubmission: true;
onSubmit: (value: string) => void;
};
// with this set of props user can submit only one of suggested matches
// (notice no onSubmit prop; typescript is smart enough to know it won't be available)
type PropsDisallowingRawDataSubmission = {
allowRawInputSubmission: false;
};
type Props =
| (CommonProps & PropsAllowingRawDataSubmission)
| (CommonProps & PropsDisallowingRawDataSubmission);
function getNextItemIndex(
props: Props,
currentItemIndex: MatchIndex | null
): MatchIndex {
const { matchGroups, allowRawInputSubmission } = props;
const [groupIndex, itemIndex] = currentItemIndex || [null, null];
const currentGroup =
typeof groupIndex === 'number' ? matchGroups[groupIndex] : null;
const firstItemIndex: MatchIndex = [0, 0];
if (itemIndex === null) {
return firstItemIndex;
} else if (currentGroup && itemIndex < currentGroup.matches.length - 1) {
// move to the next item in the group
return [groupIndex, itemIndex + 1] as MatchIndex;
} else if (groupIndex === matchGroups.length - 1) {
// this is the last item in the last group;
// either return null if submitting raw input is allowed, or
// cycle back to the first item in the list
return allowRawInputSubmission ? null : firstItemIndex;
} else if (typeof groupIndex === 'number') {
// move to the next group in the list
return [groupIndex + 1, 0];
} else {
return null; // should never happen, but makes Typescript happy
}
}
function getPreviousItemIndex(
props: Props,
currentItemIndex: MatchIndex | null
): MatchIndex {
const { matchGroups, allowRawInputSubmission } = props;
const [groupIndex, itemIndex] = currentItemIndex || [null, null];
const lastGroupIndex = matchGroups.length - 1;
const lastGroupItemIndex = matchGroups[lastGroupIndex].matches.length - 1;
const lastItemIndex: MatchIndex = [lastGroupIndex, lastGroupItemIndex];
if (itemIndex === null) {
return lastItemIndex;
} else if (itemIndex > 0) {
// move to the previous item
return [groupIndex, itemIndex - 1] as MatchIndex;
} else if (groupIndex === 0) {
// this is the first item in the first group;
// either return null if submitting raw input is allowed,
// or cycle back to the very last item in the list
return allowRawInputSubmission ? null : lastItemIndex;
} else if (typeof groupIndex === 'number') {
// move to the last item in the previous group
const previousGroupIndex = groupIndex - 1;
const lastItemIndex = matchGroups[previousGroupIndex].matches.length - 1;
return [previousGroupIndex, lastItemIndex];
} else {
return null; // should never happen, but makes Typescript happy
}
}
const AutosuggestSearchField = (props: Props) => {
const initialHighlightedItemIndex: MatchIndex = props.allowRawInputSubmission
? null
: [0, 0];
const [highlightedItemIndex, setHighlightedItemIndex] = useState(
initialHighlightedItemIndex
);
const [isSelected, setIsSelected] = useState(false);
const [canShowSuggesions, setCanShowSuggestions] = useState(true);
const element = useRef<HTMLDivElement>(null);
useEffect(() => {
setIsSelected(false);
}, [props.search]);
useEffect(() => {
const onClickOutside = (event: MouseEvent) => {
const currentElement = element.current;
if (!currentElement) return;
if (
event.target !== currentElement &&
!currentElement.contains(event.target as HTMLElement)
) {
setCanShowSuggestions(false);
}
};
window.addEventListener('click', onClickOutside);
return () => window.removeEventListener('click', onClickOutside);
}, []);
const handleSelect = (match: any) => {
setIsSelected(true);
props.onSelect(match);
};
const handleBlur = () => {
props.onBlur && props.onBlur();
};
const handleFocus = () => {
if (!canShowSuggesions) {
setCanShowSuggestions(true);
}
props.onFocus && props.onFocus();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (![keyCodes.UP, keyCodes.DOWN].includes(event.keyCode)) return;
event.preventDefault();
if (event.keyCode === keyCodes.UP) {
setHighlightedItemIndex(
getPreviousItemIndex(props, highlightedItemIndex)
);
} else {
setHighlightedItemIndex(getNextItemIndex(props, highlightedItemIndex));
}
};
const handleItemHover = (itemIndex: MatchIndex) => {
setHighlightedItemIndex(itemIndex);
};
const handleChange = (value: string) => {
if (value !== props.search) {
props.onChange(value);
}
};
const handleSubmit = (value: string) => {
if (!highlightedItemIndex && props.allowRawInputSubmission) {
props.onSubmit(value);
} else if (highlightedItemIndex) {
const [groupIndex, itemIndex] = highlightedItemIndex;
const match = props.matchGroups[groupIndex].matches[itemIndex];
props.onSelect(match.data);
}
setIsSelected(true);
};
const shouldShowSuggestions =
props.search &&
Boolean(props.matchGroups.length) &&
canShowSuggesions &&
!isSelected &&
props.canShowSuggestions;
const className = classNames(
styles.autosuggestionSearchField,
props.className
);
const searchFieldClassName = classNames(
styles.searchFieldInput,
props.searchFieldClassName
);
return (
<div ref={element} className={className}>
<SearchField
search={props.search}
rightCorner={props.rightCorner}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onSubmit={handleSubmit}
className={searchFieldClassName}
/>
{shouldShowSuggestions && (
<AutosuggestionPanel
highlightedItemIndex={highlightedItemIndex}
matchGroups={props.matchGroups}
onSelect={handleSelect}
allowRawInputSubmission={props.allowRawInputSubmission}
onItemHover={handleItemHover}
/>
)}
</div>
);
};
AutosuggestSearchField.defaultProps = {
matchGroups: [],
canShowSuggestions: true,
allowRawInputSubmission: false
};
export default AutosuggestSearchField;
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import styles from './AutosuggestSearchField.scss';
type MatchType = {
data: any;
element: ReactNode;
};
type MatchProps = MatchType & {
groupIndex: number;
itemIndex: number;
isHighlighted: boolean;
onHover: (itemIndex: MatchIndex) => void;
onClick: (itemIndex: MatchIndex) => void;
};
export type GroupOfMatchesType = {
title?: string;
matches: MatchType[];
};
type GroupOfMatchesProps = GroupOfMatchesType & {
groupIndex: number;
highlightedItemIndex: number | null;
onItemHover: (itemIndex: MatchIndex) => void;
onItemClick: (itemIndex: MatchIndex) => void;
};
export type MatchIndex = [number, number] | null; // first number is index of the group; second number is index of item within this group
type Props = {
title?: string;
highlightedItemIndex: MatchIndex;
matchGroups: GroupOfMatchesType[];
onItemHover: (itemIndex: MatchIndex) => void;
onSelect: (match: any) => void;
allowRawInputSubmission: boolean;
};
const AutosuggestionPanel = (props: Props) => {
const handleItemClick = (itemIndex: MatchIndex) => {
props.onSelect(getMatchData(itemIndex));
};
const getMatchData = (itemIndex: MatchIndex) => {