diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.interface.ts new file mode 100644 index 00000000000..9736c5d859f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.interface.ts @@ -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; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.less b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.less new file mode 100644 index 00000000000..5319c5c8fa0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.less @@ -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; + } + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.test.tsx new file mode 100644 index 00000000000..9735e319369 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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'); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.tsx new file mode 100644 index 00000000000..6ddcf63c847 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.tsx @@ -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 = ({ + label, + options, + searchKey, + selectedKeys, + showClear, + onChange, + onSearch, +}) => { + const { t } = useTranslation(); + + const [isDropDownOpen, setIsDropDownOpen] = useState(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: ( + + + {option.label} + + ), + }; + }); + }, [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) => { + const { value } = e.target; + + onSearch(value, searchKey); + }; + + return ( + { + return ( + + + + {showClear && ( + + )} + {menuNode} + + + ); + }} + key={searchKey} + menu={{ items: menuOptions, onClick: handleMenuItemClick }} + trigger={['click']} + visible={isDropDownOpen} + onVisibleChange={(visible) => setIsDropDownOpen(visible)}> + + + ); +}; + +export default SearchDropdown;