mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-08-30 20:06:19 +00:00
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:
parent
ba9109d318
commit
5e94bfbf28
@ -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;
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
@ -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;
|
Loading…
x
Reference in New Issue
Block a user