Unverified Commit 5e4a466b authored by Tamika Tannis's avatar Tamika Tannis Committed by GitHub

Search Filtering Support (#384)

* Initial Filter React/Redux Modifications (#357)

* Update configs and SearchFilter UI

* Remove search syntax logic in SearchBar; Add RegEx validatation for SearchBar

* Add filter state and ducks logic

* Update filter UI; Code cleanup

* Some python lint fixes

* Code cleanup

* Update request utils

* Filter UX Improvements (#361)

* SearchFilter UX improvements

* Filter url updates; Modify no search results message

* Support loading filters from pasted url

* InputFilter apply changes on 'enter'

* Simplify reducer logic + lint cleanup

* Some cleanup based on comments

* Fix build

* Mypy fix

* Mypy fix

* Filter UI Improvements: Part 1 (#370)

* Change 'Type' to 'Source'

* Prevent SearchPanel width increase when InputFilter is present

* Re-lower coverage while in development

* Managed input autocomplete (#375)

* Disabled autocomplete for some email input fields
* Removed the hard coded font size for autocomplete

* Improve user search experience (#376)

* Fix a merge issue

* Allow no search term; Persist filters on SearchPage

* Lint fix

* Add a TODO

* Add support for configurable filter section help text (#377)

* Add support for configurable filter section help text

* Support on CheckboxFilterSection as well

* Add default filterCategories for tables

* Add default helpText

* Update search flow (some more) (#383)

* Add a Breadcrumb on HomePage that makes filters discoverable

* Switch to using explicit clearing of search term; Revert to requiring term for SearchBar submit

* Allow for html in InforButton text

* WIP: Comment out some tests

* Fix an edge case

* Cleanup python logic; Add comments; Add tests;

* Fix indentation error; Cleanup python tests;

* Cleanup HomePage, Breadcrumb, InfoButton; Update tests

* Connect SearchBar to router for executing special SearchPage logic

* Cleanup SearchBar; Add tests;

* Cleanup SearchPage; Add tests

* Cleanup SearchFiilter components; Add tests; Increase jest threshold;

* Fix build; Update FilterSection types

* Cleanup config; Add tests; Update jest threshold;

* Add another config test

* Fix SearchFilter; Add filter action types; Code cleanup; Add tests;

* Cleanup search reducer; Update/add tests;

* Cleanup ducks + utils; Add tests;

* Update application config documentation

* Update endpoint to match search library change

* Remove unused lines

* Update input styles

* rename filterconfig 'value' reference to 'categoryId'

* Add new filter logic to support TagInfo case

* Lint fix

* Restructure filter components to leverage mapStateToProps; Update FilterType enums

* Lint fix

* Fix bad merge

* Apply suggestions from code review
Co-authored-by: 's avatarDaniel <dwon@lyft.com>
parent b3b4b649
This diff is collapsed.
......@@ -15,21 +15,26 @@ def get_query_param(args: Dict, param: str, error_msg: str = None) -> str:
def request_metadata(*, # type: ignore
url: str,
method: str = 'GET',
headers=None,
timeout_sec: int = 0,
data=None):
"""
Helper function to make a request to metadata service.
Sets the client and header information based on the configuration
:param headers: Optional headers for the request, e.g. specifying Content-Type
:param method: DELETE | GET | POST | PUT
:param url: The request URL
:param timeout_sec: Number of seconds before timeout is triggered.
:param data: Optional request payload
:return:
"""
if headers is None:
headers = {}
if app.config['REQUEST_HEADERS_METHOD']:
headers = app.config['REQUEST_HEADERS_METHOD'](app)
else:
headers = app.config['METADATASERVICE_REQUEST_HEADERS']
headers.update(app.config['REQUEST_HEADERS_METHOD'](app))
elif app.config['METADATASERVICE_REQUEST_HEADERS']:
headers.update(app.config['METADATASERVICE_REQUEST_HEADERS'])
return request_wrapper(method=method,
url=url,
client=app.config['METADATASERVICE_REQUEST_CLIENT'],
......@@ -41,21 +46,27 @@ def request_metadata(*, # type: ignore
def request_search(*, # type: ignore
url: str,
method: str = 'GET',
headers=None,
timeout_sec: int = 0,
data=None):
"""
Helper function to make a request to search service.
Sets the client and header information based on the configuration
:param headers: Optional headers for the request, e.g. specifying Content-Type
:param method: DELETE | GET | POST | PUT
:param url: The request URL
:param timeout_sec: Number of seconds before timeout is triggered.
:param data: Optional request payload
:return:
"""
if headers is None:
headers = {}
if app.config['REQUEST_HEADERS_METHOD']:
headers = app.config['REQUEST_HEADERS_METHOD'](app)
else:
headers = app.config['SEARCHSERVICE_REQUEST_HEADERS']
headers.update(app.config['REQUEST_HEADERS_METHOD'](app))
elif app.config['SEARCHSERVICE_REQUEST_HEADERS']:
headers.update(app.config['SEARCHSERVICE_REQUEST_HEADERS'])
return request_wrapper(method=method,
url=url,
client=app.config['SEARCHSERVICE_REQUEST_CLIENT'],
......
import logging
from typing import Dict # noqa: F401
from flask import Response, jsonify, make_response
def create_error_response(*, message: str, payload: Dict, status_code: int) -> Response:
"""
Logs and info level log with the given message, and returns a response with:
1. The given message as 'msg' in the response data
2. The given status code as thge response status code
"""
logging.info(message)
payload['msg'] = message
return make_response(jsonify(payload), status_code)
from typing import Dict, List # noqa: F401
# These can move to a configuration when we have custom use cases outside of these default values
valid_search_fields = {
'column',
'database',
'schema',
'table',
'tag'
}
def map_table_result(result: Dict) -> Dict:
return {
'type': 'table',
'key': result.get('key', None),
'name': result.get('name', None),
'cluster': result.get('cluster', None),
'description': result.get('description', None),
'database': result.get('database', None),
'schema': result.get('schema', None),
'last_updated_timestamp': result.get('last_updated_timestamp', None),
}
def generate_query_json(*, filters: Dict = {}, page_index: str, search_term: str) -> Dict:
"""
Transforms the given paramaters to the query json for the search service according to
the api defined at:
TODO (ttannis): Add link when amundsensearch PR is complete
"""
# Generate the filter payload
filter_payload = {}
for category in valid_search_fields:
values = filters.get(category)
value_list = [] # type: List
if values is not None:
if type(values) == str:
value_list = [values, ]
elif type(values) == dict:
value_list = [key for key in values.keys() if values[key] is True]
if len(value_list) > 0:
filter_payload[category] = value_list
# Return the full query json
return {
'page_index': int(page_index),
'search_request': {
'type': 'AND',
'filters': filter_payload
},
'query_term': search_term
}
......@@ -13,8 +13,8 @@ input {
&:-webkit-autofill:hover,
&:-webkit-autofill:focus,
&:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0px 1000px $white inset !important;
font-size: 20px !important;
-webkit-box-shadow: 0 0 0 1000px $white inset !important;
box-shadow: 0 0 0 1000px $white inset !important;
}
&[type="radio"] {
......@@ -24,6 +24,10 @@ input {
&[type="text"] {
color: $text-secondary !important;
}
&:not([disabled]) {
cursor: pointer;
}
}
textarea {
......
......@@ -91,6 +91,9 @@ $nav-bar-height: 60px;
$body-min-width: 1048px;
$footer-height: 60px;
// SearchPanel
$search-panel-width: 270px;
$search-panel-border-width: 4px;
// List Group
$list-group-border: $stroke !default;
......
module.exports = {
coverageThreshold: {
'./js/config': {
branches: 50, // 100
functions: 0, // 100
lines: 50, // 100
statements: 50, // 100
branches: 100,
functions: 75, // 100
lines: 90, // 100
statements: 90, // 100
},
'./js/components': {
branches: 40, // 75
functions: 45, // 75
lines: 50, // 75
statements: 50, // 75
branches: 60, // 75
functions: 65, // 75
lines: 65, // 75
statements: 70, // 75
},
'./js/ducks': {
branches: 75,
......
......@@ -28,7 +28,13 @@ export class BugReportFeedbackForm extends AbstractFeedbackForm {
<input type="hidden" name="feedback-type" value="Bug Report"/>
<div className="form-group">
<label>{SUBJECT_LABEL}</label>
<input type="text" name="subject" className="form-control" required={ true } placeholder={SUBJECT_PLACEHOLDER} />
<input
type="text"
autoComplete="off"
name="subject"
className="form-control"
required={ true }
placeholder={SUBJECT_PLACEHOLDER} />
</div>
<div className="form-group">
<label>{BUG_SUMMARY_LABEL}</label>
......
......@@ -28,7 +28,13 @@ export class RequestFeedbackForm extends AbstractFeedbackForm {
<input type="hidden" name="feedback-type" value="Feature Request"/>
<div className="form-group">
<label>{SUBJECT_LABEL}</label>
<input type="text" name="subject" className="form-control" required={ true } placeholder={SUBJECT_PLACEHOLDER} />
<input
type="text"
autoComplete="off"
name="subject"
className="form-control"
required={ true }
placeholder={SUBJECT_PLACEHOLDER} />
</div>
<div className="form-group">
<label>{FEATURE_SUMMARY_LABEL}</label>
......
export const SEARCH_BREADCRUMB_TEXT = "Advanced Search";
......@@ -5,8 +5,10 @@ import { RouteComponentProps } from 'react-router';
// TODO: Use css-modules instead of 'import'
import './styles.scss';
import { SEARCH_BREADCRUMB_TEXT } from './constants';
import MyBookmarks from 'components/common/Bookmark/MyBookmarks';
import Breadcrumb from 'components/common/Breadcrumb';
import PopularTables from 'components/common/PopularTables';
import { SearchAllReset } from 'ducks/search/types';
import { searchReset } from 'ducks/search/reducer';
......@@ -35,6 +37,13 @@ export class HomePage extends React.Component<HomePageProps> {
<div className="row">
<div className="col-xs-12 col-md-offset-1 col-md-10">
<SearchBar />
<div className="filter-breadcrumb pull-right">
<Breadcrumb
direction="right"
path="/search"
text={SEARCH_BREADCRUMB_TEXT}
/>
</div>
<div className="home-element-container">
<div id="browse-tags-header" className="title-1 browse-tags-header">Browse Tags</div>
<TagsList />
......
......@@ -9,6 +9,16 @@
margin-bottom: 32px;
}
.filter-breadcrumb {
margin-top: 4px;
.amundsen-breadcrumb {
margin-right: -$spacer-1;
img.icon {
margin-right: 0;
}
}
}
@media (max-width: $screen-sm-max) {
.home-element-container {
margin-top: 32px;
......
......@@ -4,6 +4,7 @@ import { shallow } from 'enzyme';
import { mapDispatchToProps, HomePage, HomePageProps } from '../';
import Breadcrumb from 'components/common/Breadcrumb';
import MyBookmarks from 'components/common/Bookmark/MyBookmarks';
import PopularTables from 'components/common/PopularTables';
import SearchBar from 'components/common/SearchBar';
......@@ -38,6 +39,12 @@ describe('HomePage', () => {
expect(wrapper.contains(<SearchBar />));
});
it('contains a Breadcrumb that directs to the /search', () => {
const element = wrapper.find(Breadcrumb);
expect(element.exists()).toBe(true);
expect(element.props().path).toEqual('/search');
});
it('contains TagsList', () => {
expect(wrapper.find('#browse-tags-header').text()).toEqual('Browse Tags');
expect(wrapper.contains(<TagsList />));
......
import * as React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { GlobalState } from 'ducks/rootReducer';
import { clearFilterByCategory, updateFilterByCategory, ClearFilterRequest, UpdateFilterRequest, FilterOptions } from 'ducks/search/filters/reducer';
import CheckBoxItem from 'components/common/Inputs/CheckBoxItem';
export interface CheckboxFilterProperties {
label: string;
value: string;
}
interface OwnProps {
categoryId: string;
checkboxProperties: CheckboxFilterProperties[];
}
interface StateFromProps {
checkedValues: FilterOptions;
}
interface DispatchFromProps {
clearFilterByCategory: (categoryId: string) => ClearFilterRequest;
updateFilterByCategory: (categoryId: string, value: FilterOptions) => UpdateFilterRequest;
}
export type CheckBoxFilterProps = OwnProps & DispatchFromProps & StateFromProps;
export class CheckBoxFilter extends React.Component<CheckBoxFilterProps> {
constructor(props) {
super(props);
}
createCheckBoxItem = (categoryId: string, key: string, item: CheckboxFilterProperties) => {
const { label, value } = item;
return (
<CheckBoxItem
key={key}
checked={ this.props.checkedValues[value] }
name={ categoryId }
value={ value }
onChange={ this.onCheckboxChange }>
<span className="subtitle-2">{ label }</span>
</CheckBoxItem>
);
};
onCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let checkedValues = this.props.checkedValues;
const value = e.target.value;
const categoryId = e.target.name;
if (e.target.checked) {
checkedValues = {
...this.props.checkedValues,
[value]: true,
}
} else {
/* Removing an object key with object destructuring */
const { [value]: removed, ...newCheckedValues } = this.props.checkedValues;
checkedValues = newCheckedValues;
}
if (Object.keys(checkedValues).length === 0) {
this.props.clearFilterByCategory(categoryId);
}
else {
this.props.updateFilterByCategory(categoryId, checkedValues);
}
};
render = () => {
const { categoryId, checkboxProperties } = this.props;
return (
<div className="checkbox-section-content">
{ checkboxProperties.map((item, index) => this.createCheckBoxItem(categoryId, `item:${categoryId}:${index}`, item)) }
</div>
)
}
};
export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
const filterState = state.search.filters;
let filterValues = filterState[state.search.selectedTab] ? filterState[state.search.selectedTab][ownProps.categoryId] : {};
if (!filterValues) {
filterValues = {};
}
return {
checkedValues: filterValues
}
};
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({
clearFilterByCategory,
updateFilterByCategory,
}, dispatch);
};
export default connect<StateFromProps, DispatchFromProps, OwnProps>(mapStateToProps, mapDispatchToProps)(CheckBoxFilter);
import * as React from 'react';
import { shallow } from 'enzyme';
import { CheckBoxFilter, CheckBoxFilterProps, mapDispatchToProps, mapStateToProps } from '../';
import CheckBoxItem from 'components/common/Inputs/CheckBoxItem';
import globalState from 'fixtures/globalState';
import { GlobalState } from 'ducks/rootReducer';
import { FilterType, ResourceType } from 'interfaces';
describe('CheckBoxFilter', () => {
const setup = (propOverrides?: Partial<CheckBoxFilterProps>) => {
const props: CheckBoxFilterProps = {
categoryId: 'database',
checkboxProperties: [
{
label: 'BigQuery',
value: 'bigquery'
},
{
label: 'Hive',
value: 'hive'
}
],
checkedValues: {
'hive': true,
},
clearFilterByCategory: jest.fn(),
updateFilterByCategory: jest.fn(),
...propOverrides
};
const wrapper = shallow<CheckBoxFilter>(<CheckBoxFilter {...props} />);
return { props, wrapper };
};
describe('createCheckBoxItem', () => {
const mockCategoryId = 'categoryId';
let props;
let wrapper;
let checkBoxItem;
let mockProperties;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
mockProperties = props.checkboxProperties[0];
const content = wrapper.instance().createCheckBoxItem(mockCategoryId, 'testKey', mockProperties);
checkBoxItem = shallow(<div>{content}</div>).find(CheckBoxItem);
})
it('returns a CheckBoxItem', () => {
expect(checkBoxItem.exists()).toBe(true);
});
it('returns a CheckBoxItem with correct props', () => {
const itemProps = checkBoxItem.props()
expect(itemProps.checked).toBe(props.checkedValues[mockProperties.value]);
expect(itemProps.name).toBe(mockCategoryId);
expect(itemProps.value).toBe(mockProperties.value);
expect(itemProps.onChange).toBe(wrapper.instance().onCheckboxChange)
});
it('returns a CheckBoxItem with correct labelText as child', () => {
expect(checkBoxItem.children().text()).toBe(mockProperties.label)
});
});
describe('onCheckboxChange', () => {
const mockCategoryId = 'database';
let props;
let wrapper;
let mockEvent;
let clearCategorySpy;
let updateCategorySpy;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
clearCategorySpy = jest.spyOn(props, 'clearFilterByCategory');
updateCategorySpy = jest.spyOn(props, 'updateFilterByCategory');
})
it('calls props.clearFilterByCategory if no items will be checked', () => {
clearCategorySpy.mockClear();
mockEvent = { target: { name: mockCategoryId, value: 'hive', checked: false }};
wrapper.instance().onCheckboxChange(mockEvent);
expect(clearCategorySpy).toHaveBeenCalledWith(mockCategoryId)
});
it('calls props.updateFilterByCategory with expected parameters', () => {
updateCategorySpy.mockClear();
mockEvent = { target: { name: mockCategoryId, value: 'bigquery', checked: true}};
const expectedCheckedValues = {
...props.checkedValues,
'bigquery': true
}
wrapper.instance().onCheckboxChange(mockEvent);
expect(updateCategorySpy).toHaveBeenCalledWith(mockCategoryId, expectedCheckedValues)
});
});
describe('render', () => {
let props;
let wrapper;
let createCheckBoxItemSpy;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
createCheckBoxItemSpy = jest.spyOn(wrapper.instance(), 'createCheckBoxItem');
wrapper.instance().render();
})
it('calls createCheckBoxItem with correct parameters for each props.checkboxProperties', () => {
props.checkboxProperties.forEach((item, index) => {
expect(createCheckBoxItemSpy).toHaveBeenCalledWith(props.categoryId, `item:${props.categoryId}:${index}`,item)
})
})
});
describe('mapStateToProps', () => {
const mockCategoryId = 'database';
const props = setup({ categoryId: mockCategoryId }).props;
const mockFilters = {
'hive': true
};
const mockStateWithFilters: GlobalState = {
...globalState,
search: {
...globalState.search,
selectedTab: ResourceType.table,
filters: {
[ResourceType.table]: {
[mockCategoryId]: mockFilters
}
}
},
};
const mockStateWithOutFilters: GlobalState = {
...globalState,
search: {
...globalState.search,
selectedTab: ResourceType.user,
filters: {
[ResourceType.table]: {}
}
},
};
let result;
beforeEach(() => {
result = mapStateToProps(mockStateWithFilters, props);
});
it('sets checkedValues on the props with the filter value for the categoryId', () => {
expect(result.checkedValues).toBe(mockFilters);
});
it('sets checkedValues to empty object if no filters exist for the given resource', () => {
result = mapStateToProps(mockStateWithOutFilters, props);
expect(result.checkedValues).toEqual({});
});
it('sets checkedValues to empty object if no filters exist for the given category', () => {
const props = setup({ categoryId: 'fakeCategory' }).props;
result = mapStateToProps(mockStateWithFilters, props);
expect(result.checkedValues).toEqual({});
});
});
describe('mapDispatchToProps', () => {
let dispatch;
let result;
beforeAll(() => {
const props = setup().props;
dispatch = jest.fn(() => Promise.resolve());
result = mapDispatchToProps(dispatch);
});
it('sets clearFilterByCategory on the props', () => {
expect(result.clearFilterByCategory).toBeInstanceOf(Function);
});
it('sets updateFilterByCategory on the props', () => {
expect(result.updateFilterByCategory).toBeInstanceOf(Function);
});
});
});
import * as React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { clearFilterByCategory, ClearFilterRequest } from 'ducks/search/filters/reducer';
import { CLEAR_BTN_TEXT } from '../constants';
import { GlobalState } from 'ducks/rootReducer';
import CheckBoxFilter, { CheckboxFilterProperties } from '../CheckBoxFilter';
import InputFilter from '../InputFilter';
import { FilterType } from 'interfaces';
import InfoButton from 'components/common/InfoButton';
export interface OwnProps {
categoryId: string;
helpText?: string;
title: string;
type: FilterType;
options?: CheckboxFilterProperties[];
};
export interface StateFromProps {
hasValue: boolean;
};
export interface DispatchFromProps {
clearFilterByCategory: (categoryId: string) => ClearFilterRequest;
};
export type FilterSectionProps = OwnProps & DispatchFromProps & StateFromProps;
export class FilterSection extends React.Component<FilterSectionProps> {
constructor(props) {
super(props);
}
onClearFilter = () => {
this.props.clearFilterByCategory(this.props.categoryId);
}
renderFilterComponent = () => {
const { categoryId, options, type } = this.props;
if (type === FilterType.INPUT_SELECT) {
return (
<InputFilter
categoryId={ categoryId }
/>
)
}
if (type === FilterType.CHECKBOX_SELECT) {
return (
<CheckBoxFilter
categoryId={ categoryId }
checkboxProperties={ options }
/>
)
}
}
render = () => {
const { categoryId, hasValue, helpText, title } = this.props;
return (
<div className="search-filter-section">
<div className="search-filter-section-header">
<div className="search-filter-section-title">
<div className="title-2">{ title }</div>
{
helpText &&
<InfoButton
infoText={ helpText }
placement="top"
size="small"
/>
}
</div>
{
hasValue &&
<a onClick={ this.onClearFilter } className='btn btn-flat-icon'>
<img className='icon icon-left'/>
<span>{ CLEAR_BTN_TEXT }</span>
</a>
}
</div>
{ this.renderFilterComponent() }
</div>
);
}
};
export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
const filterState = state.search.filters;
const filterValue = filterState[state.search.selectedTab] ? filterState[state.search.selectedTab][ownProps.categoryId] : null;
let hasValue = false;
if (filterValue && ownProps.type === FilterType.CHECKBOX_SELECT) {
Object.keys(filterValue).forEach(key => {
if (filterValue[key] === true) { hasValue = true; }
})
} else if (ownProps.type === FilterType.INPUT_SELECT) {
hasValue = !!filterValue;
}
return {
hasValue,
}
};
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({
clearFilterByCategory,
}, dispatch);
};
export default connect<StateFromProps, DispatchFromProps, OwnProps>(mapStateToProps, mapDispatchToProps)(FilterSection);
import * as React from 'react';
import { shallow } from 'enzyme';
import { GlobalState } from 'ducks/rootReducer';
import { clearFilterByCategory } from 'ducks/search/filters/reducer';
import { FilterSection, FilterSectionProps, mapDispatchToProps, mapStateToProps } from '../';
import globalState from 'fixtures/globalState';
import { FilterType, ResourceType } from 'interfaces';
import InfoButton from 'components/common/InfoButton';
import { CLEAR_BTN_TEXT } from '../../constants';
describe('FilterSection', () => {
const setup = (propOverrides?: Partial<FilterSectionProps>) => {
const props: FilterSectionProps = {
categoryId: 'testId',
hasValue: true,
title: 'Category',
clearFilterByCategory: jest.fn(),
type: FilterType.INPUT_SELECT,
...propOverrides
};
const wrapper = shallow<FilterSection>(<FilterSection {...props} />);
return { props, wrapper };
};
describe('onClearFilter', () => {
let props;
let wrapper;
let clearFilterSpy;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
clearFilterSpy = jest.spyOn(props, 'clearFilterByCategory');
});
it('calls props.clearFilterByCategory with props.categoryId', () => {
wrapper.instance().onClearFilter();
expect(clearFilterSpy).toHaveBeenCalledWith(props.categoryId);
})
});
describe('renderFilterComponent', () => {
it('returns an InputFilter w/ correct props if props.type == FilterType.INPUT_SELECT', () => {
const { props, wrapper } = setup({ type: FilterType.INPUT_SELECT });
const content = wrapper.instance().renderFilterComponent();
// @ts-ignore: This check works but TypeScript complains
expect(content.type.displayName).toBe('Connect(InputFilter)');
expect(content.props.categoryId).toBe(props.categoryId);
})
it('returns a CheckBoxFilter w/ correct props if props.type == FilterType.CHECKBOX_SELECT', () => {
const mockOptions = [{ label: 'hive', value: 'Hive' }];
const { props, wrapper } = setup({ type: FilterType.CHECKBOX_SELECT, options: mockOptions });
const content = wrapper.instance().renderFilterComponent();
// @ts-ignore: This check works but TypeScript complains
expect(content.type.displayName).toBe('Connect(CheckBoxFilter)');
expect(content.props.categoryId).toBe(props.categoryId);
expect(content.props.checkboxProperties).toBe(mockOptions);
})
});
describe('render', () => {
let props;
let wrapper;
let renderFilterComponentSpy;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
renderFilterComponentSpy = jest.spyOn(wrapper.instance(), 'renderFilterComponent');
})
it('renders FilterSection title', () => {
expect(wrapper.find('.title-2').text()).toEqual(props.title);
})
it('renders InfoButton with correct props if props.helpText exists', () => {
const mockHelpText = 'Help me';
const wrapper = setup({ helpText: mockHelpText }).wrapper;
const infoButton = wrapper.find(InfoButton);
expect(infoButton.exists()).toBe(true);
expect(infoButton.props().infoText).toBe(mockHelpText)
})
it('renders link to clear category if props.hasValue', () => {
const { props, wrapper } = setup({ hasValue: true });
const clearLink = wrapper.find('a');
expect(clearLink.exists()).toBe(true);
expect(clearLink.props().onClick).toBe(wrapper.instance().onClearFilter);
expect(clearLink.text()).toEqual(CLEAR_BTN_TEXT)
})
it('calls renderFilterComponent()', () => {
renderFilterComponentSpy.mockClear();
wrapper.instance().forceUpdate();
expect(renderFilterComponentSpy).toHaveBeenCalledTimes(1);
})
});
describe('mapStateToProps', () => {
const mockStateWithFilters: GlobalState = {
...globalState,
search: {
...globalState.search,
selectedTab: ResourceType.table,
filters: {
[ResourceType.table]: {
'database': { 'hive': true },
'schema': 'schema_name',
}
}
},
};
const mockStateWithOutFilters: GlobalState = {
...globalState,
search: {
...globalState.search,
selectedTab: ResourceType.user,
filters: {
[ResourceType.table]: {}
}
},
};
let result;
describe('sets hasValue as true', () => {
it('when CHECKBOX_SELECT filter has value', () => {
const props = setup({ categoryId: 'database', type: FilterType.CHECKBOX_SELECT }).props;
result = mapStateToProps(mockStateWithFilters, props);
expect(result.hasValue).toBe(true);
});
it('when INPUT_SELECT filter has value', () => {
const props = setup({ categoryId: 'schema', type: FilterType.INPUT_SELECT }).props;
result = mapStateToProps(mockStateWithFilters, props);
expect(result.hasValue).toBe(true);
});
});
describe('sets hasValue as false', () => {
it('when CHECKBOX_SELECT filter has no value', () => {
const props = setup({ categoryId: 'database', type: FilterType.CHECKBOX_SELECT }).props;
result = mapStateToProps(mockStateWithOutFilters, props);
expect(result.hasValue).toBe(false);
});
it('when INPUT_SELECT filter has no value', () => {
const props = setup({ categoryId: 'schema', type: FilterType.INPUT_SELECT }).props;
result = mapStateToProps(mockStateWithOutFilters, props);
expect(result.hasValue).toBe(false);
});
it('when no filters exist for the given category', () => {
const props = setup({ categoryId: 'fakeCategory' }).props;
result = mapStateToProps(mockStateWithFilters, props);
expect(result.hasValue).toEqual(false);
});
});
});
describe('mapDispatchToProps', () => {
let dispatch;
let result;
beforeAll(() => {
const props = setup().props;
dispatch = jest.fn(() => Promise.resolve());
result = mapDispatchToProps(dispatch);
});
it('sets clearFilterByCategory on the props', () => {
expect(result.clearFilterByCategory).toBeInstanceOf(Function);
});
});
});
import * as React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { clearFilterByCategory, updateFilterByCategory, ClearFilterRequest, UpdateFilterRequest } from 'ducks/search/filters/reducer';
import { APPLY_BTN_TEXT } from '../constants';
import { GlobalState } from 'ducks/rootReducer';
interface OwnProps {
categoryId: string;
}
interface StateFromProps {
value: string;
}
interface DispatchFromProps {
clearFilterByCategory: (categoryId: string) => ClearFilterRequest;
updateFilterByCategory: (categoryId: string, value: string) => UpdateFilterRequest;
}
export type InputFilterProps = StateFromProps & DispatchFromProps & OwnProps;
export interface InputFilterState {
value: string;
}
export class InputFilter extends React.Component<InputFilterProps, InputFilterState> {
constructor(props) {
super(props);
this.state = {
value: props.value,
};
}
componentDidUpdate = (prevProps: StateFromProps) => {
const newValue = this.props.value;
if (prevProps.value !== newValue) {
this.setState({ value: newValue || '' });
}
};
onApplyChanges = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if(!!this.state.value) {
this.props.updateFilterByCategory(this.props.categoryId, this.state.value);
}
else {
this.props.clearFilterByCategory(this.props.categoryId);
}
};
onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ value: e.target.value })
};
render = () => {
const { categoryId } = this.props;
return (
<form className="input-section-content form-group" onSubmit={ this.onApplyChanges }>
<input
type="text"
className="form-control"
name={ categoryId }
onChange={ this.onInputChange }
value={ this.state.value }
/>
<button
name={ categoryId }
className="btn btn-default"
type="submit"
>
{ APPLY_BTN_TEXT }
</button>
</form>
);
}
};
export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
const filterState = state.search.filters;
const value = filterState[state.search.selectedTab] ? filterState[state.search.selectedTab][ownProps.categoryId] : '';
return {
value: value || '',
}
};
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({
clearFilterByCategory,
updateFilterByCategory,
}, dispatch);
};
export default connect<StateFromProps, DispatchFromProps, OwnProps>(mapStateToProps, mapDispatchToProps)(InputFilter);
import * as React from 'react';
import { shallow } from 'enzyme';
import { InputFilter, InputFilterProps, mapDispatchToProps, mapStateToProps } from '../';
import { APPLY_BTN_TEXT } from '../../constants';
import { GlobalState } from 'ducks/rootReducer';
import { clearFilterByCategory, updateFilterByCategory } from 'ducks/search/filters/reducer';
import globalState from 'fixtures/globalState';
import { FilterType, ResourceType } from 'interfaces';
describe('InputFilter', () => {
const setStateSpy = jest.spyOn(InputFilter.prototype, 'setState');
const setup = (propOverrides?: Partial<InputFilterProps>) => {
const props: InputFilterProps = {
categoryId: 'schema',
value: 'schema_name',
clearFilterByCategory: jest.fn(),
updateFilterByCategory: jest.fn(),
...propOverrides
};
const wrapper = shallow<InputFilter>(<InputFilter {...props} />);
return { props, wrapper };
};
describe('constructor', () => {
const testValue = 'test';
let wrapper;
beforeAll(() => {
wrapper = setup({ value: testValue }).wrapper;
});
it('sets the value state from props', () => {
expect(wrapper.state().value).toEqual(testValue);
});
});
describe('componentDidUpdate', () => {
let props;
let wrapper;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
});
it('sets the value state to props.value if the property has changed', () => {
setStateSpy.mockClear()
const newProps = {
...props,
value: 'Some new value',
};
wrapper.setProps(newProps);
expect(setStateSpy).toHaveBeenCalledWith({ value: newProps.value });
});
it('sets the value state to empty string if the property has change and is not truthy', () => {
setStateSpy.mockClear()
const newProps = {
...props,
value: '',
};
wrapper.setProps(newProps);
expect(setStateSpy).toHaveBeenCalledWith({ value: '' });
});
it('does not call set state if props.value has not changed', () => {
wrapper.setProps(props);
setStateSpy.mockClear();
wrapper.setProps(props);
expect(setStateSpy).not.toHaveBeenCalled();
});
});
describe('onApplyChanges', () => {
let props;
let wrapper;
let clearCategorySpy;
let updateCategorySpy;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
clearCategorySpy = jest.spyOn(props, 'clearFilterByCategory');
updateCategorySpy = jest.spyOn(props, 'updateFilterByCategory');
});
it('calls props.clearFilterByCategory if state.value is falsy', () => {
clearCategorySpy.mockClear();
wrapper.setState({ value: '' });
wrapper.instance().onApplyChanges({ preventDefault: jest.fn() });
expect(clearCategorySpy).toHaveBeenCalledWith(props.categoryId);
});
it('calls props.updateFilterByCategory if state.value has a truthy value', () => {
updateCategorySpy.mockClear();
const mockValue = 'hello';
wrapper.setState({ value: mockValue });
wrapper.instance().onApplyChanges({ preventDefault: jest.fn() });
expect(updateCategorySpy).toHaveBeenCalledWith(props.categoryId, mockValue)
});
});
describe('onInputChange', () => {
let props;
let wrapper;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
});
it('sets the value state to e.target.value', () => {
setStateSpy.mockClear()
const mockValue = 'mockValue';
const mockEvent = { target: { value: mockValue }};
wrapper.instance().onInputChange(mockEvent)
expect(setStateSpy).toHaveBeenCalledWith({ value: mockValue });
});
});
describe('render', () => {
let props;
let wrapper;
let element;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
wrapper.instance().render();
})
it('renders a form with correct onSubmit property', () => {
element = wrapper.find('form');
expect(element.props().onSubmit).toBe(wrapper.instance().onApplyChanges);
});
it('renders and input text with correct properties', () => {
element = wrapper.find('input');
expect(element.props().name).toBe(props.categoryId);
expect(element.props().onChange).toBe(wrapper.instance().onInputChange);
expect(element.props().value).toBe(wrapper.state().value);
});
it('renders a button with correct properties', () => {
element = wrapper.find('button');
expect(element.props().name).toBe(props.categoryId);
expect(element.text()).toEqual(APPLY_BTN_TEXT);
});
});
describe('mapStateToProps', () => {
const mockCategoryId = 'schema';
const props = setup({ categoryId: mockCategoryId }).props;
const mockFilters = 'schema_name';
const mockStateWithFilters: GlobalState = {
...globalState,
search: {
...globalState.search,
selectedTab: ResourceType.table,
filters: {
[ResourceType.table]: {
[mockCategoryId]: mockFilters
}
}
},
};
const mockStateWithOutFilters: GlobalState = {
...globalState,
search: {
...globalState.search,
selectedTab: ResourceType.user,
filters: {
[ResourceType.table]: {}
}
},
};
let result;
beforeEach(() => {
result = mapStateToProps(mockStateWithFilters, props);
});
it('sets value on the props with the filter value for the categoryId', () => {
expect(result.value).toBe(mockFilters);
});
it('sets value to empty string if no filters exist for the given resource', () => {
result = mapStateToProps(mockStateWithOutFilters, props);
expect(result.value).toEqual('');
});
it('sets value to empty string if no filters exist for the given category', () => {
const props = setup({ categoryId: 'fakeCategory' }).props;
result = mapStateToProps(mockStateWithFilters, props);
expect(result.value).toEqual('');
});
});
describe('mapDispatchToProps', () => {
let dispatch;
let result;
beforeAll(() => {
const props = setup().props;
dispatch = jest.fn(() => Promise.resolve());
result = mapDispatchToProps(dispatch);
});
it('sets clearFilterByCategory on the props', () => {
expect(result.clearFilterByCategory).toBeInstanceOf(Function);
});
it('sets updateFilterByCategory on the props', () => {
expect(result.updateFilterByCategory).toBeInstanceOf(Function);
});
});
});
export const APPLY_BTN_TEXT = 'Apply';
export const CLEAR_BTN_TEXT = 'Clear';
import * as React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { GlobalState } from 'ducks/rootReducer';
import CheckBoxItem from 'components/common/Inputs/CheckBoxItem';
import { CheckboxFilterProperties } from './CheckBoxFilter';
import FilterSection from './FilterSection';
import './styles.scss'
import { getFilterConfigByResource } from 'config/config-utils';
interface SearchFilterInput {
value: string;
labelText: string;
checked: boolean;
count: number;
}
import { FilterType, ResourceType } from 'interfaces';
interface SearchFilterSection {
title: string;
categoryId: string;
inputProperties: SearchFilterInput[];
}
import './styles.scss'
/*
TODO: Change on what becomes necessary for implementation
*/
export interface StateFromProps {
checkBoxSections: SearchFilterSection[];
export interface FilterSection {
categoryId: string;
helpText?: string;
title: string;
type: FilterType;
}
/*
TODO: Delete if not necessary for final implementation
*/
export interface OwnProps {
export interface CheckboxFilterSection extends FilterSection {
options: CheckboxFilterProperties[];
}
/*
TODO: onFilterChange dispatched action to update filters. Consider:
1. Payload could contain categoryId and valueId and what to do with it
e.g. - {categoryId: 'datasets', value: 'hive', checked: false }
2. Disable component until implementing for user friendly debouncing
3. On success - Re-enable, checkedUI should update based on new state
4. On failure - Re-enable, state will not have been updated on failure,
checkedUI stays the same.
*/
export interface DispatchFromProps {
onFilterChange: () => any;
export interface StateFromProps {
filterSections: FilterSection[];
}
export type SearchFilterProps = StateFromProps & OwnProps & DispatchFromProps;
export type SearchFilterProps = StateFromProps;
export class SearchFilter extends React.Component<SearchFilterProps> {
constructor(props) {
super(props);
}
createCheckBoxItem = (item: SearchFilterInput, categoryId: string, key: string) => {
const dummyMethod = () => { console.log('Dispatched') };
const { checked, count, labelText, value } = item;
createFilterSection = (key: string, section: FilterSection | CheckboxFilterSection) => {
const { categoryId, helpText, title, type } = section;
const options = (section as CheckboxFilterSection).options ? (section as CheckboxFilterSection).options : undefined;
return (
<CheckBoxItem
<FilterSection
key={key}
checked={ checked }
disabled={ count === 0 }
name={ categoryId }
value={ value }
onChange={ dummyMethod }>
<span className="subtitle-2">{ labelText }</span>
<span className="body-secondary-3 pull-right">{ count }</span>
</CheckBoxItem>
);
categoryId={ categoryId }
helpText={ helpText }
title={ title }
type={ type }
options={ options }
/>
)
};
createCheckBoxSection = (section: SearchFilterSection, key: string) => {
const { categoryId, inputProperties, title } = section;
return (
<div key={key} className="search-filter-section">
<div className="title-2">{ title }</div>
{ inputProperties.map((item, index) => this.createCheckBoxItem(item, categoryId, `item:${categoryId}:${index}`)) }
</div>
);
renderFilterSections = () => {
return this.props.filterSections.map((section) => this.createFilterSection(`section:${section.categoryId}`, section));
};
render = () => {
return this.props.checkBoxSections.map((section, index) => this.createCheckBoxSection(section, `section:${index}`));
return (
<>
{ this.renderFilterSections() }
</>
)
};
};
/*
TODO: Process the global state however needed to get the necessary props
The dummy checkBoxSections property shape below is not expected to mirror how we store
filters and results in the globalState. Rather let checkBoxSections be the data shape
that works best for the component and mapStateToProps wiill translate globalState -> checkBoxSections.
*/
export const mapStateToProps = (state: GlobalState) => {
return {
checkBoxSections: [
{
title: 'Type', // category.displayName
categoryId: 'datasets', // category.id
inputProperties: [
{
value: 'bigquery', // value.id
labelText: 'BigQuery', // value.displayName
checked: true, // pull value or infer value from state
count: 100, // pull value from state
},
{
value: 'hive', // value.id
labelText: 'Hive', // value.displayName
checked: true, // pull value or infer value from state
count: 100, // pull value from state
},
{
value: 'druid', // value.id
labelText: 'Druid', // value.displayName
checked: true, // pull value or infer value from state
count: 0, // pull value from state
},
{
value: 's3', // value.id
labelText: 'S3 Buckets', // value.displayName
checked: false, // pull value or infer value from state
count: 100, // pull value from state
}
]
},
{
title: 'Badges', // category.displayName
categoryId: 'badges', // category.id
inputProperties: [
{
value: 'sla', // value.id
labelText: 'Missed SLA', // value.displayName
checked: true, // pull value or infer value from state
count: 3, // pull value from state
},
{
value: 'quality', // value.id
labelText: 'High Quality', // value.displayName
checked: true, // pull value or infer value from state
count: 12, // pull value from state
},
{
value: 'pii', // value.id
labelText: 'PII', // value.displayName
checked: false, // pull value or infer value from state
count: 34, // pull value from state
},
{
value: 'deprecated', // value.id
labelText: 'Deprecated', // value.displayName
checked: false, // pull value or infer value from state
count: 3, // pull value from state
}
]
const resourceType = state.search.selectedTab;
const filterCategories = getFilterConfigByResource(resourceType);
const filterSections = [];
if (filterCategories) {
filterCategories.forEach((categoryConfig) => {
const section = {
categoryId: categoryConfig.categoryId,
helpText: categoryConfig.helpText,
title: categoryConfig.displayName,
type: categoryConfig.type,
}
]
};
};
if (categoryConfig.type === FilterType.CHECKBOX_SELECT) {
section['options'] = categoryConfig.options.map((option) => {
return { value: option.value, label: option.displayName }
});
}
filterSections.push(section);
});
}
/*
TODO: Dispatch a real action
*/
export const mapDispatchToProps = (dispatch: any) => {
// return bindActionCreators({ onFilterChange } , dispatch);
return {
filterSections,
};
};
export default connect<StateFromProps, DispatchFromProps, OwnProps>(mapStateToProps)(SearchFilter);
export default connect<StateFromProps>(mapStateToProps, null)(SearchFilter);
@import 'variables';
.search-filter-section {
&:not(:first-child) {
margin-top: 24px;
margin-top: $spacer-3;
}
.search-filter-section-header {
display: flex;
.search-filter-section-title {
display: flex;
flex-grow: 1;
button {
margin: auto 4px;
}
}
}
.input-section-content {
display: flex;
button,
input[type=text] {
margin-top: $spacer-1;
}
button {
margin-left: $spacer-1;
}
input {
min-width: 0;
}
}
}
@import 'variables';
.search-control-panel {
border-right: 4px solid $stroke;
flex: 0 0 270px;
border-right: $search-panel-border-width solid $stroke;
flex: 0 0 $search-panel-width;
display: flex;
flex-direction: column;
.section {
padding: 32px 24px 32px 32px;
width: $search-panel-width - $search-panel-border-width;
&:not(:first-child) {
border-top: 2px solid $stroke;
}
}
.checkbox,
.radio {
margin-top: 16px;
margin-bottom: 16px;
.radio-label {
display: block;
}
margin-top: $spacer-1;
margin-bottom: $spacer-1;
}
}
import { ResourceType } from 'interfaces/Resources';
import { getDisplayNameByResource } from 'config/config-utils';
export const PAGINATION_PAGE_RANGE = 10;
export const RESULTS_PER_PAGE = 10;
......@@ -6,10 +9,12 @@ export const DOCUMENT_TITLE_SUFFIX = ' - Amundsen Search';
export const PAGE_INDEX_ERROR_MESSAGE = 'Page index out of bounds for available matches';
export const SEARCH_DEFAULT_MESSAGE = 'Your search results will be shown here.\n\
Try entering a search term or using any of the filters to the left.';
export const SEARCH_SOURCE_NAME = 'search_results';
export const SEARCH_ERROR_MESSAGE_INFIX = ' - did not match any ';
export const SEARCH_ERROR_MESSAGE_PREFIX = 'Your search - ';
export const SEARCH_ERROR_MESSAGE_PREFIX = 'Your search did not match any ';
export const SEARCH_ERROR_MESSAGE_SUFFIX = ' results';
export const TABLE_RESOURCE_TITLE = 'Datasets';
export const USER_RESOURCE_TITLE = 'People';
export const TABLE_RESOURCE_TITLE = getDisplayNameByResource(ResourceType.table);
export const USER_RESOURCE_TITLE = getDisplayNameByResource(ResourceType.user);
......@@ -8,6 +8,7 @@ import { Search as UrlSearch } from 'history';
import LoadingSpinner from 'components/common/LoadingSpinner';
import ResourceList from 'components/common/ResourceList';
import ResourceSelector from './ResourceSelector';
import SearchFilter from './SearchFilter';
import SearchPanel from './SearchPanel';
import { GlobalState } from 'ducks/rootReducer';
......@@ -30,7 +31,7 @@ import {
DOCUMENT_TITLE_SUFFIX,
PAGE_INDEX_ERROR_MESSAGE,
RESULTS_PER_PAGE,
SEARCH_ERROR_MESSAGE_INFIX,
SEARCH_DEFAULT_MESSAGE,
SEARCH_ERROR_MESSAGE_PREFIX,
SEARCH_ERROR_MESSAGE_SUFFIX,
SEARCH_SOURCE_NAME,
......@@ -40,6 +41,7 @@ import {
export interface StateFromProps {
hasFilters: boolean;
searchTerm: string;
selectedTab: ResourceType;
isLoading: boolean;
......@@ -97,18 +99,28 @@ export class SearchPage extends React.Component<SearchPageProps> {
};
getTabContent = (results: SearchResults<Resource>, tab: ResourceType) => {
const { searchTerm } = this.props;
const { hasFilters, searchTerm } = this.props;
const { page_index, total_results } = results;
const startIndex = (RESULTS_PER_PAGE * page_index) + 1;
const tabLabel = this.generateTabLabel(tab);
// TODO - Move error messages into Tab Component
// No search input
if (searchTerm.length === 0 && !hasFilters) {
return (
<div className="search-list-container">
<div className="search-error body-placeholder">
{SEARCH_DEFAULT_MESSAGE}
</div>
</div>
)
}
// Check no results
if (total_results === 0 && searchTerm.length > 0) {
if (total_results === 0 && (searchTerm.length > 0 || hasFilters)) {
return (
<div className="search-list-container">
<div className="search-error body-placeholder">
{SEARCH_ERROR_MESSAGE_PREFIX}<i>{ searchTerm }</i>{SEARCH_ERROR_MESSAGE_INFIX}{tabLabel.toLowerCase()}{SEARCH_ERROR_MESSAGE_SUFFIX}
{SEARCH_ERROR_MESSAGE_PREFIX}<i>{tabLabel.toLowerCase()}</i>{SEARCH_ERROR_MESSAGE_SUFFIX}
</div>
</div>
)
......@@ -152,6 +164,7 @@ export class SearchPage extends React.Component<SearchPageProps> {
<div className="search-page">
<SearchPanel>
<ResourceSelector/>
<SearchFilter />
</SearchPanel>
<div className="search-results">
{ this.renderContent() }
......@@ -170,7 +183,9 @@ export class SearchPage extends React.Component<SearchPageProps> {
}
export const mapStateToProps = (state: GlobalState) => {
const resourceFilters = state.search.filters[state.search.selectedTab];
return {
hasFilters: resourceFilters && Object.keys(resourceFilters).length > 0,
searchTerm: state.search.search_term,
selectedTab: state.search.selectedTab,
isLoading: state.search.isLoading,
......
......@@ -10,7 +10,7 @@ import {
DOCUMENT_TITLE_SUFFIX,
PAGE_INDEX_ERROR_MESSAGE,
RESULTS_PER_PAGE,
SEARCH_ERROR_MESSAGE_INFIX,
SEARCH_DEFAULT_MESSAGE,
SEARCH_ERROR_MESSAGE_PREFIX,
SEARCH_ERROR_MESSAGE_SUFFIX,
SEARCH_SOURCE_NAME,
......@@ -19,10 +19,13 @@ import {
} from '../constants';
import LoadingSpinner from 'components/common/LoadingSpinner';
import ResourceSelector from 'components/SearchPage/ResourceSelector';
import SearchFilter from 'components/SearchPage/SearchFilter';
import SearchPanel from 'components/SearchPage/SearchPanel';
import ResourceList from 'components/common/ResourceList';
import globalState from 'fixtures/globalState';
import { defaultEmptyFilters, datasetFilterExample } from 'fixtures/search/filters';
import { getMockRouterProps } from 'fixtures/mockRouter';
describe('SearchPage', () => {
......@@ -30,6 +33,7 @@ describe('SearchPage', () => {
const setup = (propOverrides?: Partial<SearchPageProps>, location?: Partial<History.Location>) => {
const routerProps = getMockRouterProps<any>(null, location);
const props: SearchPageProps = {
hasFilters: false,
searchTerm: globalState.search.search_term,
selectedTab: ResourceType.table,
isLoading: false,
......@@ -123,18 +127,38 @@ describe('SearchPage', () => {
describe('getTabContent', () => {
let content;
describe('if no search input (no term or filters)', () => {
it('renders default search page message', () => {
const { props, wrapper } = setup({ searchTerm: '', hasFilters: false });
content = shallow(wrapper.instance().getTabContent({
page_index: 0,
results: [],
total_results: 0,
}, ResourceType.table));
expect(content.children().at(0).text()).toEqual(SEARCH_DEFAULT_MESSAGE);
});
});
describe('if searchTerm but no results', () => {
it('renders expected search error message', () => {
const searchTerm = 'data';
const { props, wrapper } = setup({ searchTerm });
const testResults = {
describe('if no search results, renders expected search error message', () => {
let testResults;
beforeAll(() => {
testResults = {
page_index: 0,
results: [],
total_results: 0,
};
})
it('if there is a searchTerm ', () => {
const { props, wrapper } = setup({ searchTerm: 'data' });
content = shallow(wrapper.instance().getTabContent(testResults, ResourceType.table));
const message = `${SEARCH_ERROR_MESSAGE_PREFIX}${searchTerm}${SEARCH_ERROR_MESSAGE_INFIX}${TABLE_RESOURCE_TITLE.toLowerCase()}${SEARCH_ERROR_MESSAGE_SUFFIX}`;
const message = `${SEARCH_ERROR_MESSAGE_PREFIX}${TABLE_RESOURCE_TITLE.toLowerCase()}${SEARCH_ERROR_MESSAGE_SUFFIX}`;
expect(content.children().at(0).text()).toEqual(message);
});
it('if no searchTerm but there are filters active', () => {
const { props, wrapper } = setup({ searchTerm: '', hasFilters: true });
content = shallow(wrapper.instance().getTabContent(testResults, ResourceType.table));
const message = `${SEARCH_ERROR_MESSAGE_PREFIX}${TABLE_RESOURCE_TITLE.toLowerCase()}${SEARCH_ERROR_MESSAGE_SUFFIX}`;
expect(content.children().at(0).text()).toEqual(message);
});
});
......@@ -258,10 +282,27 @@ describe('SearchPage', () => {
});
});
it('renders a search panel', () => {
const {props, wrapper} = setup();
expect(wrapper.find(SearchPanel).exists()).toBe(true);
});
describe('renders a SearchPanel', () => {
let props;
let wrapper;
let searchPanel;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
searchPanel = wrapper.find(SearchPanel);
})
it('renders a search panel', () => {
expect(searchPanel.exists()).toBe(true);
});
it('renders ResourceSelector as SearchPanel child', () => {
expect(searchPanel.find(ResourceSelector).exists()).toBe(true);
});
it('renders SearchFilter as SearchPanel child', () => {
expect(searchPanel.find(SearchFilter).exists()).toBe(true);
});
})
});
describe('mapDispatchToProps', () => {
......@@ -314,4 +355,26 @@ describe('mapStateToProps', () => {
it('sets dashboards on the props', () => {
expect(result.dashboards).toEqual(globalState.search.dashboards);
});
describe('sets hasFilters on the props', () => {
it('sets fo falsy value if selected resource has no filters', () => {
const testState = {
...globalState
};
testState.search.selectedTab = ResourceType.user;
testState.search.filters = defaultEmptyFilters;
result = mapStateToProps(testState);
expect(result.hasFilters).toBeFalsy();
})
it('sets true if selected resource has filters', () => {
const testState = {
...globalState
};
testState.search.selectedTab = ResourceType.table;
testState.search.filters = datasetFilterExample;
result = mapStateToProps(testState);
expect(result.hasFilters).toBe(true);
})
});
});
......@@ -142,11 +142,25 @@ export class RequestMetadataForm extends React.Component<RequestMetadataProps, R
<form onSubmit={ this.submitNotification } id="RequestForm">
<div id="sender-form-group" className="form-group">
<label>{FROM_LABEL}</label>
<input type="email" name="sender" className="form-control" required={true} value={userEmail} readOnly={true}/>
<input
type="email"
autoComplete="off"
name="sender"
className="form-control"
required={true}
value={userEmail}
readOnly={true} />
</div>
<div id="recipients-form-group" className="form-group">
<label>{TO_LABEL}</label>
<input type="text" name="recipients" className="form-control" required={true} multiple={true} defaultValue={tableOwners.join(RECIPIENT_LIST_DELIMETER)}/>
<input
type="text"
autoComplete="off"
name="recipients"
className="form-control"
required={true}
multiple={true}
defaultValue={tableOwners.join(RECIPIENT_LIST_DELIMETER)}/>
</div>
<div id="request-type-form-group" className="form-group">
<label>{REQUEST_TYPE}</label>
......
......@@ -31,7 +31,10 @@ import TagInput from 'components/Tags/TagInput';
import { TableMetadata } from 'interfaces/TableMetadata';
import { EditableSection } from 'components/TableDetail/EditableSection';
import { getDatabaseDisplayName, getDatabaseIconClass, notificationsEnabled, issueTrackingEnabled } from 'config/config-utils';
import { getDisplayNameByResource, getDatabaseDisplayName, getDatabaseIconClass, issueTrackingEnabled, notificationsEnabled } from 'config/config-utils';
import { ResourceType } from 'interfaces/Resources';
import { formatDateTimeShort } from 'utils/dateUtils';
import './styles';
......@@ -140,7 +143,7 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps
<BookmarkIcon bookmarkKey={ this.props.tableData.key }/>
<div className="body-2">
<ul className="header-bullets">
<li>Datasets</li>
<li>{ getDisplayNameByResource(ResourceType.table) }</li>
<li>{ getDatabaseDisplayName(data.database) }</li>
<li>{ data.cluster }</li>
</ul>
......@@ -177,7 +180,7 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps
/>
</EditableSection>
<span>
{ notificationsEnabled() && <RequestDescriptionText/> }
{ notificationsEnabled() && <RequestDescriptionText/> }
{ issueTrackingEnabled() && <ReportTableIssue tableKey={ this.key } tableName={ this.getDisplayName() } />}
</span>
<section className="column-layout-2">
......
import * as React from 'react';
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux';
import { Tag } from 'interfaces';
import { ResourceType, Tag } from 'interfaces';
import { setSearchInputByResource, SetSearchInputRequest } from 'ducks/search/filters/reducer';
import { logClick } from 'ducks/utilMethods';
import './styles.scss';
import { SubmitSearchRequest } from 'ducks/search/types';
import { submitSearch } from 'ducks/search/reducer';
interface OwnProps {
data: Tag;
......@@ -14,7 +14,7 @@ interface OwnProps {
}
export interface DispatchFromProps {
submitSearch: (searchTerm: string) => SubmitSearchRequest;
searchTag: (tagName: string) => SetSearchInputRequest;
}
export type TagInfoProps = OwnProps & DispatchFromProps;
......@@ -35,7 +35,7 @@ export class TagInfo extends React.Component<TagInfoProps> {
target_type: 'tag',
label: name,
});
this.props.submitSearch(`tag:${name}`);
this.props.searchTag(name);
};
render() {
......@@ -59,7 +59,11 @@ export class TagInfo extends React.Component<TagInfoProps> {
}
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ submitSearch }, dispatch);
return bindActionCreators({
/* Note: Pattern intentionally isolates component from extraneous hardcoded parameters */
/* Note: This will have to be extended to all resources that support tags */
searchTag: (tagName: string) => setSearchInputByResource({ 'tag': tagName }, ResourceType.table, 0, '')
}, dispatch);
};
export default connect<null, DispatchFromProps, OwnProps>(null, mapDispatchToProps)(TagInfo);
......@@ -17,7 +17,7 @@ describe('TagInfo', () => {
tag_count: 45,
},
compact: false,
submitSearch: jest.fn(),
searchTag: jest.fn(),
...propOverrides,
};
const wrapper = shallow<TagInfo>(<TagInfo {...props} />);
......@@ -46,9 +46,9 @@ describe('TagInfo', () => {
expect(logClickSpy).toHaveBeenCalledWith(mockEvent, expectedData)
});
it('it calls submitSearch', () => {
it('it calls searchTag', () => {
wrapper.instance().onClick(mockEvent);
expect(props.submitSearch).toHaveBeenCalledWith(`tag:${props.data.tag_name}`);
expect(props.searchTag).toHaveBeenCalledWith(props.data.tag_name);
});
});
......@@ -108,7 +108,7 @@ describe('mapDispatchToProps', () => {
result = mapDispatchToProps(dispatch);
});
it('sets submitSearch on the props', () => {
expect(result.submitSearch).toBeInstanceOf(Function);
it('sets searchTag on the props', () => {
expect(result.searchTag).toBeInstanceOf(Function);
});
});
......@@ -8,6 +8,7 @@ import { loadPreviousSearch } from 'ducks/search/reducer';
import { LoadPreviousSearchRequest } from 'ducks/search/types';
export interface OwnProps {
direction?: BreadcrumbDirection;
path?: string;
text?: string;
}
......@@ -16,16 +17,25 @@ export interface MapDispatchToProps {
loadPreviousSearch: () => LoadPreviousSearchRequest;
}
type BreadcrumbDirection = "left" | "right";
export type BreadcrumbProps = OwnProps & MapDispatchToProps;
export const Breadcrumb: React.SFC<BreadcrumbProps> = (props) => {
const { path, text } = props;
const { direction = "left", path, text } = props;
if (path !== undefined && text !== undefined) {
return (
<div className="amundsen-breadcrumb">
<Link to={path} className='btn btn-flat-icon title-3'>
<img className='icon icon-left'/>
{
direction === "left" &&
<img className='icon icon-left'/>
}
<span>{text}</span>
{
direction === "right" &&
<img className='icon icon-right'/>
}
</Link>
</div>
);
......@@ -33,7 +43,14 @@ export const Breadcrumb: React.SFC<BreadcrumbProps> = (props) => {
return (
<div className="amundsen-breadcrumb">
<a onClick={ props.loadPreviousSearch } className='btn btn-flat-icon title-3'>
<img className='icon icon-left'/>
{
direction === "left" &&
<img className='icon icon-left'/>
}
{
direction === "right" &&
<img className='icon icon-right'/>
}
</a>
</div>
);
......
......@@ -6,63 +6,77 @@ import { Link } from 'react-router-dom';
import { Breadcrumb, BreadcrumbProps, mapDispatchToProps } from '../';
describe('Breadcrumb', () => {
let props: BreadcrumbProps;
let subject;
const setup = (propOverrides?: Partial<BreadcrumbProps>) => {
const props: BreadcrumbProps = {
loadPreviousSearch: jest.fn(),
...propOverrides
};
const wrapper = shallow(<Breadcrumb {...props} />);
return { props, wrapper };
};
describe('render', () => {
beforeEach(() => {
props = {
path: 'testPath',
text: 'testText',
loadPreviousSearch: jest.fn(),
};
subject = shallow(<Breadcrumb {...props} />);
});
let props: BreadcrumbProps;
let subject;
it('renders Link with correct path', () => {
expect(subject.find(Link).props()).toMatchObject({
to: props.path,
describe('when given path & text', () => {
beforeAll(() => {
const setupResult = setup({path: 'testPath', text: 'testText'});
props = setupResult.props;
subject = setupResult.wrapper;
});
});
it('renders Link with correct text', () => {
expect(subject.find(Link).find('span').text()).toEqual(props.text);
});
});
it('renders Link with given path', () => {
expect(subject.find(Link).props()).toMatchObject({
to: props.path,
});
});
describe('render with existing searchTerm', () => {
beforeEach(() => {
props = {
loadPreviousSearch: jest.fn(),
};
subject = shallow(<Breadcrumb {...props} />);
});
it('renders Link with given text', () => {
expect(subject.find(Link).find('span').text()).toEqual(props.text);
});
it('renders Link with correct path', () => {
expect(subject.find('a').props()).toMatchObject({
onClick: props.loadPreviousSearch,
it('renders left icon by default', () => {
expect(subject.find(Link).find('img').props().className).toEqual('icon icon-left');
});
});
});
describe('render with existing searchTerm and prop overrides', () => {
beforeEach(() => {
props = {
path: 'testPath',
text: 'testText',
loadPreviousSearch: jest.fn(),
};
subject = shallow(<Breadcrumb {...props} />);
});
it('renders left icon when props.direction = "left"', () => {
subject = setup({path: 'testPath', text: 'testText', direction: 'left'}).wrapper;
expect(subject.find(Link).find('img').props().className).toEqual('icon icon-left');
});
it('renders Link with correct path', () => {
expect(subject.find(Link).props()).toMatchObject({
to: 'testPath',
it('renders right icon when props.direction = "right"', () => {
subject = setup({path: 'testPath', text: 'testText', direction: 'right'}).wrapper;
expect(subject.find(Link).find('img').props().className).toEqual('icon icon-right');
});
})
describe('when not given path or text', () => {
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
subject = setupResult.wrapper;
});
it('renders anchor tag with onClick method', () => {
expect(subject.find('a').props()).toMatchObject({
onClick: props.loadPreviousSearch,
});
});
});
it('renders Link with correct text', () => {
expect(subject.find(Link).find('span').text()).toEqual('testText');
it('renders left icon by default', () => {
expect(subject.find('a').find('img').props().className).toEqual('icon icon-left');
});
it('renders left icon when props.direction = "left"', () => {
subject = setup({direction: 'left'}).wrapper;
expect(subject.find('a').find('img').props().className).toEqual('icon icon-left');
});
it('renders right icon when props.direction = "right"', () => {
subject = setup({direction: 'right'}).wrapper;
expect(subject.find('a').find('img').props().className).toEqual('icon icon-right');
});
});
});
......
import * as React from 'react';
import { OverlayTrigger, Popover } from 'react-bootstrap';
// TODO - Consider an alternative to react-sanitized-html (large filesize)
import SanitizedHTML from 'react-sanitized-html';
// TODO: Use css-modules instead of 'import'
import './styles.scss';
......@@ -14,7 +17,7 @@ export interface InfoButtonProps {
const InfoButton: React.SFC<InfoButtonProps> = ({ title, infoText, placement, size }) => {
const popoverHoverFocus = (
<Popover id="popover-trigger-hover-focus" title={ title }>
{ infoText }
<SanitizedHTML html={infoText} />
</Popover>
);
......
import * as React from 'react';
import SanitizedHTML from 'react-sanitized-html';
import { shallow } from 'enzyme';
......@@ -27,7 +28,7 @@ describe('InfoButton', () => {
it('renders OverlayTrigger w/ correct Popover', () => {
const expectedPopover = (
<Popover id="popover-trigger-hover-focus" title={ props.title }>
{ props.infoText }
<SanitizedHTML html={props.infoText} />
</Popover>
);
expect(subject.find(OverlayTrigger).props().overlay).toEqual(expectedPopover);
......
import * as React from 'react';
import { connect } from 'react-redux';
import './styles.scss';
import 'components/common/Inputs/styles.scss';
export interface CheckBoxItemProps {
checked: boolean;
......
.checkbox {
.checkbox,
.radio {
margin-top: 16px;
margin-bottom: 16px;
.checkbox-label {
.checkbox-label,
.radio-label {
display: block;
width: 100%;
input:not([disabled]) {
cursor: pointer;
}
}
}
......@@ -9,7 +9,10 @@ import { ResourceType } from 'interfaces';
import * as CONSTANTS from '../../constants';
jest.mock('config/config-utils', () => ({ indexUsersEnabled: jest.fn() }));
jest.mock('config/config-utils', () => ({
getDisplayNameByResource: jest.fn(),
indexUsersEnabled: jest.fn()
}));
import { indexUsersEnabled } from 'config/config-utils';
jest.mock("react-redux", () => {
......
// TODO: Consolidate fter implementing filtering. The final resourceConfig can include
// displayNames to avoid re-defining constants 'Datasets' & 'People' across components
export const DATASETS = "Datasets";
import { ResourceType } from 'interfaces/Resources';
import { getDisplayNameByResource } from 'config/config-utils';
export const DATASETS = getDisplayNameByResource(ResourceType.table);
export const DATASETS_ITEM_TEXT = `in ${DATASETS}`;
export const PEOPLE = "People";
export const PEOPLE = getDisplayNameByResource(ResourceType.user);
export const PEOPLE_ITEM_TEXT = `in ${PEOPLE}`;
export const PEOPLE_USER_TYPE = "User";
......
......@@ -14,6 +14,7 @@ import { ResourceType, TableResource, UserResource } from 'interfaces';
import * as CONSTANTS from '../constants';
jest.mock('config/config-utils', () => ({
getDisplayNameByResource: jest.fn(),
getDatabaseDisplayName: jest.fn(),
getDatabaseIconClass: jest.fn(),
indexUsersEnabled: jest.fn(),
......
// TODO: Hard-coded text strings should be translatable/customizable
export const ERROR_CLASSNAME = 'error';
export const PLACEHOLDER_DEFAULT = 'search for data resources...';
export const SUBTEXT_DEFAULT = `Search within a category using the pattern with wildcard support 'category:*searchTerm*', e.g. 'schema:*core*'.
Current categories are 'column', 'database', 'schema', 'table', and 'tag'.`;
export const SYNTAX_ERROR_CATEGORY = `Advanced search syntax only supports searching one category. Please remove all extra ':'`;
export const SYNTAX_ERROR_PREFIX = 'Did you mean ';
export const SYNTAX_ERROR_SPACING_SUFFIX = ` ? Please remove the space around the ':'.`;
export const BUTTON_CLOSE_TEXT = 'Close';
export const SIZE_SMALL = 'small';
import * as React from 'react';
import { bindActionCreators } from 'redux'
import { RouteComponentProps } from 'react-router';
import { withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { GlobalState } from 'ducks/rootReducer';
import { submitSearch, getInlineResultsDebounce, selectInlineResult } from 'ducks/search/reducer';
import { SubmitSearchRequest, InlineSearchRequest, InlineSearchSelect } from 'ducks/search/types';
import { clearSearch, submitSearch, getInlineResultsDebounce, selectInlineResult } from 'ducks/search/reducer';
import { ClearSearchRequest, SubmitSearchRequest, InlineSearchRequest, InlineSearchSelect } from 'ducks/search/types';
import { ResourceType } from 'interfaces';
......@@ -14,13 +16,8 @@ import './styles.scss';
import {
BUTTON_CLOSE_TEXT,
ERROR_CLASSNAME,
PLACEHOLDER_DEFAULT,
SIZE_SMALL,
SUBTEXT_DEFAULT,
SYNTAX_ERROR_CATEGORY,
SYNTAX_ERROR_PREFIX,
SYNTAX_ERROR_SPACING_SUFFIX,
SIZE_SMALL
} from './constants';
export interface StateFromProps {
......@@ -28,6 +25,7 @@ export interface StateFromProps {
}
export interface DispatchFromProps {
clearSearch?: () => ClearSearchRequest;
submitSearch: (searchTerm: string) => SubmitSearchRequest;
onInputChange: (term: string) => InlineSearchRequest;
onSelectInlineResult: (resourceType: ResourceType, searchTerm: string, updateUrl: boolean) => InlineSearchSelect;
......@@ -35,17 +33,14 @@ export interface DispatchFromProps {
export interface OwnProps {
placeholder?: string;
subText?: string;
size?: string;
}
export type SearchBarProps = StateFromProps & DispatchFromProps & OwnProps;
export type SearchBarProps = StateFromProps & DispatchFromProps & OwnProps & RouteComponentProps<{}>;
interface SearchBarState {
showTypeAhead: boolean;
subTextClassName: string;
searchTerm: string;
subText: string;
}
export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
......@@ -53,7 +48,6 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
public static defaultProps: Partial<SearchBarProps> = {
placeholder: PLACEHOLDER_DEFAULT,
subText: SUBTEXT_DEFAULT,
size: '',
};
......@@ -63,14 +57,22 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
this.state = {
showTypeAhead: false,
subTextClassName: '',
searchTerm: this.props.searchTerm,
subText: this.props.subText,
};
}
clearSearchTerm = () : void => {
this.setState({ showTypeAhead: false, searchTerm: '' });
/*
This method fires when the searchTerm is empty to re-execute a search.
This should only be applied on the SearchPage route to keep the results
up-to-date as the user refines their search interacting back & forth with
the filter UI & SearchBar
*/
if (this.props.clearSearch) {
this.props.clearSearch();
}
};
componentDidMount = () => {
......@@ -89,18 +91,20 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
handleValueChange = (event: React.SyntheticEvent<HTMLInputElement>) : void => {
const searchTerm = (event.target as HTMLInputElement).value.toLowerCase();
const showTypeAhead = this.shouldShowTypeAhead(searchTerm);
this.setState({ searchTerm, showTypeAhead });
if (showTypeAhead) {
if (searchTerm.length > 0) {
this.props.onInputChange(searchTerm);
this.setState({ searchTerm, showTypeAhead: true });
}
else {
this.clearSearchTerm();
}
};
handleValueSubmit = (event: React.FormEvent<HTMLFormElement>) : void => {
const searchTerm = this.state.searchTerm.trim();
event.preventDefault();
if (this.isFormValid(searchTerm)) {
if (this.isFormValid()) {
this.props.submitSearch(searchTerm);
this.hideTypeAhead();
}
......@@ -110,33 +114,9 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
this.setState({ showTypeAhead: false });
};
isFormValid = (searchTerm: string) : boolean => {
if (searchTerm.length === 0) {
return false;
}
const hasAtMostOneCategory = searchTerm.split(':').length <= 2;
if (!hasAtMostOneCategory) {
this.setState({
subText: SYNTAX_ERROR_CATEGORY,
subTextClassName: ERROR_CLASSNAME,
});
return false;
}
const colonIndex = searchTerm.indexOf(':');
const hasNoSpaceAroundColon = colonIndex < 0 ||
(colonIndex >= 1 && searchTerm.charAt(colonIndex+1) !== " " && searchTerm.charAt(colonIndex-1) !== " ");
if (!hasNoSpaceAroundColon) {
this.setState({
subText: `${SYNTAX_ERROR_PREFIX}'${searchTerm.substring(0,colonIndex).trim()}:${searchTerm.substring(colonIndex+1).trim()}'${SYNTAX_ERROR_SPACING_SUFFIX}`,
subTextClassName: ERROR_CLASSNAME,
});
return false;
}
this.setState({ subText: SUBTEXT_DEFAULT, subTextClassName: "" });
return true;
isFormValid = () : boolean => {
const form = document.getElementById("search-bar-form") as HTMLFormElement;
return form.checkValidity();
};
onSelectInlineResult = (resourceType: ResourceType, updateUrl: boolean = false) : void => {
......@@ -161,13 +141,13 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
render() {
const inputClass = `${this.props.size === SIZE_SMALL ? 'title-2 small' : 'h2 large'} search-bar-input form-control`;
const searchButtonClass = `btn btn-flat-icon search-button ${this.props.size === SIZE_SMALL ? 'small' : 'large'}`;
const subTextClass = `subtext body-secondary-3 ${this.state.subTextClassName}`;
return (
<div id="search-bar" ref={this.refToSelf}>
<form className="search-bar-form" onSubmit={ this.handleValueSubmit }>
<form id="search-bar-form" className="search-bar-form" onSubmit={ this.handleValueSubmit }>
<input
id="search-input"
required={ true }
className={ inputClass }
value={ this.state.searchTerm }
onChange={ this.handleValueChange }
......@@ -193,12 +173,6 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
searchTerm={this.state.searchTerm}
/>
}
{
this.props.size !== SIZE_SMALL &&
<div className={ subTextClass }>
{ this.state.subText }
</div>
}
</div>
);
}
......@@ -210,8 +184,17 @@ export const mapStateToProps = (state: GlobalState) => {
};
};
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ submitSearch, onInputChange: getInlineResultsDebounce, onSelectInlineResult: selectInlineResult }, dispatch);
export const mapDispatchToProps = (dispatch: any, ownProps) => {
/* These values activate behavior only applicable on SearchPage */
const useFilters = ownProps.history.location.pathname === '/search';
const updateStateOnClear = ownProps.history.location.pathname === '/search';
return bindActionCreators({
clearSearch: updateStateOnClear ? clearSearch : null,
submitSearch: (searchTerm: string) => { return submitSearch(searchTerm, useFilters) },
onInputChange: getInlineResultsDebounce,
onSelectInlineResult: selectInlineResult
}, dispatch);
};
export default connect<StateFromProps, DispatchFromProps, OwnProps>(mapStateToProps, mapDispatchToProps)(SearchBar);
export default withRouter(connect<StateFromProps, DispatchFromProps, OwnProps>(mapStateToProps, mapDispatchToProps)(SearchBar));
import { AppConfig } from './config-types';
import { FilterType, ResourceType } from '../interfaces';
const configDefault: AppConfig = {
badges: {},
browse: {
......@@ -46,27 +48,65 @@ const configDefault: AppConfig = {
}
],
resourceConfig: {
datasets: {
'bigquery': {
displayName: 'BigQuery',
iconClass: 'icon-bigquery',
},
'hive': {
displayName: 'Hive',
iconClass: 'icon-hive',
},
'presto': {
displayName: 'Presto',
iconClass: 'icon-presto',
},
'postgres': {
displayName: 'Postgres',
iconClass: 'icon-postgres',
},
'redshift': {
displayName: 'Redshift',
iconClass: 'icon-redshift',
[ResourceType.table]: {
displayName: 'Datasets',
supportedDatabases: {
'bigquery': {
displayName: 'BigQuery',
iconClass: 'icon-bigquery',
},
'hive': {
displayName: 'Hive',
iconClass: 'icon-hive',
},
'presto': {
displayName: 'Presto',
iconClass: 'icon-presto',
},
'postgres': {
displayName: 'Postgres',
iconClass: 'icon-postgres',
},
'redshift': {
displayName: 'Redshift',
iconClass: 'icon-redshift',
},
},
filterCategories: [
{
categoryId: 'database',
displayName: 'Source',
helpText: 'Enter exact database name or a regex wildcard pattern',
type: FilterType.INPUT_SELECT,
},
{
categoryId: 'column',
displayName: 'Column',
helpText: 'Enter exact column name or a regex wildcard pattern',
type: FilterType.INPUT_SELECT,
},
{
categoryId: 'schema',
displayName: 'Schema',
helpText: 'Enter exact schema name or a regex wildcard pattern',
type: FilterType.INPUT_SELECT,
},
{
categoryId: 'table',
displayName: 'Table',
helpText: 'Enter exact table name or a regex wildcard pattern',
type: FilterType.INPUT_SELECT,
},
{
categoryId: 'tag',
displayName: 'Tag',
helpText: 'Enter exact tag name or a regex wildcard pattern',
type: FilterType.INPUT_SELECT,
},
]
},
[ResourceType.user]: {
displayName: 'People'
},
},
tableLineage: {
......
import { FilterType, ResourceType } from '../interfaces';
/**
* AppConfig and AppConfigCustom should share the same definition, except each field in AppConfigCustom
* is optional. If you choose to override one of the configs, you must provide the full type definition
......@@ -31,6 +33,7 @@ export interface AppConfigCustom {
logoPath?: string;
mailClientFeatures?: MailClientFeaturesConfig;
navLinks?: Array<LinkConfig>;
resourceConfig?: ResourceConfig;
tableLineage?: TableLineageConfig;
tableProfile?: TableProfileConfig;
}
......@@ -58,6 +61,72 @@ interface BrowseConfig {
showAllTags: boolean;
}
/**
* The data shape of MultiSelectFilterCategory.options
*
* displaName - The display name of the multi-select filter option
* value - The value the option represents
*/
interface MultiSelectFilterOptions {
displayName?: string;
value: string;
}
/**
* Base interface for all possible FilterConfig objects
*
* categoryId - The filter category that this config represents, e.g. 'database' or 'badges'
* displayName - The displayName for the filter category
* helpText - An option string of text that will render in the filter UI for the filter category
* type - The FilterType for this filter category
*/
interface BaseFilterCategory {
categoryId: string;
displayName: string;
helpText?: string;
type: FilterType;
}
/**
* Interface for filter categories which allow multiple values to be selected by the user
*/
interface MultiSelectFilterCategory extends BaseFilterCategory {
type: FilterType.CHECKBOX_SELECT;
options: MultiSelectFilterOptions[];
}
/**
* Interface for filter categories which allow only one value to be entered by the user
*/
interface SingleFilterCategory extends BaseFilterCategory {
type: FilterType.INPUT_SELECT;
}
/**
* Configures filter categories for each resource
*/
export type FilterConfig = (MultiSelectFilterCategory|SingleFilterCategory)[];
/**
* Base interface for all possible ResourceConfig objects
*
* displayName - The name displayed throughout the application to refer to this resource type
* filterCategories - Optional configuration for any filters that can be applied to this resource
*/
interface BaseResourceConfig {
displayName: string;
filterCategories?: FilterConfig;
}
/**
* Interface for table resource types
*/
interface TableResourceConfig extends BaseResourceConfig {
supportedDatabases: {
[id: string]: DatabaseConfig
};
}
export enum BadgeStyle {
DANGER = "danger",
DEFAULT = "default",
......@@ -94,20 +163,21 @@ interface DateFormatConfig {
/** ResourceConfig - For customizing values related to how various resources
* are displayed in the UI.
*
* datasets - A map of each dataset id to an optional display name or icon class
* A map of each resource type to its configuration
*/
interface ResourceConfig {
datasets: { [id: string]: DatasetConfig }
[ResourceType.table]: TableResourceConfig;
[ResourceType.user]: BaseResourceConfig;
}
/** DatasetConfig - For customizing values related to how each dataset resource
/** DatabaseConfig - For customizing values related to how each database resource
* is displayed in the UI.
*
* displayName - An optional display name for this dataset source
* iconClass - An option icon class to be used for this dataset source. This
* displayName - An optional display name for this database source
* iconClass - An option icon class to be used for this database source. This
* value should be defined in static/css/_icons.scss
*/
interface DatasetConfig {
interface DatabaseConfig {
displayName?: string;
iconClass?: string;
}
......
......@@ -2,6 +2,10 @@ import AppConfig from 'config/config';
import { BadgeStyleConfig, BadgeStyle } from 'config/config-types';
import { TableMetadata } from 'interfaces/TableMetadata';
import { FilterConfig } from './config-types';
import { ResourceType } from '../interfaces';
export const DEFAULT_DATABASE_ICON_CLASS = 'icon-database icon-color';
/**
......@@ -10,7 +14,7 @@ export const DEFAULT_DATABASE_ICON_CLASS = 'icon-database icon-color';
* is returned.
*/
export function getDatabaseDisplayName(databaseId: string): string {
const databaseConfig = AppConfig.resourceConfig.datasets[databaseId];
const databaseConfig = AppConfig.resourceConfig[ResourceType.table].supportedDatabases[databaseId];
if (!databaseConfig || !databaseConfig.displayName) {
return databaseId;
}
......@@ -25,7 +29,7 @@ export function getDatabaseDisplayName(databaseId: string): string {
* database icon class is returned.
*/
export function getDatabaseIconClass(databaseId: string): string {
const databaseConfig = AppConfig.resourceConfig.datasets[databaseId];
const databaseConfig = AppConfig.resourceConfig[ResourceType.table].supportedDatabases[databaseId];
if (!databaseConfig || !databaseConfig.iconClass) {
return DEFAULT_DATABASE_ICON_CLASS;
}
......@@ -34,6 +38,20 @@ export function getDatabaseIconClass(databaseId: string): string {
}
/**
* Returns the displayName for the given resourceType
*/
export function getDisplayNameByResource(resourceType: ResourceType): string {
return AppConfig.resourceConfig[resourceType].displayName;
};
/**
* Returns the filterCategories for the given resourceType
*/
export function getFilterConfigByResource(resourceType: ResourceType): FilterConfig {
return AppConfig.resourceConfig[resourceType].filterCategories;
};
/*
* Given a badge name, this will return a badge style and a display name.
* If these are not specified by config, it will default to some simple rules:
* use BadgeStyle.DEFAULT and replace '-' and '_' with spaces for display name.
......
......@@ -2,6 +2,8 @@ import AppConfig from 'config/config';
import * as ConfigUtils from 'config/config-utils';
import { BadgeStyle } from 'config/config-types';
import { ResourceType } from 'interfaces';
describe('getDatabaseDisplayName', () => {
it('returns given id if no config for that id exists', () => {
const testId = 'fakeName';
......@@ -10,7 +12,7 @@ describe('getDatabaseDisplayName', () => {
it('returns given id for a configured database id', () => {
const testId = 'hive';
const expectedName = AppConfig.resourceConfig.datasets[testId].displayName;
const expectedName = AppConfig.resourceConfig[ResourceType.table].supportedDatabases[testId].displayName;
expect(ConfigUtils.getDatabaseDisplayName(testId)).toBe(expectedName);
})
});
......@@ -23,11 +25,27 @@ describe('getDatabaseIconClass', () => {
it('returns given icon class for a configured database id', () => {
const testId = 'hive';
const expectedClass = AppConfig.resourceConfig.datasets[testId].iconClass;
const expectedClass = AppConfig.resourceConfig[ResourceType.table].supportedDatabases[testId].iconClass;
expect(ConfigUtils.getDatabaseIconClass(testId)).toBe(expectedClass);
})
});
describe('getDisplayNameByResource', () => {
it('returns the displayName for a given resource', () => {
const testResource = ResourceType.table;
const expectedValue = AppConfig.resourceConfig[testResource].displayName;
expect(ConfigUtils.getDisplayNameByResource(testResource)).toBe(expectedValue);
});
});
describe('getFilterConfigByResource', () => {
it('returns the filter categories for a given resource', () => {
const testResource = ResourceType.table;
const expectedValue = AppConfig.resourceConfig[testResource].filterCategories;
expect(ConfigUtils.getFilterConfigByResource(testResource)).toBe(expectedValue);
});
});
describe('getBadgeConfig', () => {
AppConfig.badges = {
'test_1': {
......
......@@ -23,6 +23,9 @@ import { createIssueWatcher, getIssuesWatcher } from './issue/sagas';
import { getPopularTablesWatcher } from './popularTables/sagas';
// Search
import {
clearSearchWatcher,
filterWatcher,
filterWatcher2,
inlineSearchWatcher,
inlineSearchWatcherDebounce,
loadPreviousSearchWatcher,
......@@ -71,6 +74,9 @@ export default function* rootSaga() {
getIssuesWatcher(),
createIssueWatcher(),
// Search
clearSearchWatcher(),
filterWatcher(),
filterWatcher2(),
inlineSearchWatcher(),
inlineSearchWatcherDebounce(),
loadPreviousSearchWatcher(),
......
import axios, { AxiosResponse } from 'axios';
import AppConfig from 'config/config';
import { DashboardSearchResults, TableSearchResults, UserSearchResults } from 'ducks/search/types';
import globalState from 'fixtures/globalState';
......@@ -12,13 +10,15 @@ import * as API from '../v0';
jest.mock('axios');
import * as ConfigUtils from 'config/config-utils';
describe('searchResource', () => {
let axiosMockGet;
let mockTableResponse: AxiosResponse<API.SearchAPI>;
let axiosMockPost;
let userEnabledMock;
let mockSearchResponse: AxiosResponse<API.SearchAPI>;
beforeAll(() => {
mockTableResponse = {
mockSearchResponse = {
data: {
msg: 'Success',
status_code: 200,
......@@ -31,57 +31,92 @@ describe('searchResource', () => {
headers: {},
config: {},
};
axiosMockGet = jest.spyOn(axios, 'get').mockImplementation(() => Promise.resolve(mockTableResponse));
axiosMockGet = jest.spyOn(axios, 'get').mockImplementation(() => Promise.resolve(mockSearchResponse));
axiosMockPost = jest.spyOn(axios, 'post').mockImplementation(() => Promise.resolve(mockSearchResponse));
userEnabledMock = jest.spyOn(ConfigUtils, 'indexUsersEnabled').mockImplementation(() => true);
});
describe('searchResource', () => {
it('calls axios get with request for a resource', async () => {
axiosMockGet.mockClear();
const pageIndex = 0;
const resourceType = ResourceType.table;
const term = 'test';
await API.searchResource(pageIndex, resourceType, term);
expect(axiosMockGet).toHaveBeenCalledWith(`${API.BASE_URL}/${resourceType}?query=${term}&page_index=${pageIndex}`);
});
it('calls searchResourceHelper with api call response', async () => {
const searchResourceHelperSpy = jest.spyOn(API, 'searchResourceHelper');
await API.searchResource(0, ResourceType.table, 'test');
expect(searchResourceHelperSpy).toHaveBeenCalledWith(mockTableResponse);
});
it('resolves with empty object if dashboard resource search not supported', async () => {
axiosMockGet.mockClear();
axiosMockPost.mockClear();
const pageIndex = 0;
const resourceType = ResourceType.dashboard;
const term = 'test';
expect.assertions(2);
expect.assertions(3);
await API.searchResource(pageIndex, resourceType, term).then(results => {
expect(results).toEqual({});
});
expect(axiosMockGet).not.toHaveBeenCalled();
expect(axiosMockPost).not.toHaveBeenCalled();
});
it('resolves with empty object if user resource search not supported', async () => {
axiosMockGet.mockClear();
AppConfig.indexUsers.enabled = false;
axiosMockPost.mockClear();
userEnabledMock.mockImplementationOnce(() => false);
const pageIndex = 0;
const resourceType = ResourceType.user;
const term = 'test';
expect.assertions(2);
expect.assertions(3);
await API.searchResource(pageIndex, resourceType, term).then(results => {
expect(results).toEqual({});
});
expect(axiosMockGet).not.toHaveBeenCalled();
expect(axiosMockPost).not.toHaveBeenCalled();
});
describe('if not searching a table resource', () => {
it('calls axios get with request for a resource', async () => {
axiosMockGet.mockClear();
axiosMockPost.mockClear();
userEnabledMock.mockImplementationOnce(() => true);
const pageIndex = 0;
const resourceType = ResourceType.user;
const term = 'test';
await API.searchResource(pageIndex, resourceType, term);
expect(axiosMockGet).toHaveBeenCalledWith(`${API.BASE_URL}/${resourceType}?query=${term}&page_index=${pageIndex}`);
expect(axiosMockPost).not.toHaveBeenCalled();
});
it('calls searchResourceHelper with api call response', async () => {
const searchResourceHelperSpy = jest.spyOn(API, 'searchResourceHelper');
await API.searchResource(0, ResourceType.user, 'test');
expect(searchResourceHelperSpy).toHaveBeenCalledWith(mockSearchResponse);
});
})
describe('if searching a table resource', () => {
it('calls axios post with request for a resource', async () => {
axiosMockGet.mockClear();
axiosMockPost.mockClear();
const pageIndex = 0;
const resourceType = ResourceType.table;
const term = 'test';
const filters = { 'schema': 'schema_name' }
await API.searchResource(pageIndex, resourceType, term, filters);
expect(axiosMockGet).not.toHaveBeenCalled();
expect(axiosMockPost).toHaveBeenCalledWith(`${API.BASE_URL}/${resourceType}_qs`, {
filters,
pageIndex,
term,
});
});
it('calls searchResourceHelper with api call response', async () => {
const searchResourceHelperSpy = jest.spyOn(API, 'searchResourceHelper');
await API.searchResource(0, ResourceType.table, 'test', { 'schema': 'schema_name' });
expect(searchResourceHelperSpy).toHaveBeenCalledWith(mockSearchResponse);
});
})
});
describe('searchResourceHelper', () => {
it('returns expected object', () => {
expect(API.searchResourceHelper(mockTableResponse)).toEqual({
searchTerm: mockTableResponse.data.search_term,
tables: mockTableResponse.data.tables,
users: mockTableResponse.data.users,
expect(API.searchResourceHelper(mockSearchResponse)).toEqual({
searchTerm: mockSearchResponse.data.search_term,
tables: mockSearchResponse.data.tables,
users: mockSearchResponse.data.users,
});
});
});
......
import axios, { AxiosResponse } from 'axios';
import AppConfig from 'config/config';
import { indexUsersEnabled } from 'config/config-utils';
import { ResourceType } from 'interfaces';
import { DashboardSearchResults, TableSearchResults, UserSearchResults } from '../types';
import { ResourceFilterReducerState } from '../filters/reducer';
export const BASE_URL = '/api/search/v0';
export interface SearchAPI {
......@@ -27,11 +29,20 @@ export const searchResourceHelper = (response: AxiosResponse<SearchAPI>) => {
return ret;
};
export function searchResource(pageIndex: number, resource: ResourceType, term: string) {
export function searchResource(pageIndex: number, resource: ResourceType, term: string, filters: ResourceFilterReducerState = {}) {
if (resource === ResourceType.dashboard ||
(resource === ResourceType.user && !AppConfig.indexUsers.enabled)) {
(resource === ResourceType.user && !indexUsersEnabled())) {
return Promise.resolve({});
}
/* Note: This logic must exist until query string endpoints are created for all resources */
if (resource === ResourceType.table) {
return axios.post(`${BASE_URL}/${resource}_qs`, {
filters,
pageIndex,
term,
}).then(searchResourceHelper);
}
return axios.get(`${BASE_URL}/${resource}?query=${term}&page_index=${pageIndex}`)
.then(searchResourceHelper);
};
import { ResourceType } from 'interfaces';
import { filterFromObj } from 'ducks/utilMethods';
/* ACTION TYPES */
export enum UpdateSearchFilter {
CLEAR_ALL = 'amundsen/search/filter/CLEAR_ALL',
CLEAR_CATEGORY = 'amundsen/search/filter/CLEAR_CATEGORY',
SET_BY_RESOURCE = 'amundsen/search/filter/SET_BY_RESOURCE',
UPDATE_CATEGORY = 'amundsen/search/filter/UPDATE_CATEGORY',
};
interface ClearAllFiltersRequest {
type: UpdateSearchFilter.CLEAR_ALL;
};
export interface ClearFilterRequest {
payload: {
categoryId: string;
};
type: UpdateSearchFilter.CLEAR_CATEGORY;
};
export interface SetSearchInputRequest {
payload: {
filters: ResourceFilterReducerState;
pageIndex?: number;
resourceType: ResourceType;
term?: string;
};
type: UpdateSearchFilter.SET_BY_RESOURCE;
};
export interface UpdateFilterRequest {
payload: {
categoryId: string;
value: string | FilterOptions;
};
type: UpdateSearchFilter.UPDATE_CATEGORY;
};
/* ACTIONS */
export function clearAllFilters(): ClearAllFiltersRequest {
return {
type: UpdateSearchFilter.CLEAR_ALL,
};
};
export function clearFilterByCategory(categoryId: string): ClearFilterRequest {
return {
payload: {
categoryId,
},
type: UpdateSearchFilter.CLEAR_CATEGORY,
};
};
export function setSearchInputByResource(filters: ResourceFilterReducerState,
resourceType: ResourceType,
pageIndex?: number,
term?: string): SetSearchInputRequest {
return {
payload: {
filters,
pageIndex,
resourceType,
term
},
type: UpdateSearchFilter.SET_BY_RESOURCE,
};
};
export function updateFilterByCategory(categoryId: string, value: string | FilterOptions): UpdateFilterRequest {
return {
payload: {
categoryId,
value
},
type: UpdateSearchFilter.UPDATE_CATEGORY,
};
};
/* REDUCER TYPES */
export type FilterOptions = { [id:string]: boolean };
export interface FilterReducerState {
[ResourceType.table]: ResourceFilterReducerState;
};
export interface ResourceFilterReducerState {
[categoryId: string]: string | FilterOptions;
};
/* REDUCER */
export const initialTableFilterState = {};
export const initialFilterState: FilterReducerState = {
[ResourceType.table]: initialTableFilterState,
};
export default function reducer(state: FilterReducerState = initialFilterState, action, resourceType: ResourceType): FilterReducerState {
const resourceFilters = state[resourceType];
const { payload, type } = action;
switch (type) {
case UpdateSearchFilter.CLEAR_ALL:
return initialFilterState;
case UpdateSearchFilter.CLEAR_CATEGORY:
return {
...state,
[resourceType]: filterFromObj(resourceFilters, [payload.categoryId])
};
case UpdateSearchFilter.SET_BY_RESOURCE:
return {
...state,
[payload.resourceType]: payload.filters
};
case UpdateSearchFilter.UPDATE_CATEGORY:
return {
...state,
[resourceType]: {
...resourceFilters,
[payload.categoryId]: payload.value
}
};
default:
return state;
};
};
import { ResourceType } from 'interfaces';
import reducer, {
clearAllFilters,
clearFilterByCategory,
setSearchInputByResource,
updateFilterByCategory,
initialFilterState,
FilterReducerState,
UpdateSearchFilter,
} from '../reducer';
describe('filters ducks', () => {
describe('actions', () => {
it('clearAllFilters - returns the action to clear all filters', () => {
const action = clearAllFilters();
expect(action.type).toBe(UpdateSearchFilter.CLEAR_ALL);
});
it('clearFilterByCategory - returns the action to clear the filters for a given category', () => {
const testCategory = 'category';
const action = clearFilterByCategory(testCategory);
const { payload } = action;
expect(action.type).toBe(UpdateSearchFilter.CLEAR_CATEGORY);
expect(payload.categoryId).toBe(testCategory);
});
it('setSearchInputByResource - returns the action to set all search input for a given resource', () => {;
const testResource = ResourceType.table;
const testFilters = { 'column': 'column_name' }
const testIndex = 0;
const testTerm = 'test';
const action = setSearchInputByResource(testFilters, testResource, testIndex, testTerm);
const { payload } = action;
expect(action.type).toBe(UpdateSearchFilter.SET_BY_RESOURCE);
expect(payload.resourceType).toBe(testResource);
expect(payload.filters).toBe(testFilters);
expect(payload.pageIndex).toBe(testIndex);
expect(payload.term).toBe(testTerm);
});
it('updateFilterByCategory - returns the action to update the filters for a given category', () => {;
const testCategory = 'column';
const testValue = 'column_name';
const action = updateFilterByCategory(testCategory, testValue);
const { payload } = action;
expect(action.type).toBe(UpdateSearchFilter.UPDATE_CATEGORY);
expect(payload.categoryId).toBe(testCategory);
expect(payload.value).toBe(testValue);
});
});
describe('reducer', () => {
let testState: FilterReducerState;
beforeAll(() => {
testState = {
[ResourceType.table]: {
'column': 'column_name'
}
}
});
it('should return the existing state if action is not handled', () => {
expect(reducer(testState, { type: 'INVALID.ACTION' }, ResourceType.table)).toBe(testState);
});
it('returns initial reducer state on UpdateSearchFilter.CLEAR_ALL', () => {
expect(reducer(testState, clearAllFilters(), ResourceType.table)).toBe(initialFilterState);
});
it('removes the given category from the current resource filter state on UpdateSearchFilter.CLEAR_CATEGORY', () => {
const givenResource = ResourceType.table;
const givenCategory = 'column';
expect(testState[givenResource][givenCategory]).toBeTruthy();
const result = reducer(testState, clearFilterByCategory(givenCategory), givenResource);
expect(result[givenResource][givenCategory]).toBe(undefined);
});
it('sets the given filters on the given resource on UpdateSearchFilter.SET_BY_RESOURCE', () => {
const givenResource = ResourceType.table;
const givenFilters = {
'column': 'column_name',
'schema': 'schema_name',
'database': { 'testDb': true }
}
const result = reducer(initialFilterState, setSearchInputByResource(givenFilters, givenResource), givenResource);
expect(result[givenResource]).toBe(givenFilters);
});
it('sets the given category on the filter state to the given value on UpdateSearchFilter.UPDATE_CATEGORY', () => {
const givenResource = ResourceType.table;
const givenCategory = 'database';
const givenValue = { 'testDb': true }
const result = reducer(initialFilterState, updateFilterByCategory(givenCategory, givenValue), givenResource);
expect(result[givenResource][givenCategory]).toBe(givenValue);
});
});
});
......@@ -2,6 +2,8 @@ import { ResourceType } from 'interfaces';
import { Search as UrlSearch } from 'history';
import filterReducer, { initialFilterState, UpdateSearchFilter, FilterReducerState } from './filters/reducer';
import {
DashboardSearchResults,
SearchAll,
......@@ -22,6 +24,8 @@ import {
InlineSearchUpdate,
TableSearchResults,
UserSearchResults,
ClearSearch,
ClearSearchRequest,
SubmitSearchRequest,
SubmitSearch,
SetResourceRequest,
......@@ -40,16 +44,18 @@ export interface SearchReducerState {
isLoading: boolean;
tables: TableSearchResults;
users: UserSearchResults;
}
},
filters: FilterReducerState;
};
/* ACTIONS */
export function searchAll(term: string, resource?: ResourceType, pageIndex?: number): SearchAllRequest {
export function searchAll(term: string, resource?: ResourceType, pageIndex?: number, useFilters: boolean = false): SearchAllRequest {
return {
payload: {
resource,
pageIndex,
term,
useFilters,
},
type: SearchAll.REQUEST,
};
......@@ -123,13 +129,19 @@ export function searchReset(): SearchAllReset {
};
};
export function submitSearch(searchTerm: string): SubmitSearchRequest {
export function submitSearch(searchTerm: string, useFilters: boolean = false): SubmitSearchRequest {
return {
payload: { searchTerm },
payload: { searchTerm, useFilters },
type: SubmitSearch.REQUEST,
};
};
export function clearSearch(): ClearSearchRequest {
return {
type: ClearSearch.REQUEST,
};
};
export function setResource(resource: ResourceType, updateUrl: boolean = true): SetResourceRequest {
return {
payload: { resource, updateUrl },
......@@ -191,11 +203,30 @@ export const initialState: SearchReducerState = {
results: [],
total_results: 0,
},
filters: initialFilterState,
inlineResults: initialInlineResultsState,
};
export default function reducer(state: SearchReducerState = initialState, action): SearchReducerState {
switch (action.type) {
case UpdateSearchFilter.SET_BY_RESOURCE:
return {
...state,
search_term: action.payload.term,
filters: filterReducer(state.filters, action, state.selectedTab),
}
case UpdateSearchFilter.CLEAR_ALL:
return {
...state,
filters: filterReducer(state.filters, action, state.selectedTab),
}
case UpdateSearchFilter.CLEAR_CATEGORY:
case UpdateSearchFilter.UPDATE_CATEGORY:
return {
...state,
isLoading: true,
filters: filterReducer(state.filters, action, state.selectedTab),
}
case SearchAll.RESET:
return initialState;
case SearchAll.REQUEST:
......@@ -219,6 +250,7 @@ export default function reducer(state: SearchReducerState = initialState, action
return {
...initialState,
...newState,
filters: state.filters,
inlineResults: {
tables: newState.tables,
users: newState.users,
......@@ -252,6 +284,7 @@ export default function reducer(state: SearchReducerState = initialState, action
tables,
users,
search_term: searchTerm,
filters: initialFilterState,
};
case InlineSearch.SUCCESS:
const inlineResults = (<InlineSearchResponse>action).payload;
......
import { SagaIterator } from 'redux-saga';
import { all, call, debounce, put, select, takeEvery, takeLatest } from 'redux-saga/effects';
import * as _ from 'lodash';
import * as qs from 'simple-query-string';
import { ResourceType } from 'interfaces/Resources';
......@@ -7,6 +8,8 @@ import { ResourceType } from 'interfaces/Resources';
import * as API from './api/v0';
import {
ClearSearch,
ClearSearchRequest,
LoadPreviousSearch,
LoadPreviousSearchRequest,
SearchAll,
......@@ -39,9 +42,58 @@ import {
updateFromInlineResult,
setPageIndex, setResource,
} from './reducer';
import {
clearAllFilters,
setSearchInputByResource,
UpdateSearchFilter
} from './filters/reducer';
import { autoSelectResource, getPageIndex, getSearchState } from './utils';
import { BrowserHistory, updateSearchUrl } from 'utils/navigationUtils';
/**
* Listens to actions triggers by user updates to the filter state.
* For better user experience debounce the start of the worker as multiple updates can happen in < 1 second.
*/
export function* filterWatcher(): SagaIterator {
yield debounce(750, [UpdateSearchFilter.CLEAR_CATEGORY, UpdateSearchFilter.UPDATE_CATEGORY], filterWorker);
};
/*
* Executes a search on the current resource.
* Actions that trigger this worker will have updated the filter reducer.
* The updated filter state is applied in searchResourceWorker().
* Updates the search url to reflect the change in filters.
*/
export function* filterWorker(): SagaIterator {
const state = yield select(getSearchState);
const { search_term, selectedTab, filters } = state;
const pageIndex = getPageIndex(state)
yield put(searchResource(search_term, selectedTab, pageIndex));
updateSearchUrl({ filters, resource: selectedTab, term: search_term, index: pageIndex }, true);
};
/**
* Listens to actions triggers by application updates to both the filter state and search term.
* This is intended to be temporary code. searchResource saga restructring will allow us to consolidate this support.
*/
export function* filterWatcher2(): SagaIterator {
yield takeLatest(UpdateSearchFilter.SET_BY_RESOURCE, filterWorker2);
};
/**
* Executes a search on the given resource.
* Actions that trigger this worker will have updated the filter reducer.
* The updated filter state is applied in searchResourceWorker().
* Updates the search url to reflect the change in filters.
* This is intended to be temporary code. searchResource Saga restructring will allow us to consolidate this support.
*/
export function* filterWorker2(action: any): SagaIterator {
const state = yield select(getSearchState);
const { pageIndex = 0, resourceType, term = '' } = action.payload;
yield put(searchResource(term, resourceType, pageIndex));
updateSearchUrl({ term, filters: state.filters, resource: resourceType, index: pageIndex }, false);
};
export function* inlineSearchWorker(action: InlineSearchRequest): SagaIterator {
const { term } = action.payload;
try {
......@@ -73,11 +125,11 @@ export function* selectInlineResultWorker(action): SagaIterator {
const { searchTerm, resourceType, updateUrl } = action.payload;
if (state.search.inlineResults.isLoading) {
yield put(searchAll(searchTerm, resourceType, 0))
updateSearchUrl({ term: searchTerm });
updateSearchUrl({ term: searchTerm, filters: state.search.filters });
}
else {
if (updateUrl) {
updateSearchUrl({ resource: resourceType, term: searchTerm, index: 0 });
updateSearchUrl({ resource: resourceType, term: searchTerm, index: 0, filters: state.search.filters });
}
const data = {
searchTerm,
......@@ -95,16 +147,21 @@ export function* selectInlineResultsWatcher(): SagaIterator {
export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
let { resource } = action.payload;
const { pageIndex, term } = action.payload;
const { pageIndex, term, useFilters } = action.payload;
if (!useFilters) {
yield put(clearAllFilters())
}
const state = yield select(getSearchState);
const tableIndex = resource === ResourceType.table ? pageIndex : 0;
const userIndex = resource === ResourceType.user ? pageIndex : 0;
const dashboardIndex = resource === ResourceType.dashboard ? pageIndex : 0;
try {
const [tableResponse, userResponse, dashboardResponse] = yield all([
call(API.searchResource, tableIndex, ResourceType.table, term),
call(API.searchResource, userIndex, ResourceType.user, term),
call(API.searchResource, dashboardIndex, ResourceType.dashboard, term),
call(API.searchResource, tableIndex, ResourceType.table, term, state.filters[ResourceType.table]),
call(API.searchResource, userIndex, ResourceType.user, term, state.filters[ResourceType.user]),
call(API.searchResource, dashboardIndex, ResourceType.dashboard, term, state.filters[ResourceType.dashboard]),
]);
const searchAllResponse = {
search_term: term,
......@@ -120,7 +177,7 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
}
const index = getPageIndex(searchAllResponse);
yield put(searchAllSuccess(searchAllResponse));
updateSearchUrl({ term, resource, index, }, true);
updateSearchUrl({ term, resource, index, filters: state.filters }, true);
} catch (e) {
yield put(searchAllFailure());
......@@ -132,8 +189,9 @@ export function* searchAllWatcher(): SagaIterator {
export function* searchResourceWorker(action: SearchResourceRequest): SagaIterator {
const { pageIndex, resource, term } = action.payload;
const state = yield select(getSearchState);
try {
const searchResults = yield call(API.searchResource, pageIndex, resource, term);
const searchResults = yield call(API.searchResource, pageIndex, resource, term, state.filters[resource]);
yield put(searchResourceSuccess(searchResults));
} catch (e) {
yield put(searchResourceFailure());
......@@ -144,9 +202,10 @@ export function* searchResourceWatcher(): SagaIterator {
};
export function* submitSearchWorker(action: SubmitSearchRequest): SagaIterator {
const { searchTerm } = action.payload;
yield put(searchAll(searchTerm));
updateSearchUrl({ term: searchTerm });
const state = yield select(getSearchState);
const { searchTerm, useFilters } = action.payload;
yield put(searchAll(searchTerm, undefined, undefined, useFilters));
updateSearchUrl({ term: searchTerm, filters: state.filters });
};
export function* submitSearchWatcher(): SagaIterator {
yield takeEvery(SubmitSearch.REQUEST, submitSearchWorker);
......@@ -160,6 +219,7 @@ export function* setResourceWorker(action: SetResourceRequest): SagaIterator {
resource,
term: state.search_term,
index: getPageIndex(state, resource),
filters: state.filters,
});
}
};
......@@ -177,6 +237,7 @@ export function* setPageIndexWorker(action: SetPageIndexRequest): SagaIterator {
term: state.search_term,
resource: state.selectedTab,
index: pageIndex,
filters: state.filters,
});
}
};
......@@ -184,17 +245,40 @@ export function* setPageIndexWatcher(): SagaIterator {
yield takeEvery(SetPageIndex.REQUEST, setPageIndexWorker);
};
export function* clearSearchWorker(action: ClearSearchRequest): SagaIterator {
/* If there was a previous search term, search each resource using filters */
const state = yield select(getSearchState);
if (!!state.search_term) {
yield put(searchAll('', undefined, undefined, true));
}
};
export function* clearSearchWatcher(): SagaIterator {
yield takeEvery(ClearSearch.REQUEST, clearSearchWorker);
};
export function* urlDidUpdateWorker(action: UrlDidUpdateRequest): SagaIterator {
const { urlSearch } = action.payload;
const { term, resource, index} = qs.parse(urlSearch);
const { term = '', resource, index, filters } = qs.parse(urlSearch);
const parsedIndex = parseInt(index, 10);
const parsedFilters = filters ? JSON.parse(filters) : null;
const state = yield select(getSearchState);
if (!!term && state.search_term !== term) {
yield put(searchAll(term, resource, parsedIndex));
} else if (!!resource && resource !== state.selectedTab) {
yield put(setResource(resource, false))
} else if (!!resource) {
if (resource !== state.selectedTab) {
yield put(setResource(resource, false))
}
if (parsedFilters && !_.isEqual(state.filters[resource], parsedFilters)) {
/* This will update filter state + search resource */
yield put(setSearchInputByResource(parsedFilters, resource, parsedIndex, term));
}
} else if (!isNaN(parsedIndex) && parsedIndex !== getPageIndex(state, resource)) {
/*
Note: Current filtering logic seems to reproduction of this case.
Could there be a race condition between url and reducer state updates?
Re-evaluate when restrucuring sagas to consolidate filter support.
*/
yield put(setPageIndex(parsedIndex, false));
}
};
......@@ -212,6 +296,7 @@ export function* loadPreviousSearchWorker(action: LoadPreviousSearchRequest): Sa
term: state.search_term,
resource: state.selectedTab,
index: getPageIndex(state),
filters: state.filters,
});
};
export function* loadPreviousSearchWatcher(): SagaIterator {
......
......@@ -52,6 +52,7 @@ export interface SearchAllRequest {
resource: ResourceType;
pageIndex: number;
term: string;
useFilters?: boolean;
};
type: SearchAll.REQUEST;
};
......@@ -120,10 +121,17 @@ export enum SubmitSearch {
export interface SubmitSearchRequest {
payload: {
searchTerm: string;
useFilters?: boolean;
};
type: SubmitSearch.REQUEST;
};
export enum ClearSearch {
REQUEST = 'amundsen/search/CLEAR_SEARCH_REQUEST',
};
export interface ClearSearchRequest {
type: ClearSearch.REQUEST;
};
export enum SetResource {
REQUEST = 'amundsen/search/SET_RESOURCE_REQUEST',
......
import { GlobalState } from 'ducks/rootReducer';
import { ResourceType, SendingState } from 'interfaces';
import { defaultEmptyFilters } from './search/filters';
const globalState: GlobalState = {
announcements: {
posts: [{
......@@ -104,7 +106,8 @@ const globalState: GlobalState = {
results: [],
total_results: 0,
},
}
},
filters: defaultEmptyFilters,
},
tableMetadata: {
isLoading: true,
......
import { ResourceType } from 'interfaces';
export const defaultEmptyFilters = {
[ResourceType.table]: {}
};
export const datasetFilterExample = {
[ResourceType.table]: {
'schema': 'schema_name',
'database': {
'hive': true
}
}
};
......@@ -2,3 +2,8 @@ export enum UpdateMethod {
PUT = 'PUT',
DELETE = 'DELETE',
};
export enum FilterType {
CHECKBOX_SELECT = 'checkboxFilter',
INPUT_SELECT = 'inputFilter'
}
import { PeopleUser } from 'interfaces/User';
import { PeopleUser } from './User';
export enum ResourceType {
table = "table",
......
import * as qs from 'simple-query-string';
import { createBrowserHistory } from 'history';
import { ResourceType } from 'interfaces/Resources';
// https://github.com/ReactTraining/react-router/issues/3972#issuecomment-264805667
export const BrowserHistory = createBrowserHistory();
interface SearchParams {
export interface SearchParams {
term?: string;
resource?: string;
resource?: ResourceType;
index?: number;
filters?: {};
}
export const updateSearchUrl = (searchParams: SearchParams, replace: boolean = false) => {
// Explicitly listing out parameters to ensure consistent URL format
const urlParams = qs.stringify({
term: searchParams.term,
export const DEFAULT_SEARCH_ROUTE = '/search';
export const generateSearchUrl = (searchParams: SearchParams) : string => {
const filtersForResource = searchParams.filters && searchParams.filters[searchParams.resource] || {};
const hasFilters = Object.keys(filtersForResource).length > 0;
// If there is no search input return the search route url
if (!searchParams.term && !hasFilters) {
return DEFAULT_SEARCH_ROUTE;
}
// Explicitly list out parameters to ensure consistent URL format
const queryStringValues = {
term: searchParams.term || undefined,
resource: searchParams.resource,
index: searchParams.index,
});
};
if (hasFilters) {
queryStringValues['filters'] = filtersForResource;
}
const urlParams = qs.stringify(queryStringValues);
return `${DEFAULT_SEARCH_ROUTE}?${urlParams}`;
};
export const updateSearchUrl = (searchParams: SearchParams, replace: boolean = false) => {
const newUrl = generateSearchUrl(searchParams);
if (replace) {
BrowserHistory.replace(`/search?${urlParams}`);
BrowserHistory.replace(newUrl);
} else {
BrowserHistory.push(`/search?${urlParams}`);
BrowserHistory.push(newUrl);
}
};
......@@ -5,7 +5,7 @@ This document describes how to leverage the frontend service's application confi
**NOTE: This document is a work in progress and does not include 100% of features. We welcome PRs to complete this document**
## Badge Config
Badges are a special type of tag that cannot be edited through the UI.
Badges are a special type of tag that cannot be edited through the UI.
`BadgeConfig` can be used to customize the text and color of badges. This config defines a mapping of badge name to a `BadgeStyle` and optional `displayName`. Badges that are not defined will default to use the `BadgeStyle.default` style and `displayName` use the badge name with any `_` or `-` characters replaced with a space.
......@@ -35,7 +35,7 @@ _TODO: Please add doc_
## Index Users
In Amundsen, users themselves are data resources and user metadata helps to facilitate network based discovery. When users are indexed they will show up in search results, and selecting a user surfaces a profile page that displays that user's relationships with different data resources.
After ingesting user metadata into the search and metadata services, set `IndexUsersConfig.enabled` to `true` on the application configuration to display the UI for the aforementioned features.
After ingesting user metadata into the search and metadata services, set `IndexUsersConfig.enabled` to `true` on the application configuration to display the UI for the aforementioned features.
## Mail Client Features
Amundsen has two features that leverage the custom mail client -- the feedback tool and notifications.
......@@ -52,15 +52,24 @@ client, please see this [entry](flask_config.md#mail-client-features) in our fla
_TODO: Please add doc_
## Resource Configurations
This configuration drives resource specific aspects of the application's user interface. Each supported resource should be mapped to an object that matches or extends the `BaseResourceConfig`.
### Datasets
We provide a `datasets` option on our `ResourceConfig`. This can be used for the following customizations:
### Base Configuration
All resource configurations must match or extend the `BaseResourceConfig`. This configuration supports the following options:
1. `displayName`: The name displayed throughout the application to refer to this resource type.
2. `filterCategories`: An optional `FilterConfig` object. When set for a given resource, that resource will display filter options in the search page UI.
#### Filter Categories
The `FilterConfig` is an array of objects that match any of the supported filter options. We currently support a `MultiSelectFilterCategory` and a `SingleFilterCategory`. See our [config-types](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/js/config/config-types.ts) for more information about each option.
### Table Configuration
`TableResourceConfig` extends `BaseResourceConfig` with a `supportedDatabases` option. This can be used for the following customizations:
#### Custom Icons
You can configure custom icons to be used throughout the UI when representing datasets from particular sources/databases. On the `ResourceConfig.datasets` object, add an entry with the `id` used to reference that database and set the `iconClass`. This `iconClass` should be defined in [icons.scss](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/css/_icons.scss).
You can configure custom icons to be used throughout the UI when representing datasets from particular sources/databases. On the `TableResourceConfig.supportedDatabases` object, add an entry with the `id` used to reference that database and map to an object that specifies the `iconClass` for that database. This `iconClass` should be defined in [icons.scss](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/css/_icons.scss).
#### Display Names
You can configure a specific display name to be used throughout the UI when representing datasets from particular sources/databases. On the `ResourceConfig.datasets` object, add an entry with the `id` used to reference that database and set the `displayName`.
You can configure a specific display name to be used throughout the UI when representing datasets from particular sources/databases. On the `TableResourceConfig.supportedDatabases` object, add an entry with the `id` used to reference that database and map to an object that specified the `displayName` for that database.
## Table Lineage
......
This diff is collapsed.
import json
import unittest
from amundsen_application import create_app
from amundsen_application.api.utils.response_utils import create_error_response
local_app = create_app('amundsen_application.config.TestConfig', 'tests/templates')
class ResponseUtilsTest(unittest.TestCase):
def setUp(self) -> None:
pass
def test_create_error_response(self) -> None:
"""
Verify that the returned response contains the given messag and status_code
:return:
"""
test_message = 'Success'
test_payload = {}
status_code = 200
with local_app.app_context():
response = create_error_response(message=test_message,
payload=test_payload,
status_code=status_code)
data = json.loads(response.data)
self.assertEqual(response.status_code, status_code)
self.assertEqual(data.get('msg'), test_message)
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment