Replace search view with drop down
Summary: The support form currently has a search form to select a group, but unless the selection is cleared, it won't show you which groups is actually available, which makes it hard for people to select the right group if they don't know up front. Since the scale of available groups doesn't justify needing a typeahead, converted it to an ordinary dropdown. An added benefit is that this allows us to remove a large and complicated component we shouldn't be maintaining ourselves, but rather reuse from Ant. Reviewed By: nikoant Differential Revision: D26046131 fbshipit-source-id: f499e5848eec8b961b054104c8e3a01567e2801e
This commit is contained in:
committed by
Facebook GitHub Bot
parent
de60b28cc7
commit
f6d8b19001
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
SearchInput,
|
||||
styled,
|
||||
colors,
|
||||
SearchIcon,
|
||||
SearchBox,
|
||||
FlexColumn,
|
||||
FlexRow,
|
||||
Text,
|
||||
Tooltip,
|
||||
Line,
|
||||
Spacer,
|
||||
Glyph,
|
||||
} from '../ui';
|
||||
import React, {useState, useCallback, useEffect, useRef} from 'react';
|
||||
|
||||
const RowComponentContainer = styled(FlexRow)({
|
||||
height: '24px',
|
||||
margin: '4px',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
const Separator = styled(Line)({margin: '2px 0px'});
|
||||
|
||||
const OverlayContainer = styled.div({
|
||||
height: '100%',
|
||||
overflow: 'visible',
|
||||
position: 'absolute',
|
||||
top: '30px',
|
||||
left: '0px',
|
||||
width: '100%',
|
||||
zIndex: 100,
|
||||
});
|
||||
|
||||
const ListViewContainer = styled(FlexColumn)({
|
||||
borderWidth: '0px 1px 1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: colors.greyTint2,
|
||||
margin: '0px 10px',
|
||||
maxHeight: '300px',
|
||||
backgroundColor: colors.white,
|
||||
});
|
||||
|
||||
const StyledSearchInput = styled(SearchInput)({
|
||||
height: '20px',
|
||||
margin: '4px',
|
||||
});
|
||||
|
||||
type Element = {id: string; label: string};
|
||||
type Props = {
|
||||
list: Array<Element>;
|
||||
onSelect?: (id: string, label: string) => void;
|
||||
handleNoResults?: (value: string) => void;
|
||||
selectedElementID?: string;
|
||||
};
|
||||
|
||||
function RowComponent(props: {
|
||||
elem: Element;
|
||||
onClick: (id: string) => void;
|
||||
selected?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<RowComponentContainer
|
||||
data-testid={'row-component'}
|
||||
onClick={() => {
|
||||
props.onClick(props.elem.id);
|
||||
}}>
|
||||
<Text>{props.elem.label}</Text>
|
||||
<Spacer />
|
||||
{props.selected && (
|
||||
<Glyph color={colors.highlightTint15} name="checkmark" />
|
||||
)}
|
||||
</RowComponentContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function (props: Props) {
|
||||
const {list, handleNoResults, onSelect, selectedElementID} = props;
|
||||
|
||||
const [filteredElements, setFilteredElements] = useState<Array<Element>>([]);
|
||||
const [searchedValue, setSearchedValue] = useState<string>('');
|
||||
const [selectedElement, setSelectedElement] = useState<Element | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [focussed, setFocus] = useState<boolean>(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onChangeCallBack = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchedValue(e.target.value.trim());
|
||||
},
|
||||
[setSearchedValue],
|
||||
);
|
||||
|
||||
const onSelectCallBack = useCallback(
|
||||
(id: string) => {
|
||||
const elem = list.find((e) => {
|
||||
return e.id === id;
|
||||
});
|
||||
if (elem) {
|
||||
setSearchedValue(elem.label);
|
||||
setSelectedElement(elem);
|
||||
}
|
||||
setFocus(false);
|
||||
if (elem && onSelect) {
|
||||
onSelect(elem.id, elem.label);
|
||||
}
|
||||
},
|
||||
[list, onSelect, setSearchedValue],
|
||||
);
|
||||
|
||||
const onFocusCallBack = useCallback(
|
||||
(_e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setFocus(true);
|
||||
},
|
||||
[setFocus],
|
||||
);
|
||||
|
||||
// Set the searched value and selectedElement when the selectedElementID changes.
|
||||
useEffect(() => {
|
||||
const initialElement = list.find((e) => e.id === selectedElementID);
|
||||
if (initialElement) {
|
||||
setSearchedValue(initialElement.label);
|
||||
}
|
||||
setSelectedElement(initialElement);
|
||||
setFocus(false);
|
||||
}, [selectedElementID, list]);
|
||||
|
||||
// Effect to filter items
|
||||
useEffect(() => {
|
||||
if (searchedValue.length > 0) {
|
||||
const filteredValues = list.filter((s) => {
|
||||
return s.label.toLowerCase().includes(searchedValue.toLowerCase());
|
||||
});
|
||||
if (filteredValues.length === 0 && handleNoResults) {
|
||||
handleNoResults(searchedValue);
|
||||
}
|
||||
setFilteredElements(filteredValues);
|
||||
} else if (focussed) {
|
||||
setFilteredElements(list);
|
||||
}
|
||||
}, [searchedValue, handleNoResults, list, setFilteredElements, focussed]);
|
||||
|
||||
// Effect to detect outside click
|
||||
useEffect(() => {
|
||||
//TODO: Generalise this effect so that other components can use it.
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const current = wrapperRef.current;
|
||||
const target = event.target;
|
||||
if (
|
||||
wrapperRef &&
|
||||
current &&
|
||||
target &&
|
||||
!current.contains(target as Node) &&
|
||||
focussed
|
||||
) {
|
||||
if (selectedElement && onSelect) {
|
||||
setSearchedValue(selectedElement.label);
|
||||
onSelect(selectedElement.id, selectedElement.label);
|
||||
}
|
||||
setFocus(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [
|
||||
wrapperRef,
|
||||
selectedElement,
|
||||
onSelect,
|
||||
setSearchedValue,
|
||||
setFocus,
|
||||
focussed,
|
||||
]);
|
||||
const validationError =
|
||||
filteredElements.length === 0 && searchedValue.length > 0
|
||||
? 'Unsupported element, please try clearing your text to see the list of elements.'
|
||||
: '';
|
||||
return (
|
||||
<FlexColumn ref={wrapperRef}>
|
||||
<Tooltip title={validationError} options={{position: 'below'}}>
|
||||
<SearchBox isInvalidInput={validationError.length > 0}>
|
||||
<SearchIcon
|
||||
name="magnifying-glass"
|
||||
color={colors.macOSTitleBarIcon}
|
||||
size={16}
|
||||
/>
|
||||
<StyledSearchInput
|
||||
placeholder={'Search Groups'}
|
||||
onChange={onChangeCallBack}
|
||||
onFocus={onFocusCallBack}
|
||||
value={searchedValue}
|
||||
isValidInput={false}
|
||||
data-testid={'search-input'}
|
||||
/>
|
||||
</SearchBox>
|
||||
</Tooltip>
|
||||
{filteredElements.length > 0 && focussed && (
|
||||
<OverlayContainer>
|
||||
<ListViewContainer scrollable={true}>
|
||||
{filteredElements.map((e, idx) => {
|
||||
return (
|
||||
<FlexColumn key={idx}>
|
||||
<RowComponent
|
||||
elem={e}
|
||||
onClick={onSelectCallBack}
|
||||
selected={selectedElement && e.id == selectedElement.id}
|
||||
/>
|
||||
{idx < filteredElements.length - 1 && <Separator />}
|
||||
</FlexColumn>
|
||||
);
|
||||
})}
|
||||
</ListViewContainer>
|
||||
</OverlayContainer>
|
||||
)}
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import {render, fireEvent} from '@testing-library/react';
|
||||
import DropDownSearchView from '../DropDownSearchView';
|
||||
import {act} from 'react-dom/test-utils';
|
||||
|
||||
test('Test selected element id is shown as the selected one.', async () => {
|
||||
const res = render(
|
||||
<DropDownSearchView
|
||||
list={[{id: 'id1', label: 'label1'}]}
|
||||
onSelect={jest.fn()}
|
||||
handleNoResults={jest.fn()}
|
||||
selectedElementID={'id1'}
|
||||
/>,
|
||||
);
|
||||
const searchInput = (await res.findByTestId(
|
||||
'search-input',
|
||||
)) as HTMLInputElement;
|
||||
expect(searchInput).toBeTruthy();
|
||||
expect(searchInput.value).toEqual('label1');
|
||||
|
||||
searchInput.focus();
|
||||
});
|
||||
|
||||
test('Test the change of the selectedElementID changes the the selected element in the UI.', async () => {
|
||||
const res = render(
|
||||
<DropDownSearchView
|
||||
list={[
|
||||
{id: 'id1', label: 'label1'},
|
||||
{id: 'id2', label: 'label2'},
|
||||
]}
|
||||
onSelect={jest.fn()}
|
||||
handleNoResults={jest.fn()}
|
||||
selectedElementID={'id1'}
|
||||
/>,
|
||||
);
|
||||
const searchInput = (await res.findByTestId(
|
||||
'search-input',
|
||||
)) as HTMLInputElement;
|
||||
expect(searchInput).toBeTruthy();
|
||||
expect(searchInput.value).toEqual('label1');
|
||||
|
||||
res.rerender(
|
||||
<DropDownSearchView
|
||||
list={[
|
||||
{id: 'id1', label: 'label1'},
|
||||
{id: 'id2', label: 'label2'},
|
||||
]}
|
||||
onSelect={jest.fn()}
|
||||
handleNoResults={jest.fn()}
|
||||
selectedElementID={'id2'}
|
||||
/>,
|
||||
);
|
||||
const searchInputRerendered = (await res.findByTestId(
|
||||
'search-input',
|
||||
)) as HTMLInputElement;
|
||||
expect(searchInputRerendered).toBeTruthy();
|
||||
expect(searchInputRerendered.value).toEqual('label2');
|
||||
});
|
||||
|
||||
test('Test the entire flow and click on the available options.', async () => {
|
||||
const onSelect = jest.fn();
|
||||
const res = render(
|
||||
<DropDownSearchView
|
||||
list={[
|
||||
{id: 'id1', label: 'label1'},
|
||||
{id: 'id2', label: 'label2'},
|
||||
{id: 'id3', label: 'label3'},
|
||||
{id: 'id4', label: 'label4'},
|
||||
]}
|
||||
onSelect={onSelect}
|
||||
handleNoResults={jest.fn()}
|
||||
selectedElementID={'id1'}
|
||||
/>,
|
||||
);
|
||||
const searchInput = (await res.findByTestId(
|
||||
'search-input',
|
||||
)) as HTMLInputElement;
|
||||
expect(searchInput).toBeTruthy();
|
||||
expect(searchInput.value).toEqual('label1');
|
||||
|
||||
searchInput.focus();
|
||||
// Right now just the filtered elements will show up
|
||||
expect(res.queryByText('label1')).toBeTruthy();
|
||||
expect(res.queryByText('label2')).toBeFalsy();
|
||||
expect(res.queryByText('label3')).toBeFalsy();
|
||||
expect(res.queryByText('label4')).toBeFalsy();
|
||||
act(() => {
|
||||
fireEvent.change(searchInput, {target: {value: ''}});
|
||||
});
|
||||
// Once the input field is cleared all the available options will show up.
|
||||
expect(res.queryByText('label1')).toBeTruthy();
|
||||
expect(res.queryByText('label2')).toBeTruthy();
|
||||
expect(res.queryByText('label3')).toBeTruthy();
|
||||
const text4 = res.queryByText('label4');
|
||||
|
||||
expect(text4).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
text4?.parentElement?.dispatchEvent(
|
||||
new MouseEvent('click', {bubbles: true}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(searchInput.value).toEqual('label4');
|
||||
expect(onSelect).toBeCalledTimes(1);
|
||||
// After onSelect the expanded menu gets closed.
|
||||
expect(res.queryByText('label1')).toBeFalsy();
|
||||
expect(res.queryByText('label2')).toBeFalsy();
|
||||
expect(res.queryByText('label3')).toBeFalsy();
|
||||
expect(res.queryByText('label4')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('Test the validation error.', async () => {
|
||||
const handleNoResults = jest.fn();
|
||||
const res = render(
|
||||
<DropDownSearchView
|
||||
list={[
|
||||
{id: 'id1', label: 'label1 group'},
|
||||
{id: 'id2', label: 'label2 group'},
|
||||
{id: 'id3', label: 'label3 support'},
|
||||
{id: 'id4', label: 'label4 support'},
|
||||
]}
|
||||
handleNoResults={handleNoResults}
|
||||
selectedElementID={undefined}
|
||||
/>,
|
||||
);
|
||||
const searchInput = (await res.findByTestId(
|
||||
'search-input',
|
||||
)) as HTMLInputElement;
|
||||
expect(searchInput).toBeTruthy();
|
||||
expect(searchInput.value).toEqual('');
|
||||
|
||||
searchInput.focus();
|
||||
// Right now just the filtered elements will show up
|
||||
expect(await res.findByText('label1 group')).toBeTruthy();
|
||||
expect(await res.findByText('label2 group')).toBeTruthy();
|
||||
expect(await res.findByText('label3 support')).toBeTruthy();
|
||||
expect(await res.findByText('label4 support')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(searchInput, {target: {value: 'support'}});
|
||||
});
|
||||
// Only the items which satisfy the search query should be shown
|
||||
expect(res.queryByText('label3 support')).toBeTruthy();
|
||||
expect(res.queryByText('label4 support')).toBeTruthy();
|
||||
expect(res.queryByText('label1 group')).toBeFalsy();
|
||||
expect(res.queryByText('label2 group')).toBeFalsy();
|
||||
act(() => {
|
||||
fireEvent.change(searchInput, {target: {value: 'gibberish'}});
|
||||
});
|
||||
|
||||
expect(handleNoResults).toBeCalled();
|
||||
expect(res.queryByText('label3 support')).toBeFalsy();
|
||||
expect(res.queryByText('label4 support')).toBeFalsy();
|
||||
expect(res.queryByText('label1 group')).toBeFalsy();
|
||||
expect(res.queryByText('label2 group')).toBeFalsy();
|
||||
});
|
||||
Reference in New Issue
Block a user