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 <ashish@getcollate.io>
Co-authored-by: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com>
This commit is contained in:
Aniket Katkar 2022-12-13 19:18:10 +05:30 committed by GitHub
parent c7d0fede2f
commit 930361a49d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 557 additions and 592 deletions

View File

@ -0,0 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.00002 8.65912C5.8954 8.65912 5.79067 8.61915 5.71079 8.53927L1.61989 4.44834C1.46004 4.28849 1.46004 4.02964 1.61989 3.86989C1.77974 3.71014 2.03859 3.71004 2.19834 3.86989L6.00002 7.6716L9.80167 3.86989C9.96157 3.71004 10.2204 3.71004 10.3802 3.86989C10.5399 4.02974 10.54 4.28859 10.3802 4.44834L6.28925 8.53927C6.20937 8.61915 6.10464 8.65912 6.00002 8.65912Z" fill="#37352F" stroke="#37352F" stroke-width="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 532 B

View File

@ -248,6 +248,20 @@ export const getAdvancedFieldOptions = (
});
};
export const getAdvancedFieldDefaultOptions = (
index: SearchIndex,
field: string
) => {
const params = { index, field };
return APIClient.get<SearchResponse<ExploreSearchIndex>>(
`/search/aggregate`,
{
params,
}
);
};
export const getEntityCount = async (
path: string,
database?: string

View File

@ -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<ExploreProps> = ({
};
const handleAdvanceSearchFilter = (data: ExploreQuickFilterField[]) => {
const term = {} as Record<string, unknown>;
const terms = [] as Array<Record<string, unknown>>;
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<ExploreProps> = ({
});
};
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<ExploreProps> = ({
};
}, []);
useEffect(() => {
const dropdownItems = getDropDownItems(searchIndex);
setSelectedQuickFilters(
dropdownItems.map((item) => ({ ...item, value: undefined }))
);
}, [searchIndex]);
return (
<PageLayoutV1
leftPanel={
@ -393,9 +381,6 @@ const Explore: React.FC<ExploreProps> = ({
fields={selectedQuickFilters}
index={searchIndex}
onAdvanceSearch={() => setShowAdvanceSearchModal(true)}
onClear={handleAdvanceFieldClear}
onFieldRemove={handleAdvanceFieldRemove}
onFieldSelect={handleAdvancedFieldSelect}
onFieldValueSelect={handleAdvanceFieldValueSelect}
/>
</Col>

View File

@ -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(<AdvancedField {...mockProps} />);
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(<AdvancedField {...mockProps} />);
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(
<AdvancedField {...mockProps} />
);
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',
});
});
});
});

View File

@ -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) => (
<Option data-testid="field-option" key={d.value}>
{d.label}
</Option>
));
return (
<Select
allowClear
showSearch
bordered={false}
className="ant-advaced-field-select"
data-testid="field-select"
defaultActiveFirstOption={false}
dropdownClassName="ant-suggestion-dropdown"
filterOption={false}
notFoundContent={null}
placeholder="Search to Select"
showArrow={false}
value={value}
onChange={handleChange}
onClear={handleClear}
onSearch={handleSearch}
onSelect={handleSelect}>
{optionsElement}
</Select>
);
};
const ExploreQuickFilter: FC<ExploreQuickFilterProps> = ({
field,
onFieldRemove,
index,
onFieldValueSelect,
}) => {
const advancedField = getAdvancedField(field.key);
const [options, setOptions] = useState<DefaultOptionType[]>([]);
const [value, setValue] = useState<string | undefined>(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 (
<div className="tw-bg-white tw-border tw-border-main tw-rounded tw-p-1 tw-px-2 tw-flex tw-justify-between">
<span className="tw-self-center" data-testid="field-label">
{startCase(getItemLabel(field.key))}:
</span>
<SearchInput
handleChange={handleChange}
handleClear={handleOnClear}
handleSearch={handleSearch}
handleSelect={handleOnSelect}
options={options}
value={value}
/>
<span
className="tw-cursor-pointer tw-self-center"
data-testid="field-remove-button"
onClick={() => onFieldRemove(field.key)}>
<FontAwesomeIcon className="tw-text-primary" icon="times" />
</span>
</div>
);
};
export default ExploreQuickFilter;

View File

@ -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<ExploreQuickFilterField>;
onFieldValueSelect: (field: ExploreQuickFilterField) => void;
onAdvanceSearch: () => void;
}
export interface FilterFieldsMenuItem {
key: string;
label: string;
defaultField: boolean;
}

View File

@ -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(<div data-testid="advanced-field">ExploreQuickFilter</div>)
.mockReturnValue(<div data-testid="search-dropdown">SearchDropdown</div>)
);
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(
<ExploreQuickFilters {...mockProps} />
);
const { findAllByTestId } = render(<ExploreQuickFilters {...mockProps} />);
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(
<ExploreQuickFilters {...mockProps} />
);
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', () => {
<ExploreQuickFilters {...mockProps} />
);
const fields = await findAllByTestId('advanced-field');
const fields = await findAllByTestId('search-dropdown');
const advanceSearchButton = await findByTestId('advance-search-button');
expect(fields).toHaveLength(fields.length);

View File

@ -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<ExploreQuickFilterField>;
onFieldRemove: (value: string) => void;
onClear: () => void;
onFieldValueSelect: (field: ExploreQuickFilterField) => void;
onFieldSelect: (value: string) => void;
onAdvanceSearch: () => void;
}
const ExploreQuickFilters: FC<Props> = ({
const ExploreQuickFilters: FC<ExploreQuickFiltersProps> = ({
fields,
onFieldRemove,
onClear,
onAdvanceSearch,
index,
onFieldValueSelect,
onFieldSelect,
}) => {
const handleMenuItemClick = useCallback((menuInfo) => {
onFieldSelect(menuInfo.key);
}, []);
const [options, setOptions] = useState<string[]>();
const [isOptionsLoading, setIsOptionsLoading] = useState<boolean>(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 (
<Menu
items={menuItems.map((option) => ({
...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 (
<Space wrap size={[16, 16]}>
<Space wrap className="explore-quick-filters-container" size={[16, 16]}>
{fields.map((field) => (
<AdvancedField
field={field}
index={index}
key={uniqueId()}
onFieldRemove={onFieldRemove}
onFieldValueSelect={onFieldValueSelect}
<SearchDropdown
isSuggestionsLoading={isOptionsLoading}
key={field.key}
label={field.label}
options={options || []}
searchKey={field.key}
selectedKeys={field.value || []}
onChange={(updatedValues) => {
onFieldValueSelect({ ...field, value: updatedValues });
}}
onSearch={getFilterOptions}
/>
))}
<Dropdown
className="cursor-pointer"
data-testid="quick-filter-dropdown"
overlay={menu}
trigger={['click']}>
<Badge count={filterCount} size="small">
<SVGIcons alt="filter" icon={Icons.FILTER_PRIMARY} />
</Badge>
</Dropdown>
<Divider type="vertical" />
{!isEmpty(fields) && (
<span
className="tw-text-primary tw-self-center tw-cursor-pointer"
data-testid="clear-all-button"
onClick={onClear}>
Clear All
</span>
)}
<Divider className="m-0" type="vertical" />
<span
className="tw-text-primary tw-self-center tw-cursor-pointer"
data-testid="advance-search-button"

View File

@ -72,7 +72,8 @@ export interface ExploreProps {
export interface ExploreQuickFilterField {
key: string;
value: string | undefined;
label: string;
value: string[] | undefined;
}
export interface ExploreQuickFilterProps {

View File

@ -11,17 +11,12 @@
* limitations under the License.
*/
export interface DropDownOption {
key: string;
label: string;
}
export interface SearchDropdownProps {
label: string;
options: DropDownOption[];
isSuggestionsLoading?: boolean;
options: string[];
searchKey: string;
selectedKeys: string[];
showClear?: boolean;
onChange: (values: string[], searchKey: string) => void;
onSearch: (searchText: string, searchKey: string) => void;
}

View File

@ -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;
}
}

View File

@ -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(<SearchDropdown {...mockProps} />);
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(<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();
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(<SearchDropdown {...mockProps} />);
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(<SearchDropdown {...mockProps} />);
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();
});
});

View File

@ -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<SearchDropdownProps> = ({
isSuggestionsLoading,
label,
options,
searchKey,
selectedKeys,
showClear,
onChange,
onSearch,
}) => {
const { t } = useTranslation();
const [isDropDownOpen, setIsDropDownOpen] = useState<boolean>(false);
const [selectedOptions, setSelectedOptions] =
useState<string[]>(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: (
<Space data-testid={option.label} size={6}>
<Checkbox checked={isSelected} data-testid={option.key} />
{option.label}
</Space>
),
};
});
}, [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<HTMLInputElement>) => {
@ -80,41 +92,111 @@ const SearchDropdown: FC<SearchDropdownProps> = ({
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 (
<Dropdown
destroyPopupOnHide
data-testid={searchKey}
dropdownRender={(menuNode) => {
return (
<Card className="custom-dropdown-render" data-testid="drop-down-menu">
<Space direction="vertical" size={4}>
dropdownRender={(menuNode) => (
<Card
bodyStyle={{ padding: 0 }}
className="custom-dropdown-render"
data-testid="drop-down-menu">
<Space direction="vertical" size={0}>
<div className="p-t-sm p-x-sm">
<Input
data-testid="search-input"
placeholder={`Search ${label}...`}
placeholder={`${t('label.search-entity', {
entity: label,
})}...`}
onChange={handleSearch}
/>
{showClear && (
</div>
{showClearAllBtn && (
<>
<Divider className="m-t-xs m-b-0" />
<Button
className="p-0"
className="p-0 m-l-sm"
data-testid="clear-button"
type="link"
onClick={handleClear}>
{t('label.clear-all')}
</Button>
)}
{menuNode}
</>
)}
<Divider
className={classNames(showClearAllBtn ? 'm-y-0' : 'm-t-xs m-b-0')}
/>
{isSuggestionsLoading ? (
<Row align="middle" className="p-y-sm" justify="center">
<Loader size="small" />
</Row>
) : options.length > 0 || selectedOptions.length > 0 ? (
menuNode
) : (
<Row className="m-y-sm" justify="center">
<Typography.Text>
{t('label.no-data-available')}
</Typography.Text>
</Row>
)}
<Space className="p-sm p-t-xss">
<Button
className="update-btn"
data-testid="update-btn"
size="small"
onClick={handleUpdate}>
{t('label.update')}
</Button>
<Button
data-testid="close-btn"
size="small"
type="link"
onClick={handleDropdownClose}>
{t('label.close')}
</Button>
</Space>
</Card>
);
}}
</Space>
</Card>
)}
key={searchKey}
menu={{ items: menuOptions, onClick: handleMenuItemClick }}
open={isDropDownOpen}
trigger={['click']}
visible={isDropDownOpen}
onVisibleChange={(visible) => setIsDropDownOpen(visible)}>
<Button>
<Space data-testid="search-dropdown">
{label}
<DownOutlined />
onOpenChange={(visible) => {
visible && onSearch('', searchKey);
setIsDropDownOpen(visible);
}}>
<Button className="quick-filter-dropdown-trigger-btn">
<Space data-testid="search-dropdown" size={4}>
<Space size={0}>
<Typography.Text>{label}</Typography.Text>
{selectedKeys.length > 0 && (
<span>
{': '}
<Typography.Text className="text-primary font-medium">
{getSelectedOptionLabelString(selectedKeys)}
</Typography.Text>
</span>
)}
</Space>
<DropDown className="flex self-center" />
</Space>
</Button>
</Dropdown>

View File

@ -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",

View File

@ -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;
}

View File

@ -288,6 +288,9 @@
.p-xs {
padding: @padding-xs !important;
}
.p-xss {
padding: @padding-xss;
}
.p-sm {
padding: @padding-sm;
}

View File

@ -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('');
});
});

View File

@ -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: (
<Space className="m-x-sm" data-testid={option} size={6}>
<Checkbox checked={checked} data-testid={`${option}-checkbox`} />
<Typography.Text
ellipsis
className="dropdown-option-label"
title={option}>
{option}
</Typography.Text>
</Space>
),
}));
} 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 '';
}
};

View File

@ -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',
];