From 930361a49d921d580ef0a2cf9d5adc1758ec49d5 Mon Sep 17 00:00:00 2001 From: Aniket Katkar Date: Tue, 13 Dec 2022 19:18:10 +0530 Subject: [PATCH] Improvements(UI) #9003: Improved explore page advanced search quick filters styling and functionality (#9237) * Added util functions to use in AdvancedSearchUtils * Replaced ExploreQuickFilter component with SearchDropdown Styling improvements Localization changes * Fixed failing unit tests * Improved filters styling * Advanced search quick filter dropdown styling changes * Fixed failing unit tests * Added unit tests for newly added functions in AdvancedSearchUtils * UI : fix add owner and tier functionality on Translated entity page (#9244) * fix add owner and tier functionality on Translated entity page * get entity info keys from enum * remove unwanted translation keys * minor changes * Removed repetitions in en-us * Added update and close button feature to search dropdown * Stylings updated * Fixed failing unit tests and added tests for additional features * - Handled possible failure case for util functions in AdvancedSearchUtils functions - Added tests for negative scenarios Co-authored-by: Ashish Gupta Co-authored-by: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> --- .../resources/ui/src/assets/svg/DropDown.svg | 3 + .../resources/ui/src/axiosAPIs/miscAPI.ts | 14 ++ .../components/Explore/Explore.component.tsx | 49 ++--- .../Explore/ExploreQuickFilter.test.tsx | 179 ----------------- .../components/Explore/ExploreQuickFilter.tsx | 187 ------------------ .../Explore/ExploreQuickFilters.interface.ts | 28 +++ .../Explore/ExploreQuickFilters.test.tsx | 54 +++-- .../Explore/ExploreQuickFilters.tsx | 152 +++++++------- .../components/Explore/explore.interface.ts | 3 +- .../SearchDropdown.interface.ts | 9 +- .../SearchDropdown/SearchDropdown.less | 34 ++++ .../SearchDropdown/SearchDropdown.test.tsx | 120 +++++++---- .../SearchDropdown/SearchDropdown.tsx | 162 +++++++++++---- .../ui/src/locale/languages/en-us.json | 5 +- .../src/main/resources/ui/src/styles/app.less | 16 ++ .../main/resources/ui/src/styles/spacing.less | 3 + .../ui/src/utils/AdvancedSearchUtils.test.tsx | 62 ++++++ .../ui/src/utils/AdvancedSearchUtils.tsx | 41 +++- .../utils/mocks/AdvancedSearchUtils.mock.ts | 28 +++ 19 files changed, 557 insertions(+), 592 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/DropDown.svg delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilter.test.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilter.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.interface.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/mocks/AdvancedSearchUtils.mock.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/DropDown.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/DropDown.svg new file mode 100644 index 00000000000..05f32f42411 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/DropDown.svg @@ -0,0 +1,3 @@ + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/miscAPI.ts b/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/miscAPI.ts index e84468cf2e6..bf4ca40f46e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/miscAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/miscAPI.ts @@ -248,6 +248,20 @@ export const getAdvancedFieldOptions = ( }); }; +export const getAdvancedFieldDefaultOptions = ( + index: SearchIndex, + field: string +) => { + const params = { index, field }; + + return APIClient.get>( + `/search/aggregate`, + { + params, + } + ); +}; + export const getEntityCount = async ( path: string, database?: string diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/Explore.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/Explore.component.tsx index 79dadfbbc1e..5c0b5172007 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/Explore.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/Explore.component.tsx @@ -44,6 +44,7 @@ import { TabSpecificField } from '../../enums/entity.enum'; import { SearchIndex } from '../../enums/search.enum'; import { Table } from '../../generated/entity/data/table'; import { Include } from '../../generated/type/include'; +import { getDropDownItems } from '../../utils/AdvancedSearchUtils'; import { formatNumberWithComma, formTwoDigitNmber, @@ -237,36 +238,25 @@ const Explore: React.FC = ({ }; const handleAdvanceSearchFilter = (data: ExploreQuickFilterField[]) => { - const term = {} as Record; + const terms = [] as Array>; data.forEach((filter) => { - if (filter.key) { - term[filter.key] = filter.value; - } + filter.value?.map((val) => { + if (filter.key) { + terms.push({ term: { [filter.key]: val } }); + } + }); }); onChangeAdvancedSearchQueryFilter( - isEmpty(term) + isEmpty(terms) ? undefined : { - query: { bool: { must: [{ term }] } }, + query: { bool: { must: terms } }, } ); }; - const handleAdvanceFieldClear = () => { - setSelectedQuickFilters([]); - }; - - const handleAdvanceFieldRemove = (value: string) => { - setSelectedQuickFilters((prev) => { - const data = prev.filter((p) => p.key !== value); - handleAdvanceSearchFilter(data); - - return data; - }); - }; - const handleAdvanceFieldValueSelect = (field: ExploreQuickFilterField) => { setSelectedQuickFilters((pre) => { const data = pre.map((preField) => { @@ -283,16 +273,6 @@ const Explore: React.FC = ({ }); }; - const handleAdvancedFieldSelect = (value: string) => { - const flag = selectedQuickFilters.some((field) => field.key === value); - if (!flag) { - setSelectedQuickFilters((pre) => [ - ...pre, - { key: value, value: undefined }, - ]); - } - }; - useEffect(() => { const escapeKeyHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') { @@ -306,6 +286,14 @@ const Explore: React.FC = ({ }; }, []); + useEffect(() => { + const dropdownItems = getDropDownItems(searchIndex); + + setSelectedQuickFilters( + dropdownItems.map((item) => ({ ...item, value: undefined })) + ); + }, [searchIndex]); + return ( = ({ fields={selectedQuickFilters} index={searchIndex} onAdvanceSearch={() => setShowAdvanceSearchModal(true)} - onClear={handleAdvanceFieldClear} - onFieldRemove={handleAdvanceFieldRemove} - onFieldSelect={handleAdvancedFieldSelect} onFieldValueSelect={handleAdvanceFieldValueSelect} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilter.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilter.test.tsx deleted file mode 100644 index 31b56b1b9a7..00000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilter.test.tsx +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright 2021 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, fireEvent, render } from '@testing-library/react'; -import React from 'react'; -import { SearchIndex } from '../../enums/search.enum'; -import { ExploreQuickFilterField } from './explore.interface'; -import AdvancedField from './ExploreQuickFilter'; - -const mockData = { - 'metadata-suggest': [ - { - text: 'clou', - offset: 0, - length: 4, - options: [ - { - text: 'Cloud_Infra', - _index: 'team_search_index', - _type: '_doc', - _id: '267a4dd4-df64-400d-b4d1-3925d6f23885', - _score: 10, - _source: { - suggest: [ - { - input: 'Cloud_Infra', - weight: 5, - }, - { - input: 'Cloud_Infra', - weight: 10, - }, - ], - deleted: false, - // eslint-disable-next-line @typescript-eslint/camelcase - team_id: '267a4dd4-df64-400d-b4d1-3925d6f23885', - name: 'Cloud_Infra', - // eslint-disable-next-line @typescript-eslint/camelcase - display_name: 'Cloud_Infra', - // eslint-disable-next-line @typescript-eslint/camelcase - entityType: 'team', - users: [], - owns: [], - // eslint-disable-next-line @typescript-eslint/camelcase - default_roles: [], - // eslint-disable-next-line @typescript-eslint/camelcase - last_updated_timestamp: 1654838173854, - }, - }, - ], - }, - ], -}; - -jest.mock('../../axiosAPIs/miscAPI', () => ({ - getAdvancedFieldOptions: jest - .fn() - .mockImplementation(() => Promise.resolve()), - getUserSuggestions: jest - .fn() - .mockImplementation(() => Promise.resolve({ data: { suggest: mockData } })), -})); - -jest.mock('../../utils/AdvancedSearchUtils', () => ({ - getItemLabel: jest.fn().mockImplementation(() => 'owner'), - getAdvancedField: jest.fn(), -})); - -const index = SearchIndex.TABLE; -const field = { - key: 'owner.name', - value: undefined, -} as ExploreQuickFilterField; -const onFieldRemove = jest.fn(); -const onFieldValueSelect = jest.fn(); - -const mockProps = { - index, - field, - onFieldRemove, - onFieldValueSelect, -}; - -describe('Test AdvancedField Component', () => { - it('Should render AdvancedField component', async () => { - await act(async () => { - const { findByTestId } = render(); - - const label = await findByTestId('field-label'); - - expect(label).toBeInTheDocument(); - - expect(label).toHaveTextContent('Owner:'); - - const searchSelect = await findByTestId('field-select'); - - expect(searchSelect).toBeInTheDocument(); - - const removeButton = await findByTestId('field-remove-button'); - - expect(removeButton).toBeInTheDocument(); - }); - }); - - it('Should call remove method on click of remove button', async () => { - await act(async () => { - const { findByTestId } = render(); - - const label = await findByTestId('field-label'); - - expect(label).toBeInTheDocument(); - - expect(label).toHaveTextContent('Owner:'); - - const searchSelect = await findByTestId('field-select'); - - expect(searchSelect).toBeInTheDocument(); - - const removeButton = await findByTestId('field-remove-button'); - - expect(removeButton).toBeInTheDocument(); - - fireEvent.click(removeButton); - - expect(onFieldRemove).toHaveBeenCalledWith(field.key); - }); - }); - - it('Should call select method on click of option', async () => { - await act(async () => { - const { findByTestId, findByRole, findAllByTestId } = render( - - ); - - const label = await findByTestId('field-label'); - - expect(label).toBeInTheDocument(); - - expect(label).toHaveTextContent('Owner:'); - - const searchSelect = await findByTestId('field-select'); - - expect(searchSelect).toBeInTheDocument(); - - const removeButton = await findByTestId('field-remove-button'); - - expect(removeButton).toBeInTheDocument(); - - const searchInput = await findByRole('combobox'); - - expect(searchInput).toBeInTheDocument(); - - fireEvent.change(searchInput, { target: { value: 'cloud' } }); - - const fieldOptions = await findAllByTestId('field-option'); - - expect(fieldOptions).toHaveLength( - mockData['metadata-suggest'][0].options.length - ); - - fireEvent.click(fieldOptions[0]); - - expect(onFieldValueSelect).toHaveBeenCalledWith({ - key: 'owner.name', - value: 'Cloud_Infra', - }); - }); - }); -}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilter.tsx deleted file mode 100644 index c09175405f1..00000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilter.tsx +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright 2021 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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Select } from 'antd'; -import { DefaultOptionType } from 'antd/lib/select'; -import { AxiosError } from 'axios'; -import { startCase } from 'lodash'; -import React, { FC, useState } from 'react'; -import { - getAdvancedFieldOptions, - getTagSuggestions, - getUserSuggestions, -} from '../../axiosAPIs/miscAPI'; -import { MISC_FIELDS } from '../../constants/AdvancedSearch.constants'; -import { - getAdvancedField, - getItemLabel, -} from '../../utils/AdvancedSearchUtils'; -import { showErrorToast } from '../../utils/ToastUtils'; -import { - ExploreQuickFilterProps, - SearchInputProps, -} from '../Explore/explore.interface'; - -const SearchInput = ({ - options, - value, - handleChange, - handleSearch, - handleSelect, - handleClear, -}: SearchInputProps) => { - const { Option } = Select; - - const optionsElement = options.map((d) => ( - - )); - - return ( - - ); -}; - -const ExploreQuickFilter: FC = ({ - field, - onFieldRemove, - index, - onFieldValueSelect, -}) => { - const advancedField = getAdvancedField(field.key); - - const [options, setOptions] = useState([]); - const [value, setValue] = useState(field.value); - - const fetchOptions = (query: string) => { - if (!MISC_FIELDS.includes(field.key)) { - getAdvancedFieldOptions(query, index, advancedField) - .then((res) => { - const suggestOptions = - res.data.suggest['metadata-suggest'][0].options ?? []; - const uniqueOptions = [ - ...new Set(suggestOptions.map((op) => op.text)), - ]; - setOptions( - uniqueOptions.map((op: unknown) => ({ - label: op as string, - value: op as string, - })) - ); - }) - .catch((err: AxiosError) => showErrorToast(err)); - } else { - if (field.key === 'tags.tagFQN') { - getTagSuggestions(query) - .then((res) => { - const suggestOptions = - res.data.suggest['metadata-suggest'][0].options ?? []; - const uniqueOptions = [ - ...new Set( - // eslint-disable-next-line - suggestOptions.map((op: any) => op._source.fullyQualifiedName) - ), - ]; - setOptions( - uniqueOptions.map((op: unknown) => ({ - label: op as string, - value: op as string, - })) - ); - }) - .catch((err: AxiosError) => showErrorToast(err)); - } else { - getUserSuggestions(query) - .then((res) => { - const suggestOptions = - res.data.suggest['metadata-suggest'][0].options ?? []; - const uniqueOptions = [ - // eslint-disable-next-line - ...new Set(suggestOptions.map((op: any) => op._source.name)), - ]; - setOptions( - uniqueOptions.map((op: unknown) => ({ - label: op as string, - value: op as string, - })) - ); - }) - .catch((err: AxiosError) => showErrorToast(err)); - } - } - }; - - const handleSearch = (newValue: string) => { - if (newValue) { - fetchOptions(newValue); - } else { - setOptions([]); - } - }; - - const handleChange = (newValue: string) => { - setValue(newValue); - }; - - const handleOnSelect = (newValue: string) => { - onFieldValueSelect({ ...field, value: newValue }); - }; - - const handleOnClear = () => { - onFieldValueSelect({ ...field, value: undefined }); - }; - - return ( -
- - {startCase(getItemLabel(field.key))}: - - - onFieldRemove(field.key)}> - - -
- ); -}; - -export default ExploreQuickFilter; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.interface.ts new file mode 100644 index 00000000000..a30e7097c07 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.interface.ts @@ -0,0 +1,28 @@ +/* + * 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 { SearchIndex } from '../../enums/search.enum'; +import { ExploreQuickFilterField } from './explore.interface'; + +export interface ExploreQuickFiltersProps { + index: SearchIndex; + fields: Array; + onFieldValueSelect: (field: ExploreQuickFilterField) => void; + onAdvanceSearch: () => void; +} + +export interface FilterFieldsMenuItem { + key: string; + label: string; + defaultField: boolean; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx index 07d07e58c31..4cb2c9cd3e2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx @@ -17,10 +17,18 @@ import { SearchIndex } from '../../enums/search.enum'; import { ExploreQuickFilterField } from '../Explore/explore.interface'; import ExploreQuickFilters from './ExploreQuickFilters'; -jest.mock('./ExploreQuickFilter', () => +const mockOnFieldRemove = jest.fn(); +const mockOnAdvanceSearch = jest.fn(); +const mockOnClear = jest.fn(); +const mockOnFieldValueSelect = jest.fn(); +const mockOnFieldSelect = jest.fn(); +const mockOnClearSelection = jest.fn(); +const mockOnUpdateFilterValues = jest.fn(); + +jest.mock('../SearchDropdown/SearchDropdown', () => jest .fn() - .mockReturnValue(
ExploreQuickFilter
) + .mockReturnValue(
SearchDropdown
) ); jest.mock('./AdvanceSearchModal.component', () => ({ @@ -33,11 +41,13 @@ const fields = [ { key: 'column_names', value: undefined }, ] as ExploreQuickFilterField[]; -const onFieldRemove = jest.fn(); -const onAdvanceSearch = jest.fn(); -const onClear = jest.fn(); -const onFieldValueSelect = jest.fn(); -const onFieldSelect = jest.fn(); +const onFieldRemove = mockOnFieldRemove; +const onAdvanceSearch = mockOnAdvanceSearch; +const onClear = mockOnClear; +const onFieldValueSelect = mockOnFieldValueSelect; +const onFieldSelect = mockOnFieldSelect; +const onClearSelection = mockOnClearSelection; +const onUpdateFilterValues = mockOnUpdateFilterValues; const mockProps = { index, @@ -45,39 +55,19 @@ const mockProps = { onFieldRemove, onAdvanceSearch, onClear, + onClearSelection, onFieldValueSelect, onFieldSelect, + onUpdateFilterValues, }; describe('Test ExploreQuickFilters component', () => { it('Should render ExploreQuickFilters component', async () => { - const { findByTestId, findAllByTestId } = render( - - ); + const { findAllByTestId } = render(); - const fields = await findAllByTestId('advanced-field'); - const clearButton = await findByTestId('clear-all-button'); + const fields = await findAllByTestId('search-dropdown'); expect(fields).toHaveLength(fields.length); - - expect(clearButton).toBeInTheDocument(); - }); - - it('Should call onClear method on click of Clear All button', async () => { - const { findByTestId, findAllByTestId } = render( - - ); - - const fields = await findAllByTestId('advanced-field'); - const clearButton = await findByTestId('clear-all-button'); - - expect(fields).toHaveLength(fields.length); - - expect(clearButton).toBeInTheDocument(); - - fireEvent.click(clearButton); - - expect(onClear).toBeCalledWith(); }); it('Should call onAdvanceSearch method on click of Advance Search button', async () => { @@ -85,7 +75,7 @@ describe('Test ExploreQuickFilters component', () => { ); - const fields = await findAllByTestId('advanced-field'); + const fields = await findAllByTestId('search-dropdown'); const advanceSearchButton = await findByTestId('advance-search-button'); expect(fields).toHaveLength(fields.length); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx index 42ae45e3ef0..fb2bdcd8dfb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx @@ -11,93 +11,101 @@ * limitations under the License. */ -import { Badge, Divider, Dropdown, Menu, Space } from 'antd'; -import { isEmpty, isNil, uniqueId } from 'lodash'; -import React, { FC, useCallback, useEffect, useMemo } from 'react'; -import { SearchIndex } from '../../enums/search.enum'; -import { getDropDownItems } from '../../utils/AdvancedSearchUtils'; -import SVGIcons, { Icons } from '../../utils/SvgUtils'; -import { ExploreQuickFilterField } from './explore.interface'; -import AdvancedField from './ExploreQuickFilter'; +import { Divider, Space } from 'antd'; +import { AxiosError } from 'axios'; +import { isUndefined } from 'lodash'; +import React, { FC, useState } from 'react'; +import { + getAdvancedFieldDefaultOptions, + getAdvancedFieldOptions, + getTagSuggestions, + getUserSuggestions, +} from '../../axiosAPIs/miscAPI'; +import { MISC_FIELDS } from '../../constants/AdvancedSearch.constants'; +import { getAdvancedField } from '../../utils/AdvancedSearchUtils'; +import { showErrorToast } from '../../utils/ToastUtils'; +import SearchDropdown from '../SearchDropdown/SearchDropdown'; +import { ExploreQuickFiltersProps } from './ExploreQuickFilters.interface'; -interface Props { - index: SearchIndex; - fields: Array; - onFieldRemove: (value: string) => void; - onClear: () => void; - onFieldValueSelect: (field: ExploreQuickFilterField) => void; - onFieldSelect: (value: string) => void; - onAdvanceSearch: () => void; -} - -const ExploreQuickFilters: FC = ({ +const ExploreQuickFilters: FC = ({ fields, - onFieldRemove, - onClear, onAdvanceSearch, index, onFieldValueSelect, - onFieldSelect, }) => { - const handleMenuItemClick = useCallback((menuInfo) => { - onFieldSelect(menuInfo.key); - }, []); + const [options, setOptions] = useState(); + const [isOptionsLoading, setIsOptionsLoading] = useState(false); - const menuItems = useMemo(() => getDropDownItems(index), [index]); + const fetchOptions = async (query: string, fieldKey: string) => { + const advancedField = getAdvancedField(fieldKey); + if (!MISC_FIELDS.includes(fieldKey)) { + const res = await getAdvancedFieldOptions(query, index, advancedField); - const menu = useMemo(() => { - return ( - ({ - ...option, - disabled: Boolean(fields.find((f) => f.key === option.key)), - onClick: handleMenuItemClick, - 'data-testid': 'dropdown-menu-item', - }))} - /> - ); - }, [onFieldSelect, fields, menuItems]); + const suggestOptions = + res.data.suggest['metadata-suggest'][0].options ?? []; + const uniqueOptions = [...new Set(suggestOptions.map((op) => op.text))]; + setOptions(uniqueOptions); + } else { + if (fieldKey === 'tags.tagFQN') { + const res = await getTagSuggestions(query); - useEffect(() => { - onClear(); - handleMenuItemClick(menuItems[0]); - handleMenuItemClick(menuItems[1]); - }, [menuItems]); + const suggestOptions = + res.data.suggest['metadata-suggest'][0].options ?? []; + const uniqueOptions = [ + ...new Set( + suggestOptions + .filter((op) => !isUndefined(op._source.fullyQualifiedName)) + .map((op) => op._source.fullyQualifiedName as string) + ), + ]; + setOptions(uniqueOptions); + } else { + const res = await getUserSuggestions(query); - const filterCount = useMemo( - () => fields.filter((field) => !isNil(field.value)).length, - [fields] - ); + const suggestOptions = + res.data.suggest['metadata-suggest'][0].options ?? []; + const uniqueOptions = [ + ...new Set(suggestOptions.map((op) => op._source.name)), + ]; + setOptions(uniqueOptions); + } + } + }; + + const getFilterOptions = async (value: string, key: string) => { + setIsOptionsLoading(true); + try { + if (value) { + await fetchOptions(value, key); + } else { + const res = await getAdvancedFieldDefaultOptions(index, key); + const buckets = res.data.aggregations[`sterms#${key}`].buckets; + setOptions(buckets.map((option) => option.key)); + } + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsOptionsLoading(false); + } + }; return ( - + {fields.map((field) => ( - { + onFieldValueSelect({ ...field, value: updatedValues }); + }} + onSearch={getFilterOptions} /> ))} - - - - - - - {!isEmpty(fields) && ( - - Clear All - - )} + 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 index 5319c5c8fa0..55504d55456 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.less @@ -11,6 +11,11 @@ * limitations under the License. */ +@trigger-btn-hover-bg: #efefef; +@remove-icon-color: #bbb; +@remove-icon-hover-color: #373737; +@update-btn-hover-bg: #e2e2e2; + .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%), @@ -19,8 +24,37 @@ .ant-dropdown-menu { box-shadow: none; padding: 0px; + max-height: 200px; + overflow-y: scroll; + margin-bottom: 8px; + .ant-dropdown-menu-item { padding: 4px 0px; } } + + .dropdown-option-label { + max-width: 200px; + } + + .update-btn { + background-color: @trigger-btn-hover-bg; + border-color: @trigger-btn-hover-bg; + } + + .update-btn:hover { + background-color: @update-btn-hover-bg; + border-color: @update-btn-hover-bg; + color: inherit; + } +} + +.quick-filter-dropdown-trigger-btn { + .remove-field-icon { + color: @remove-icon-color; + padding-bottom: 2px; + } + .remove-field-icon:hover { + color: @remove-icon-hover-color; + } } 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 index 9735e319369..b8e32c0de9b 100644 --- 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 @@ -20,20 +20,14 @@ 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 searchOptions = ['User 1', 'User 2', 'User 3', 'User 4', 'User 5']; const mockProps: SearchDropdownProps = { label: 'Owner', + isSuggestionsLoading: false, options: searchOptions, searchKey: 'owner.name', - selectedKeys: ['user.1'], - showClear: true, + selectedKeys: ['User 1'], onChange: mockOnChange, onSearch: mockOnSearch, }; @@ -72,9 +66,15 @@ describe('Search DropDown Component', () => { expect(searchInput).toBeInTheDocument(); - const clearButton = await screen.findByTestId('clear-button'); + const clearButton = screen.queryByTestId('clear-button'); - expect(clearButton).toBeInTheDocument(); + expect(clearButton).not.toBeInTheDocument(); + + const updateButton = await screen.findByTestId('update-btn'); + const closeButton = await screen.findByTestId('update-btn'); + + expect(updateButton).toBeInTheDocument(); + expect(closeButton).toBeInTheDocument(); }); it('Selected keys option should be checked', async () => { @@ -90,8 +90,8 @@ describe('Search DropDown Component', () => { expect(await screen.findByTestId('drop-down-menu')).toBeInTheDocument(); - // user.1 is selected key so should be checked - expect(await screen.findByTestId('user.1')).toBeChecked(); + // User 1 is selected key so should be checked + expect(await screen.findByTestId('User 1-checkbox')).toBeChecked(); }); it('UnSelected keys option should not be checked', async () => { @@ -107,13 +107,13 @@ describe('Search DropDown Component', () => { 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(); + expect(await screen.findByTestId('User 2-checkbox')).not.toBeChecked(); + expect(await screen.findByTestId('User 3-checkbox')).not.toBeChecked(); + expect(await screen.findByTestId('User 4-checkbox')).not.toBeChecked(); + expect(await screen.findByTestId('User 5-checkbox')).not.toBeChecked(); }); - it('Should render the clear all button and click should work', async () => { + it('Should render the clear all button after more than one options are selected and click should work', async () => { render(); const container = await screen.findByTestId('search-dropdown'); @@ -126,6 +126,18 @@ describe('Search DropDown Component', () => { expect(await screen.findByTestId('drop-down-menu')).toBeInTheDocument(); + const option2 = await screen.findByTestId('User 2'); + + await act(async () => { + userEvent.click(option2); + }); + + let option1Checkbox = await screen.findByTestId('User 1-checkbox'); + let option2Checkbox = await screen.findByTestId('User 2-checkbox'); + + expect(option1Checkbox).toBeChecked(); + expect(option2Checkbox).toBeChecked(); + const clearButton = await screen.findByTestId('clear-button'); expect(clearButton).toBeInTheDocument(); @@ -134,25 +146,11 @@ describe('Search DropDown Component', () => { userEvent.click(clearButton); }); - expect(mockOnChange).toHaveBeenCalledWith([], 'owner.name'); - }); + option1Checkbox = await screen.findByTestId('User 1-checkbox'); + option2Checkbox = await screen.findByTestId('User 2-checkbox'); - 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(); + expect(option1Checkbox).not.toBeChecked(); + expect(option2Checkbox).not.toBeChecked(); }); it('Search should work', async () => { @@ -179,7 +177,7 @@ describe('Search DropDown Component', () => { expect(mockOnSearch).toHaveBeenCalledWith('user', 'owner.name'); }); - it('On Change should work', async () => { + it('Update button should work properly', async () => { render(); const container = await screen.findByTestId('search-dropdown'); @@ -198,9 +196,15 @@ describe('Search DropDown Component', () => { userEvent.click(option2); }); + const updateButton = await screen.findByTestId('update-btn'); + + await act(async () => { + userEvent.click(updateButton); + }); + // onChange should be called with previous selected keys and current selected keys expect(mockOnChange).toHaveBeenCalledWith( - ['user.1', 'user.2'], + ['User 1', 'User 2'], 'owner.name' ); }); @@ -218,12 +222,50 @@ describe('Search DropDown Component', () => { expect(await screen.findByTestId('drop-down-menu')).toBeInTheDocument(); + let option1Checkbox = await screen.findByTestId('User 1-checkbox'); + + expect(option1Checkbox).toBeChecked(); + const option1 = await screen.findByTestId('User 1'); await act(async () => { userEvent.click(option1); }); - expect(mockOnChange).toHaveBeenCalledWith([], 'owner.name'); + option1Checkbox = await screen.findByTestId('User 1-checkbox'); + + expect(option1Checkbox).not.toBeChecked(); + }); + + it('Close button should work properly', async () => { + render(); + + const container = await screen.findByTestId('search-dropdown'); + + expect(container).toBeInTheDocument(); + + let dropdownMenu = screen.queryByTestId('drop-down-menu'); + + expect(dropdownMenu).toBeNull(); + + await act(async () => { + userEvent.click(container); + }); + + dropdownMenu = await screen.findByTestId('drop-down-menu'); + + expect(dropdownMenu).toBeInTheDocument(); + + const closeButton = await screen.findByTestId('update-btn'); + + expect(closeButton).toBeInTheDocument(); + + await act(async () => { + userEvent.click(closeButton); + }); + + dropdownMenu = screen.queryByTestId('drop-down-menu'); + + expect(dropdownMenu).toBeNull(); }); }); 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 index 6ddcf63c847..2f5b5210500 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchDropdown/SearchDropdown.tsx @@ -11,67 +11,79 @@ * limitations under the License. */ -import { DownOutlined } from '@ant-design/icons'; import { Button, Card, - Checkbox, + Divider, Dropdown, Input, MenuItemProps, MenuProps, + Row, Space, + Typography, } from 'antd'; +import classNames from 'classnames'; import React, { ChangeEvent, FC, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { ReactComponent as DropDown } from '../../assets/svg/DropDown.svg'; +import { + getSearchDropdownLabels, + getSelectedOptionLabelString, +} from '../../utils/AdvancedSearchUtils'; +import Loader from '../Loader/Loader'; import { SearchDropdownProps } from './SearchDropdown.interface'; import './SearchDropdown.less'; const SearchDropdown: FC = ({ + isSuggestionsLoading, label, options, searchKey, selectedKeys, - showClear, onChange, onSearch, }) => { const { t } = useTranslation(); const [isDropDownOpen, setIsDropDownOpen] = useState(false); + const [selectedOptions, setSelectedOptions] = + useState(selectedKeys); // derive menu props from options and selected keys const menuOptions: MenuProps['items'] = useMemo(() => { - return options.map((option) => { - const isSelected = selectedKeys.includes(option.key); + // Separating selected options to show on top + const selectedOptionKeys = + getSearchDropdownLabels(selectedOptions, true) || []; - return { - key: option.key, - label: ( - - - {option.label} - - ), - }; - }); - }, [options, selectedKeys]); + // Filtering out unselected options + const unselectedOptions = options.filter( + (option) => !selectedOptions.includes(option) + ); + + // Labels for unselected options + const otherOptions = + getSearchDropdownLabels(unselectedOptions, false) || []; + + return [...selectedOptionKeys, ...otherOptions]; + }, [options, selectedOptions]); // handle menu item click const handleMenuItemClick: MenuItemProps['onClick'] = (info) => { const currentKey = info.key; - const isSelected = selectedKeys.includes(currentKey); + const isSelected = selectedOptions.includes(currentKey); const updatedValues = isSelected - ? selectedKeys.filter((v) => v !== currentKey) - : [...selectedKeys, currentKey]; + ? selectedOptions.filter((v) => v !== currentKey) + : [...selectedOptions, currentKey]; - // call on change with updated value - onChange(updatedValues, searchKey); + setSelectedOptions(updatedValues); }; // handle clear all - const handleClear = () => onChange([], searchKey); + const handleClear = () => { + setSelectedOptions([]); + }; // handle search const handleSearch = (e: ChangeEvent) => { @@ -80,41 +92,111 @@ const SearchDropdown: FC = ({ onSearch(value, searchKey); }; + // Handle dropdown close + const handleDropdownClose = () => { + setIsDropDownOpen(false); + }; + + // Handle update button click + const handleUpdate = () => { + // call on change with updated value + onChange(selectedOptions, searchKey); + handleDropdownClose(); + }; + + const showClearAllBtn = useMemo( + () => selectedOptions.length > 1, + [selectedOptions] + ); + return ( { - return ( - - + dropdownRender={(menuNode) => ( + + +
- {showClear && ( +
+ {showClearAllBtn && ( + <> + - )} - {menuNode} + + )} + + {isSuggestionsLoading ? ( + + + + ) : options.length > 0 || selectedOptions.length > 0 ? ( + menuNode + ) : ( + + + {t('label.no-data-available')} + + + )} + + + -
- ); - }} +
+
+ )} key={searchKey} menu={{ items: menuOptions, onClick: handleMenuItemClick }} + open={isDropDownOpen} trigger={['click']} - visible={isDropDownOpen} - onVisibleChange={(visible) => setIsDropDownOpen(visible)}> -
diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 2afcc69b08c..d6055889719 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -474,7 +474,10 @@ "no-of-test": " No. of Test", "test-suite": "Test Suite", "enter-entity": "Enter {{entity}}", - "test-suite-status": "Test Suite Status" + "test-suite-status": "Test Suite Status", + "search-entity": "Search {{entity}}", + "more": "More", + "update": "Update" }, "message": { "service-email-required": "Service account Email is required", diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/app.less b/openmetadata-ui/src/main/resources/ui/src/styles/app.less index 937868420bd..41f3dde0d65 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/app.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/app.less @@ -19,6 +19,7 @@ @white: #fff; @gray: #dde3ea; @grey-body: rgb(55, 53, 47); +@trigger-btn-hover-bg: #efefef; //font weight .font-medium { @@ -92,6 +93,9 @@ .border-1 { border-width: 1px; } +.border-l { + border-left-width: 1px; +} .border-l-2 { border-left: 2px; } @@ -280,3 +284,15 @@ margin-bottom: 16px !important; color: @grey-body !important; } + +.quick-filter-dropdown-trigger-btn { + padding: 4px; + border: none; + background: transparent; + box-shadow: none; +} + +.quick-filter-dropdown-trigger-btn:hover, +.quick-filter-dropdown-trigger-btn:focus { + background-color: @trigger-btn-hover-bg; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/spacing.less b/openmetadata-ui/src/main/resources/ui/src/styles/spacing.less index fc6cf912286..ea366ce3df6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/spacing.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/spacing.less @@ -288,6 +288,9 @@ .p-xs { padding: @padding-xs !important; } +.p-xss { + padding: @padding-xss; +} .p-sm { padding: @padding-sm; } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.test.tsx new file mode 100644 index 00000000000..10fa409ccb0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.test.tsx @@ -0,0 +1,62 @@ +/* + * 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 { + getSearchDropdownLabels, + getSelectedOptionLabelString, +} from './AdvancedSearchUtils'; +import { + mockLongOptionsArray, + mockOptionsArray, + mockShortOptionsArray, +} from './mocks/AdvancedSearchUtils.mock'; + +describe('AdvancedSearchUtils tests', () => { + it('Function getSearchDropdownLabels should return menuItems for passed options', () => { + const resultMenuItems = getSearchDropdownLabels(mockOptionsArray, true); + + expect(resultMenuItems).toHaveLength(4); + }); + + it('Function getSearchDropdownLabels should return an empty array if passed 1st argument as other than array', () => { + const resultMenuItems = getSearchDropdownLabels( + '' as unknown as string[], + true + ); + + expect(resultMenuItems).toHaveLength(0); + }); + + it('Function getSelectedOptionLabelString should return all options if the length of resultant string is less than 15', () => { + const resultOptionsString = getSelectedOptionLabelString( + mockShortOptionsArray + ); + + expect(resultOptionsString).toEqual('str1, str2'); + }); + + it('Function getSelectedOptionLabelString should return string with ellipsis if the length of resultant string is more than 15', () => { + const resultOptionsString = + getSelectedOptionLabelString(mockLongOptionsArray); + + expect(resultOptionsString).toEqual('string1, st...'); + }); + + it('Function getSelectedOptionLabelString should return an empty string when passed anything else than string array as an argument', () => { + const resultOptionsString = getSelectedOptionLabelString( + 'invalidInput' as unknown as string[] + ); + + expect(resultOptionsString).toEqual(''); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx index 6ca9c003b2c..3e5ea393801 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx @@ -12,9 +12,9 @@ */ import Icon, { CloseCircleOutlined, PlusOutlined } from '@ant-design/icons'; -import { Button } from 'antd'; +import { Button, Checkbox, MenuProps, Space, Typography } from 'antd'; import i18next from 'i18next'; -import { isUndefined } from 'lodash'; +import { isArray, isUndefined } from 'lodash'; import React from 'react'; import { RenderSettings } from 'react-awesome-query-builder'; import { @@ -136,3 +136,40 @@ export const renderAdvanceSearchButtons: RenderSettings['renderButton'] = ( return <>; }; + +export const getSearchDropdownLabels = ( + optionsArray: string[], + checked: boolean +): MenuProps['items'] => { + if (isArray(optionsArray)) { + return optionsArray.map((option) => ({ + key: option, + label: ( + + + + {option} + + + ), + })); + } else { + return []; + } +}; + +export const getSelectedOptionLabelString = (selectedOptions: string[]) => { + if (isArray(selectedOptions)) { + const stringifiedOptions = selectedOptions.join(', '); + if (stringifiedOptions.length < 15) { + return stringifiedOptions; + } else { + return `${stringifiedOptions.slice(0, 11)}...`; + } + } else { + return ''; + } +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/mocks/AdvancedSearchUtils.mock.ts b/openmetadata-ui/src/main/resources/ui/src/utils/mocks/AdvancedSearchUtils.mock.ts new file mode 100644 index 00000000000..e25e4e3c214 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/mocks/AdvancedSearchUtils.mock.ts @@ -0,0 +1,28 @@ +/* + * 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 const mockOptionsArray = [ + 'option_1', + 'option_2', + 'option_3', + 'option_4', +]; + +export const mockShortOptionsArray = ['str1', 'str2']; + +export const mockLongOptionsArray = [ + 'string1', + 'string2', + 'string3', + 'string4', +];