feat(ui) : Add search dropdown (#9122)

* bump(ui) : antd@4.20.6 to antd@4.24.0

* Fix: types issue

* Address comments

* Fix : unit tests

* feat(ui) : Add search dropdown

* Fix : Cypress Tests

* Fix: tabs content height issue

* bump(ui) : antd@4.20.6 to antd@4.24.0

* chore : add support for search and clear

* test: Add unit tests
This commit is contained in:
Sachin Chaurasiya 2022-12-09 17:13:06 +05:30 committed by GitHub
parent ba9109d318
commit 5e94bfbf28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 406 additions and 0 deletions

View File

@ -0,0 +1,27 @@
/*
* Copyright 2022 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface DropDownOption {
key: string;
label: string;
}
export interface SearchDropdownProps {
label: string;
options: DropDownOption[];
searchKey: string;
selectedKeys: string[];
showClear?: boolean;
onChange: (values: string[], searchKey: string) => void;
onSearch: (searchText: string, searchKey: string) => void;
}

View File

@ -0,0 +1,26 @@
/*
* Copyright 2022 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.custom-dropdown-render {
// this is taken from antd dropdown menu box shadow
box-shadow: 0 3px 6px -4px rgb(0 0 0 / 12%), 0 6px 16px 0 rgb(0 0 0 / 8%),
0 9px 28px 8px rgb(0 0 0 / 5%);
.ant-dropdown-menu {
box-shadow: none;
padding: 0px;
.ant-dropdown-menu-item {
padding: 4px 0px;
}
}
}

View File

@ -0,0 +1,229 @@
/*
* Copyright 2022 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import SearchDropdown from './SearchDropdown';
import { SearchDropdownProps } from './SearchDropdown.interface';
const mockOnChange = jest.fn();
const mockOnSearch = jest.fn();
const searchOptions = [
{ key: 'user.1', label: 'User 1' },
{ key: 'user.2', label: 'User 2' },
{ key: 'user.3', label: 'User 3' },
{ key: 'user.4', label: 'User 4' },
{ key: 'user.5', label: 'User 5' },
];
const mockProps: SearchDropdownProps = {
label: 'Owner',
options: searchOptions,
searchKey: 'owner.name',
selectedKeys: ['user.1'],
showClear: true,
onChange: mockOnChange,
onSearch: mockOnSearch,
};
describe('Search DropDown Component', () => {
it('Should render Dropdown components', async () => {
render(<SearchDropdown {...mockProps} />);
const container = await screen.findByTestId('search-dropdown');
expect(container).toBeInTheDocument();
await act(async () => {
userEvent.click(container);
});
expect(await screen.findByTestId('drop-down-menu')).toBeInTheDocument();
expect((await screen.findByTestId('User 1')).textContent).toContain(
'User 1'
);
expect((await screen.findByTestId('User 2')).textContent).toContain(
'User 2'
);
expect((await screen.findByTestId('User 3')).textContent).toContain(
'User 3'
);
expect((await screen.findByTestId('User 4')).textContent).toContain(
'User 4'
);
expect((await screen.findByTestId('User 5')).textContent).toContain(
'User 5'
);
const searchInput = await screen.findByTestId('search-input');
expect(searchInput).toBeInTheDocument();
const clearButton = await screen.findByTestId('clear-button');
expect(clearButton).toBeInTheDocument();
});
it('Selected keys option should be checked', async () => {
render(<SearchDropdown {...mockProps} />);
const container = await screen.findByTestId('search-dropdown');
expect(container).toBeInTheDocument();
await act(async () => {
userEvent.click(container);
});
expect(await screen.findByTestId('drop-down-menu')).toBeInTheDocument();
// user.1 is selected key so should be checked
expect(await screen.findByTestId('user.1')).toBeChecked();
});
it('UnSelected keys option should not be checked', async () => {
render(<SearchDropdown {...mockProps} />);
const container = await screen.findByTestId('search-dropdown');
expect(container).toBeInTheDocument();
await act(async () => {
userEvent.click(container);
});
expect(await screen.findByTestId('drop-down-menu')).toBeInTheDocument();
expect(await screen.findByTestId('user.2')).not.toBeChecked();
expect(await screen.findByTestId('user.3')).not.toBeChecked();
expect(await screen.findByTestId('user.4')).not.toBeChecked();
expect(await screen.findByTestId('user.5')).not.toBeChecked();
});
it('Should render the clear all button and click should work', async () => {
render(<SearchDropdown {...mockProps} />);
const container = await screen.findByTestId('search-dropdown');
expect(container).toBeInTheDocument();
await act(async () => {
userEvent.click(container);
});
expect(await screen.findByTestId('drop-down-menu')).toBeInTheDocument();
const clearButton = await screen.findByTestId('clear-button');
expect(clearButton).toBeInTheDocument();
await act(async () => {
userEvent.click(clearButton);
});
expect(mockOnChange).toHaveBeenCalledWith([], 'owner.name');
});
it('Should not render the clear all button if showClear is false/undefined', async () => {
render(<SearchDropdown {...mockProps} showClear={false} />);
const container = await screen.findByTestId('search-dropdown');
expect(container).toBeInTheDocument();
await act(async () => {
userEvent.click(container);
});
expect(await screen.findByTestId('drop-down-menu')).toBeInTheDocument();
const clearButton = screen.queryByTestId('clear-button');
expect(clearButton).not.toBeInTheDocument();
});
it('Search should work', async () => {
render(<SearchDropdown {...mockProps} />);
const container = await screen.findByTestId('search-dropdown');
expect(container).toBeInTheDocument();
await act(async () => {
userEvent.click(container);
});
expect(await screen.findByTestId('drop-down-menu')).toBeInTheDocument();
const searchInput = await screen.findByTestId('search-input');
await act(async () => {
userEvent.type(searchInput, 'user');
});
expect(searchInput).toHaveValue('user');
expect(mockOnSearch).toHaveBeenCalledWith('user', 'owner.name');
});
it('On Change should work', async () => {
render(<SearchDropdown {...mockProps} />);
const container = await screen.findByTestId('search-dropdown');
expect(container).toBeInTheDocument();
await act(async () => {
userEvent.click(container);
});
expect(await screen.findByTestId('drop-down-menu')).toBeInTheDocument();
const option2 = await screen.findByTestId('User 2');
await act(async () => {
userEvent.click(option2);
});
// onChange should be called with previous selected keys and current selected keys
expect(mockOnChange).toHaveBeenCalledWith(
['user.1', 'user.2'],
'owner.name'
);
});
it('Selected option should unselect on next click', async () => {
render(<SearchDropdown {...mockProps} />);
const container = await screen.findByTestId('search-dropdown');
expect(container).toBeInTheDocument();
await act(async () => {
userEvent.click(container);
});
expect(await screen.findByTestId('drop-down-menu')).toBeInTheDocument();
const option1 = await screen.findByTestId('User 1');
await act(async () => {
userEvent.click(option1);
});
expect(mockOnChange).toHaveBeenCalledWith([], 'owner.name');
});
});

View File

@ -0,0 +1,124 @@
/*
* Copyright 2022 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DownOutlined } from '@ant-design/icons';
import {
Button,
Card,
Checkbox,
Dropdown,
Input,
MenuItemProps,
MenuProps,
Space,
} from 'antd';
import React, { ChangeEvent, FC, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SearchDropdownProps } from './SearchDropdown.interface';
import './SearchDropdown.less';
const SearchDropdown: FC<SearchDropdownProps> = ({
label,
options,
searchKey,
selectedKeys,
showClear,
onChange,
onSearch,
}) => {
const { t } = useTranslation();
const [isDropDownOpen, setIsDropDownOpen] = useState<boolean>(false);
// derive menu props from options and selected keys
const menuOptions: MenuProps['items'] = useMemo(() => {
return options.map((option) => {
const isSelected = selectedKeys.includes(option.key);
return {
key: option.key,
label: (
<Space data-testid={option.label} size={6}>
<Checkbox checked={isSelected} data-testid={option.key} />
{option.label}
</Space>
),
};
});
}, [options, selectedKeys]);
// handle menu item click
const handleMenuItemClick: MenuItemProps['onClick'] = (info) => {
const currentKey = info.key;
const isSelected = selectedKeys.includes(currentKey);
const updatedValues = isSelected
? selectedKeys.filter((v) => v !== currentKey)
: [...selectedKeys, currentKey];
// call on change with updated value
onChange(updatedValues, searchKey);
};
// handle clear all
const handleClear = () => onChange([], searchKey);
// handle search
const handleSearch = (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
onSearch(value, searchKey);
};
return (
<Dropdown
data-testid={searchKey}
dropdownRender={(menuNode) => {
return (
<Card className="custom-dropdown-render" data-testid="drop-down-menu">
<Space direction="vertical" size={4}>
<Input
data-testid="search-input"
placeholder={`Search ${label}...`}
onChange={handleSearch}
/>
{showClear && (
<Button
className="p-0"
data-testid="clear-button"
type="link"
onClick={handleClear}>
{t('label.clear-all')}
</Button>
)}
{menuNode}
</Space>
</Card>
);
}}
key={searchKey}
menu={{ items: menuOptions, onClick: handleMenuItemClick }}
trigger={['click']}
visible={isDropDownOpen}
onVisibleChange={(visible) => setIsDropDownOpen(visible)}>
<Button>
<Space data-testid="search-dropdown">
{label}
<DownOutlined />
</Space>
</Button>
</Dropdown>
);
};
export default SearchDropdown;