mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-08-29 19:35:56 +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