Unverified Commit e8893c1a authored by Tamika Tannis's avatar Tamika Tannis Committed by GitHub

Consolidate some areas of search application state workflow (#405)

* clearSearch consolidated with submitSearch

* Remove unused 'setResource' logic from SearchPage

* WIP

* WIP: Consolidate filter actions

* selectedTab --> resource

* Code cleanup

* More code cleanup

* Add back/split up some tests

* Update a type

* Cleanup + add more tests

* ts-ignore

* Cleanup doc wording; Searches when url updates should be LOAD_URL type
parent f51b2aa3
...@@ -16,7 +16,7 @@ module.exports = { ...@@ -16,7 +16,7 @@ module.exports = {
branches: 75, branches: 75,
functions: 80, functions: 80,
lines: 80, lines: 80,
statements: 80, statements: 85,
}, },
'./js/fixtures': { './js/fixtures': {
branches: 100, branches: 100,
......
...@@ -10,14 +10,14 @@ import { SEARCH_BREADCRUMB_TEXT } from './constants'; ...@@ -10,14 +10,14 @@ import { SEARCH_BREADCRUMB_TEXT } from './constants';
import MyBookmarks from 'components/common/Bookmark/MyBookmarks'; import MyBookmarks from 'components/common/Bookmark/MyBookmarks';
import Breadcrumb from 'components/common/Breadcrumb'; import Breadcrumb from 'components/common/Breadcrumb';
import PopularTables from 'components/common/PopularTables'; import PopularTables from 'components/common/PopularTables';
import { SearchAllReset } from 'ducks/search/types'; import { resetSearchState } from 'ducks/search/reducer';
import { searchReset } from 'ducks/search/reducer'; import { UpdateSearchStateReset } from 'ducks/search/types';
import SearchBar from 'components/common/SearchBar'; import SearchBar from 'components/common/SearchBar';
import TagsList from 'components/common/TagsList'; import TagsList from 'components/common/TagsList';
export interface DispatchFromProps { export interface DispatchFromProps {
searchReset: () => SearchAllReset; searchReset: () => UpdateSearchStateReset;
} }
export type HomePageProps = DispatchFromProps & RouteComponentProps<any>; export type HomePageProps = DispatchFromProps & RouteComponentProps<any>;
...@@ -62,7 +62,9 @@ export class HomePage extends React.Component<HomePageProps> { ...@@ -62,7 +62,9 @@ export class HomePage extends React.Component<HomePageProps> {
} }
export const mapDispatchToProps = (dispatch: any) => { export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ searchReset } , dispatch); return bindActionCreators({
searchReset: () => resetSearchState(),
}, dispatch);
}; };
export default connect<DispatchFromProps>(null, mapDispatchToProps)(HomePage); export default connect<DispatchFromProps>(null, mapDispatchToProps)(HomePage);
...@@ -15,7 +15,7 @@ import { getMockRouterProps } from 'fixtures/mockRouter'; ...@@ -15,7 +15,7 @@ import { getMockRouterProps } from 'fixtures/mockRouter';
describe('HomePage', () => { describe('HomePage', () => {
const setup = (propOverrides?: Partial<HomePageProps>) => { const setup = (propOverrides?: Partial<HomePageProps>) => {
const mockLocation = { const mockLocation = {
search: '/search?searchTerm=testName&selectedTab=table&pageIndex=1', search: '/search?searchTerm=testName&resource=table&pageIndex=1',
}; };
const routerProps = getMockRouterProps<any>(null, mockLocation); const routerProps = getMockRouterProps<any>(null, mockLocation);
const props: HomePageProps = { const props: HomePageProps = {
......
...@@ -5,24 +5,24 @@ import { connect } from 'react-redux'; ...@@ -5,24 +5,24 @@ import { connect } from 'react-redux';
import { TABLE_RESOURCE_TITLE, USER_RESOURCE_TITLE } from 'components/SearchPage/constants'; import { TABLE_RESOURCE_TITLE, USER_RESOURCE_TITLE } from 'components/SearchPage/constants';
import AppConfig from 'config/config'; import AppConfig from 'config/config';
import { GlobalState } from 'ducks/rootReducer'; import { GlobalState } from 'ducks/rootReducer';
import { updateSearchState } from 'ducks/search/reducer';
import { import {
DashboardSearchResults, DashboardSearchResults,
SetResourceRequest,
TableSearchResults, TableSearchResults,
UpdateSearchStateRequest,
UserSearchResults UserSearchResults
} from 'ducks/search/types'; } from 'ducks/search/types';
import { ResourceType } from 'interfaces/Resources'; import { ResourceType } from 'interfaces/Resources';
import { setResource } from 'ducks/search/reducer';
export interface StateFromProps { export interface StateFromProps {
selectedTab: ResourceType, resource: ResourceType,
tables: TableSearchResults; tables: TableSearchResults;
dashboards: DashboardSearchResults; dashboards: DashboardSearchResults;
users: UserSearchResults; users: UserSearchResults;
} }
export interface DispatchFromProps { export interface DispatchFromProps {
setResource: (resource: ResourceType) => SetResourceRequest; setResource: (resource: ResourceType) => UpdateSearchStateRequest;
} }
export type ResourceSelectorProps = StateFromProps & DispatchFromProps; export type ResourceSelectorProps = StateFromProps & DispatchFromProps;
...@@ -50,7 +50,7 @@ export class ResourceSelector extends React.Component<ResourceSelectorProps > { ...@@ -50,7 +50,7 @@ export class ResourceSelector extends React.Component<ResourceSelectorProps > {
type="radio" type="radio"
name="resource" name="resource"
value={ option.type } value={ option.type }
checked={ this.props.selectedTab === option.type } checked={ this.props.resource === option.type }
onChange={ this.onChange } onChange={ this.onChange }
/> />
<span className="subtitle-2">{ option.label }</span> <span className="subtitle-2">{ option.label }</span>
...@@ -88,7 +88,7 @@ export class ResourceSelector extends React.Component<ResourceSelectorProps > { ...@@ -88,7 +88,7 @@ export class ResourceSelector extends React.Component<ResourceSelectorProps > {
export const mapStateToProps = (state: GlobalState) => { export const mapStateToProps = (state: GlobalState) => {
return { return {
selectedTab: state.search.selectedTab, resource: state.search.resource,
tables: state.search.tables, tables: state.search.tables,
users: state.search.users, users: state.search.users,
dashboards: state.search.dashboards, dashboards: state.search.dashboards,
...@@ -96,7 +96,9 @@ export const mapStateToProps = (state: GlobalState) => { ...@@ -96,7 +96,9 @@ export const mapStateToProps = (state: GlobalState) => {
}; };
export const mapDispatchToProps = (dispatch: any) => { export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ setResource }, dispatch); return bindActionCreators({
setResource: (resource: ResourceType) => updateSearchState({ resource, updateUrl: true }),
}, dispatch);
}; };
export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(ResourceSelector); export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(ResourceSelector);
...@@ -16,7 +16,7 @@ import { ResourceType } from 'interfaces/Resources'; ...@@ -16,7 +16,7 @@ import { ResourceType } from 'interfaces/Resources';
describe('ResourceSelector', () => { describe('ResourceSelector', () => {
const setup = (propOverrides?: Partial<ResourceSelectorProps>) => { const setup = (propOverrides?: Partial<ResourceSelectorProps>) => {
const props = { const props = {
selectedTab: ResourceType.table, resource: ResourceType.table,
tables: globalState.search.tables, tables: globalState.search.tables,
users: globalState.search.users, users: globalState.search.users,
dashboards: globalState.search.dashboards, dashboards: globalState.search.dashboards,
...@@ -42,7 +42,7 @@ describe('ResourceSelector', () => { ...@@ -42,7 +42,7 @@ describe('ResourceSelector', () => {
expect(inputProps.type).toEqual("radio"); expect(inputProps.type).toEqual("radio");
expect(inputProps.name).toEqual("resource"); expect(inputProps.name).toEqual("resource");
expect(inputProps.value).toEqual(radioConfig.type); expect(inputProps.value).toEqual(radioConfig.type);
expect(inputProps.checked).toEqual(props.selectedTab === radioConfig.type); expect(inputProps.checked).toEqual(props.resource === radioConfig.type);
expect(inputProps.onChange).toEqual(instance.onChange); expect(inputProps.onChange).toEqual(instance.onChange);
}); });
...@@ -121,8 +121,8 @@ describe('mapStateToProps', () => { ...@@ -121,8 +121,8 @@ describe('mapStateToProps', () => {
result = mapStateToProps(globalState); result = mapStateToProps(globalState);
}); });
it('sets selectedTab on the props', () => { it('sets resource on the props', () => {
expect(result.selectedTab).toEqual(globalState.search.selectedTab); expect(result.resource).toEqual(globalState.search.resource);
}); });
it('sets tables on the props', () => { it('sets tables on the props', () => {
......
...@@ -3,7 +3,7 @@ import { bindActionCreators } from 'redux'; ...@@ -3,7 +3,7 @@ import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { GlobalState } from 'ducks/rootReducer'; import { GlobalState } from 'ducks/rootReducer';
import { clearFilterByCategory, updateFilterByCategory, ClearFilterRequest, UpdateFilterRequest, FilterOptions } from 'ducks/search/filters/reducer'; import { updateFilterByCategory, UpdateFilterRequest, FilterOptions } from 'ducks/search/filters/reducer';
import CheckBoxItem from 'components/common/Inputs/CheckBoxItem'; import CheckBoxItem from 'components/common/Inputs/CheckBoxItem';
...@@ -22,8 +22,7 @@ interface StateFromProps { ...@@ -22,8 +22,7 @@ interface StateFromProps {
} }
interface DispatchFromProps { interface DispatchFromProps {
clearFilterByCategory: (categoryId: string) => ClearFilterRequest; updateFilter: (categoryId: string, checkedValues: FilterOptions | undefined) => UpdateFilterRequest;
updateFilterByCategory: (categoryId: string, value: FilterOptions) => UpdateFilterRequest;
} }
export type CheckBoxFilterProps = OwnProps & DispatchFromProps & StateFromProps; export type CheckBoxFilterProps = OwnProps & DispatchFromProps & StateFromProps;
...@@ -64,10 +63,10 @@ export class CheckBoxFilter extends React.Component<CheckBoxFilterProps> { ...@@ -64,10 +63,10 @@ export class CheckBoxFilter extends React.Component<CheckBoxFilterProps> {
} }
if (Object.keys(checkedValues).length === 0) { if (Object.keys(checkedValues).length === 0) {
this.props.clearFilterByCategory(categoryId); this.props.updateFilter(categoryId, undefined);
} }
else { else {
this.props.updateFilterByCategory(categoryId, checkedValues); this.props.updateFilter(categoryId, checkedValues);
} }
}; };
...@@ -83,7 +82,7 @@ export class CheckBoxFilter extends React.Component<CheckBoxFilterProps> { ...@@ -83,7 +82,7 @@ export class CheckBoxFilter extends React.Component<CheckBoxFilterProps> {
export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => { export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
const filterState = state.search.filters; const filterState = state.search.filters;
let filterValues = filterState[state.search.selectedTab] ? filterState[state.search.selectedTab][ownProps.categoryId] : {}; let filterValues = filterState[state.search.resource] ? filterState[state.search.resource][ownProps.categoryId] : {};
if (!filterValues) { if (!filterValues) {
filterValues = {}; filterValues = {};
} }
...@@ -95,8 +94,7 @@ export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => { ...@@ -95,8 +94,7 @@ export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
export const mapDispatchToProps = (dispatch: any) => { export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ return bindActionCreators({
clearFilterByCategory, updateFilter: (categoryId: string, checkedValues: FilterOptions | undefined) => updateFilterByCategory({ categoryId, value: checkedValues }),
updateFilterByCategory,
}, dispatch); }, dispatch);
}; };
......
...@@ -27,8 +27,7 @@ describe('CheckBoxFilter', () => { ...@@ -27,8 +27,7 @@ describe('CheckBoxFilter', () => {
checkedValues: { checkedValues: {
'hive': true, 'hive': true,
}, },
clearFilterByCategory: jest.fn(), updateFilter: jest.fn(),
updateFilterByCategory: jest.fn(),
...propOverrides ...propOverrides
}; };
const wrapper = shallow<CheckBoxFilter>(<CheckBoxFilter {...props} />); const wrapper = shallow<CheckBoxFilter>(<CheckBoxFilter {...props} />);
...@@ -74,32 +73,30 @@ describe('CheckBoxFilter', () => { ...@@ -74,32 +73,30 @@ describe('CheckBoxFilter', () => {
let wrapper; let wrapper;
let mockEvent; let mockEvent;
let clearCategorySpy; let updateFilterSpy;
let updateCategorySpy;
beforeAll(() => { beforeAll(() => {
const setupResult = setup(); const setupResult = setup();
props = setupResult.props; props = setupResult.props;
wrapper = setupResult.wrapper; wrapper = setupResult.wrapper;
clearCategorySpy = jest.spyOn(props, 'clearFilterByCategory'); updateFilterSpy = jest.spyOn(props, 'updateFilter');
updateCategorySpy = jest.spyOn(props, 'updateFilterByCategory');
}) })
it('calls props.clearFilterByCategory if no items will be checked', () => { it('calls props.updateFilter if no items will be checked', () => {
clearCategorySpy.mockClear(); updateFilterSpy.mockClear();
mockEvent = { target: { name: mockCategoryId, value: 'hive', checked: false }}; mockEvent = { target: { name: mockCategoryId, value: 'hive', checked: false }};
wrapper.instance().onCheckboxChange(mockEvent); wrapper.instance().onCheckboxChange(mockEvent);
expect(clearCategorySpy).toHaveBeenCalledWith(mockCategoryId) expect(updateFilterSpy).toHaveBeenCalledWith(mockCategoryId, undefined)
}); });
it('calls props.updateFilterByCategory with expected parameters', () => { it('calls props.updateFilter with expected parameters', () => {
updateCategorySpy.mockClear(); updateFilterSpy.mockClear();
mockEvent = { target: { name: mockCategoryId, value: 'bigquery', checked: true}}; mockEvent = { target: { name: mockCategoryId, value: 'bigquery', checked: true}};
const expectedCheckedValues = { const expectedCheckedValues = {
...props.checkedValues, ...props.checkedValues,
'bigquery': true 'bigquery': true
} }
wrapper.instance().onCheckboxChange(mockEvent); wrapper.instance().onCheckboxChange(mockEvent);
expect(updateCategorySpy).toHaveBeenCalledWith(mockCategoryId, expectedCheckedValues) expect(updateFilterSpy).toHaveBeenCalledWith(mockCategoryId, expectedCheckedValues)
}); });
}); });
...@@ -133,7 +130,7 @@ describe('CheckBoxFilter', () => { ...@@ -133,7 +130,7 @@ describe('CheckBoxFilter', () => {
...globalState, ...globalState,
search: { search: {
...globalState.search, ...globalState.search,
selectedTab: ResourceType.table, resource: ResourceType.table,
filters: { filters: {
[ResourceType.table]: { [ResourceType.table]: {
[mockCategoryId]: mockFilters [mockCategoryId]: mockFilters
...@@ -146,7 +143,7 @@ describe('CheckBoxFilter', () => { ...@@ -146,7 +143,7 @@ describe('CheckBoxFilter', () => {
...globalState, ...globalState,
search: { search: {
...globalState.search, ...globalState.search,
selectedTab: ResourceType.user, resource: ResourceType.user,
filters: { filters: {
[ResourceType.table]: {} [ResourceType.table]: {}
} }
...@@ -183,12 +180,8 @@ describe('CheckBoxFilter', () => { ...@@ -183,12 +180,8 @@ describe('CheckBoxFilter', () => {
result = mapDispatchToProps(dispatch); result = mapDispatchToProps(dispatch);
}); });
it('sets clearFilterByCategory on the props', () => { it('sets updateFilter on the props', () => {
expect(result.clearFilterByCategory).toBeInstanceOf(Function); expect(result.updateFilter).toBeInstanceOf(Function);
});
it('sets updateFilterByCategory on the props', () => {
expect(result.updateFilterByCategory).toBeInstanceOf(Function);
}); });
}); });
}); });
...@@ -2,7 +2,7 @@ import * as React from 'react'; ...@@ -2,7 +2,7 @@ import * as React from 'react';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { clearFilterByCategory, ClearFilterRequest } from 'ducks/search/filters/reducer'; import { updateFilterByCategory, UpdateFilterRequest } from 'ducks/search/filters/reducer';
import { CLEAR_BTN_TEXT } from '../constants'; import { CLEAR_BTN_TEXT } from '../constants';
...@@ -28,7 +28,7 @@ export interface StateFromProps { ...@@ -28,7 +28,7 @@ export interface StateFromProps {
}; };
export interface DispatchFromProps { export interface DispatchFromProps {
clearFilterByCategory: (categoryId: string) => ClearFilterRequest; clearFilter: (categoryId: string) => UpdateFilterRequest;
}; };
export type FilterSectionProps = OwnProps & DispatchFromProps & StateFromProps; export type FilterSectionProps = OwnProps & DispatchFromProps & StateFromProps;
...@@ -39,7 +39,7 @@ export class FilterSection extends React.Component<FilterSectionProps> { ...@@ -39,7 +39,7 @@ export class FilterSection extends React.Component<FilterSectionProps> {
} }
onClearFilter = () => { onClearFilter = () => {
this.props.clearFilterByCategory(this.props.categoryId); this.props.clearFilter(this.props.categoryId);
} }
renderFilterComponent = () => { renderFilterComponent = () => {
...@@ -93,7 +93,7 @@ export class FilterSection extends React.Component<FilterSectionProps> { ...@@ -93,7 +93,7 @@ export class FilterSection extends React.Component<FilterSectionProps> {
export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => { export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
const filterState = state.search.filters; const filterState = state.search.filters;
const filterValue = filterState[state.search.selectedTab] ? filterState[state.search.selectedTab][ownProps.categoryId] : null; const filterValue = filterState[state.search.resource] ? filterState[state.search.resource][ownProps.categoryId] : null;
let hasValue = false; let hasValue = false;
if (filterValue && ownProps.type === FilterType.CHECKBOX_SELECT) { if (filterValue && ownProps.type === FilterType.CHECKBOX_SELECT) {
Object.keys(filterValue).forEach(key => { Object.keys(filterValue).forEach(key => {
...@@ -110,7 +110,7 @@ export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => { ...@@ -110,7 +110,7 @@ export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
export const mapDispatchToProps = (dispatch: any) => { export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ return bindActionCreators({
clearFilterByCategory, clearFilter: (categoryId: string) => updateFilterByCategory({ categoryId, value: undefined }),
}, dispatch); }, dispatch);
}; };
......
...@@ -2,7 +2,6 @@ import * as React from 'react'; ...@@ -2,7 +2,6 @@ import * as React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { GlobalState } from 'ducks/rootReducer'; import { GlobalState } from 'ducks/rootReducer';
import { clearFilterByCategory } from 'ducks/search/filters/reducer';
import { FilterSection, FilterSectionProps, mapDispatchToProps, mapStateToProps } from '../'; import { FilterSection, FilterSectionProps, mapDispatchToProps, mapStateToProps } from '../';
...@@ -19,7 +18,7 @@ describe('FilterSection', () => { ...@@ -19,7 +18,7 @@ describe('FilterSection', () => {
categoryId: 'testId', categoryId: 'testId',
hasValue: true, hasValue: true,
title: 'Category', title: 'Category',
clearFilterByCategory: jest.fn(), clearFilter: jest.fn(),
type: FilterType.INPUT_SELECT, type: FilterType.INPUT_SELECT,
...propOverrides ...propOverrides
}; };
...@@ -36,10 +35,10 @@ describe('FilterSection', () => { ...@@ -36,10 +35,10 @@ describe('FilterSection', () => {
const setupResult = setup(); const setupResult = setup();
props = setupResult.props; props = setupResult.props;
wrapper = setupResult.wrapper; wrapper = setupResult.wrapper;
clearFilterSpy = jest.spyOn(props, 'clearFilterByCategory'); clearFilterSpy = jest.spyOn(props, 'clearFilter');
}); });
it('calls props.clearFilterByCategory with props.categoryId', () => { it('calls props.clearFilter with props.categoryId', () => {
wrapper.instance().onClearFilter(); wrapper.instance().onClearFilter();
expect(clearFilterSpy).toHaveBeenCalledWith(props.categoryId); expect(clearFilterSpy).toHaveBeenCalledWith(props.categoryId);
}) })
...@@ -109,7 +108,7 @@ describe('FilterSection', () => { ...@@ -109,7 +108,7 @@ describe('FilterSection', () => {
...globalState, ...globalState,
search: { search: {
...globalState.search, ...globalState.search,
selectedTab: ResourceType.table, resource: ResourceType.table,
filters: { filters: {
[ResourceType.table]: { [ResourceType.table]: {
'database': { 'hive': true }, 'database': { 'hive': true },
...@@ -123,7 +122,7 @@ describe('FilterSection', () => { ...@@ -123,7 +122,7 @@ describe('FilterSection', () => {
...globalState, ...globalState,
search: { search: {
...globalState.search, ...globalState.search,
selectedTab: ResourceType.user, resource: ResourceType.user,
filters: { filters: {
[ResourceType.table]: {} [ResourceType.table]: {}
} }
...@@ -175,8 +174,8 @@ describe('FilterSection', () => { ...@@ -175,8 +174,8 @@ describe('FilterSection', () => {
result = mapDispatchToProps(dispatch); result = mapDispatchToProps(dispatch);
}); });
it('sets clearFilterByCategory on the props', () => { it('sets clearFilter on the props', () => {
expect(result.clearFilterByCategory).toBeInstanceOf(Function); expect(result.clearFilter).toBeInstanceOf(Function);
}); });
}); });
}); });
...@@ -2,7 +2,7 @@ import * as React from 'react'; ...@@ -2,7 +2,7 @@ import * as React from 'react';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { clearFilterByCategory, updateFilterByCategory, ClearFilterRequest, UpdateFilterRequest } from 'ducks/search/filters/reducer'; import { updateFilterByCategory, UpdateFilterRequest } from 'ducks/search/filters/reducer';
import { APPLY_BTN_TEXT } from '../constants'; import { APPLY_BTN_TEXT } from '../constants';
...@@ -17,8 +17,7 @@ interface StateFromProps { ...@@ -17,8 +17,7 @@ interface StateFromProps {
} }
interface DispatchFromProps { interface DispatchFromProps {
clearFilterByCategory: (categoryId: string) => ClearFilterRequest; updateFilter: (categoryId: string, value: string | undefined) => UpdateFilterRequest;
updateFilterByCategory: (categoryId: string, value: string) => UpdateFilterRequest;
} }
export type InputFilterProps = StateFromProps & DispatchFromProps & OwnProps; export type InputFilterProps = StateFromProps & DispatchFromProps & OwnProps;
...@@ -46,10 +45,10 @@ export class InputFilter extends React.Component<InputFilterProps, InputFilterSt ...@@ -46,10 +45,10 @@ export class InputFilter extends React.Component<InputFilterProps, InputFilterSt
onApplyChanges = (event: React.FormEvent<HTMLFormElement>) => { onApplyChanges = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if(!!this.state.value) { if(!!this.state.value) {
this.props.updateFilterByCategory(this.props.categoryId, this.state.value); this.props.updateFilter(this.props.categoryId, this.state.value);
} }
else { else {
this.props.clearFilterByCategory(this.props.categoryId); this.props.updateFilter(this.props.categoryId, undefined);
} }
}; };
...@@ -82,7 +81,7 @@ export class InputFilter extends React.Component<InputFilterProps, InputFilterSt ...@@ -82,7 +81,7 @@ export class InputFilter extends React.Component<InputFilterProps, InputFilterSt
export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => { export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
const filterState = state.search.filters; const filterState = state.search.filters;
const value = filterState[state.search.selectedTab] ? filterState[state.search.selectedTab][ownProps.categoryId] : ''; const value = filterState[state.search.resource] ? filterState[state.search.resource][ownProps.categoryId] : '';
return { return {
value: value || '', value: value || '',
} }
...@@ -90,8 +89,7 @@ export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => { ...@@ -90,8 +89,7 @@ export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
export const mapDispatchToProps = (dispatch: any) => { export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ return bindActionCreators({
clearFilterByCategory, updateFilter: (categoryId: string, value: string | undefined) => updateFilterByCategory({ categoryId, value }),
updateFilterByCategory,
}, dispatch); }, dispatch);
}; };
......
...@@ -6,7 +6,6 @@ import { InputFilter, InputFilterProps, mapDispatchToProps, mapStateToProps } fr ...@@ -6,7 +6,6 @@ import { InputFilter, InputFilterProps, mapDispatchToProps, mapStateToProps } fr
import { APPLY_BTN_TEXT } from '../../constants'; import { APPLY_BTN_TEXT } from '../../constants';
import { GlobalState } from 'ducks/rootReducer'; import { GlobalState } from 'ducks/rootReducer';
import { clearFilterByCategory, updateFilterByCategory } from 'ducks/search/filters/reducer';
import globalState from 'fixtures/globalState'; import globalState from 'fixtures/globalState';
...@@ -19,8 +18,7 @@ describe('InputFilter', () => { ...@@ -19,8 +18,7 @@ describe('InputFilter', () => {
const props: InputFilterProps = { const props: InputFilterProps = {
categoryId: 'schema', categoryId: 'schema',
value: 'schema_name', value: 'schema_name',
clearFilterByCategory: jest.fn(), updateFilter: jest.fn(),
updateFilterByCategory: jest.fn(),
...propOverrides ...propOverrides
}; };
const wrapper = shallow<InputFilter>(<InputFilter {...props} />); const wrapper = shallow<InputFilter>(<InputFilter {...props} />);
...@@ -78,29 +76,27 @@ describe('InputFilter', () => { ...@@ -78,29 +76,27 @@ describe('InputFilter', () => {
let props; let props;
let wrapper; let wrapper;
let clearCategorySpy; let updateFilterSpy;
let updateCategorySpy;
beforeAll(() => { beforeAll(() => {
const setupResult = setup(); const setupResult = setup();
props = setupResult.props; props = setupResult.props;
wrapper = setupResult.wrapper; wrapper = setupResult.wrapper;
clearCategorySpy = jest.spyOn(props, 'clearFilterByCategory'); updateFilterSpy = jest.spyOn(props, 'updateFilter');
updateCategorySpy = jest.spyOn(props, 'updateFilterByCategory');
}); });
it('calls props.clearFilterByCategory if state.value is falsy', () => { it('calls props.updateFilter if state.value is falsy', () => {
clearCategorySpy.mockClear(); updateFilterSpy.mockClear();
wrapper.setState({ value: '' }); wrapper.setState({ value: '' });
wrapper.instance().onApplyChanges({ preventDefault: jest.fn() }); wrapper.instance().onApplyChanges({ preventDefault: jest.fn() });
expect(clearCategorySpy).toHaveBeenCalledWith(props.categoryId); expect(updateFilterSpy).toHaveBeenCalledWith(props.categoryId, undefined);
}); });
it('calls props.updateFilterByCategory if state.value has a truthy value', () => { it('calls props.updateFilter if state.value has a truthy value', () => {
updateCategorySpy.mockClear(); updateFilterSpy.mockClear();
const mockValue = 'hello'; const mockValue = 'hello';
wrapper.setState({ value: mockValue }); wrapper.setState({ value: mockValue });
wrapper.instance().onApplyChanges({ preventDefault: jest.fn() }); wrapper.instance().onApplyChanges({ preventDefault: jest.fn() });
expect(updateCategorySpy).toHaveBeenCalledWith(props.categoryId, mockValue) expect(updateFilterSpy).toHaveBeenCalledWith(props.categoryId, mockValue);
}); });
}); });
...@@ -162,7 +158,7 @@ describe('InputFilter', () => { ...@@ -162,7 +158,7 @@ describe('InputFilter', () => {
...globalState, ...globalState,
search: { search: {
...globalState.search, ...globalState.search,
selectedTab: ResourceType.table, resource: ResourceType.table,
filters: { filters: {
[ResourceType.table]: { [ResourceType.table]: {
[mockCategoryId]: mockFilters [mockCategoryId]: mockFilters
...@@ -175,7 +171,7 @@ describe('InputFilter', () => { ...@@ -175,7 +171,7 @@ describe('InputFilter', () => {
...globalState, ...globalState,
search: { search: {
...globalState.search, ...globalState.search,
selectedTab: ResourceType.user, resource: ResourceType.user,
filters: { filters: {
[ResourceType.table]: {} [ResourceType.table]: {}
} }
...@@ -212,12 +208,8 @@ describe('InputFilter', () => { ...@@ -212,12 +208,8 @@ describe('InputFilter', () => {
result = mapDispatchToProps(dispatch); result = mapDispatchToProps(dispatch);
}); });
it('sets clearFilterByCategory on the props', () => { it('sets updateFilter on the props', () => {
expect(result.clearFilterByCategory).toBeInstanceOf(Function); expect(result.updateFilter).toBeInstanceOf(Function);
});
it('sets updateFilterByCategory on the props', () => {
expect(result.updateFilterByCategory).toBeInstanceOf(Function);
}); });
}); });
}); });
...@@ -63,7 +63,7 @@ export class SearchFilter extends React.Component<SearchFilterProps> { ...@@ -63,7 +63,7 @@ export class SearchFilter extends React.Component<SearchFilterProps> {
}; };
export const mapStateToProps = (state: GlobalState) => { export const mapStateToProps = (state: GlobalState) => {
const resourceType = state.search.selectedTab; const resourceType = state.search.resource;
const filterCategories = getFilterConfigByResource(resourceType); const filterCategories = getFilterConfigByResource(resourceType);
const filterSections = []; const filterSections = [];
......
...@@ -156,7 +156,7 @@ describe('mapStateToProps', () => { ...@@ -156,7 +156,7 @@ describe('mapStateToProps', () => {
...globalState, ...globalState,
search: { search: {
...globalState.search, ...globalState.search,
selectedTab: ResourceType.table, resource: ResourceType.table,
filters: { filters: {
[ResourceType.table]: { [ResourceType.table]: {
[mockSchemaId]: mockSchemaValue, [mockSchemaId]: mockSchemaValue,
...@@ -170,10 +170,10 @@ describe('mapStateToProps', () => { ...@@ -170,10 +170,10 @@ describe('mapStateToProps', () => {
let getFilterConfigByResourceSpy; let getFilterConfigByResourceSpy;
let result; let result;
it('calls getFilterConfigByResource with selectedTab', () => { it('calls getFilterConfigByResource with resource', () => {
getFilterConfigByResourceSpy = jest.spyOn(ConfigUtils, 'getFilterConfigByResource').mockReturnValue(MOCK_CATEGORY_CONFIG); getFilterConfigByResourceSpy = jest.spyOn(ConfigUtils, 'getFilterConfigByResource').mockReturnValue(MOCK_CATEGORY_CONFIG);
mapStateToProps(mockStateWithFilters); mapStateToProps(mockStateWithFilters);
expect(getFilterConfigByResourceSpy).toHaveBeenCalledWith(mockStateWithFilters.search.selectedTab); expect(getFilterConfigByResourceSpy).toHaveBeenCalledWith(mockStateWithFilters.search.resource);
}); });
it('sets expected filterSections on the result', () => { it('sets expected filterSections on the result', () => {
......
...@@ -12,18 +12,17 @@ import SearchFilter from './SearchFilter'; ...@@ -12,18 +12,17 @@ import SearchFilter from './SearchFilter';
import SearchPanel from './SearchPanel'; import SearchPanel from './SearchPanel';
import { GlobalState } from 'ducks/rootReducer'; import { GlobalState } from 'ducks/rootReducer';
import { setPageIndex, setResource, urlDidUpdate } from 'ducks/search/reducer'; import { submitSearchResource, urlDidUpdate } from 'ducks/search/reducer';
import { import {
DashboardSearchResults, DashboardSearchResults,
SearchResults, SearchResults,
SetPageIndexRequest, SubmitSearchResourceRequest,
SetResourceRequest,
TableSearchResults, TableSearchResults,
UrlDidUpdateRequest, UrlDidUpdateRequest,
UserSearchResults, UserSearchResults,
} from 'ducks/search/types'; } from 'ducks/search/types';
import { Resource, ResourceType } from 'interfaces'; import { Resource, ResourceType, SearchType } from 'interfaces';
// TODO: Use css-modules instead of 'import' // TODO: Use css-modules instead of 'import'
import './styles.scss'; import './styles.scss';
...@@ -43,7 +42,7 @@ import { ...@@ -43,7 +42,7 @@ import {
export interface StateFromProps { export interface StateFromProps {
hasFilters: boolean; hasFilters: boolean;
searchTerm: string; searchTerm: string;
selectedTab: ResourceType; resource: ResourceType;
isLoading: boolean; isLoading: boolean;
tables: TableSearchResults; tables: TableSearchResults;
dashboards: DashboardSearchResults; dashboards: DashboardSearchResults;
...@@ -51,8 +50,7 @@ export interface StateFromProps { ...@@ -51,8 +50,7 @@ export interface StateFromProps {
} }
export interface DispatchFromProps { export interface DispatchFromProps {
setResource: (resource: ResourceType) => SetResourceRequest; setPageIndex: (pageIndex: number) => SubmitSearchResourceRequest;
setPageIndex: (pageIndex: number) => SetPageIndexRequest;
urlDidUpdate: (urlSearch: UrlSearch) => UrlDidUpdateRequest; urlDidUpdate: (urlSearch: UrlSearch) => UrlDidUpdateRequest;
} }
...@@ -76,7 +74,7 @@ export class SearchPage extends React.Component<SearchPageProps> { ...@@ -76,7 +74,7 @@ export class SearchPage extends React.Component<SearchPageProps> {
} }
renderSearchResults = () => { renderSearchResults = () => {
switch(this.props.selectedTab) { switch(this.props.resource) {
case ResourceType.table: case ResourceType.table:
return this.getTabContent(this.props.tables, ResourceType.table); return this.getTabContent(this.props.tables, ResourceType.table);
case ResourceType.user: case ResourceType.user:
...@@ -183,11 +181,11 @@ export class SearchPage extends React.Component<SearchPageProps> { ...@@ -183,11 +181,11 @@ export class SearchPage extends React.Component<SearchPageProps> {
} }
export const mapStateToProps = (state: GlobalState) => { export const mapStateToProps = (state: GlobalState) => {
const resourceFilters = state.search.filters[state.search.selectedTab]; const resourceFilters = state.search.filters[state.search.resource];
return { return {
hasFilters: resourceFilters && Object.keys(resourceFilters).length > 0, hasFilters: resourceFilters && Object.keys(resourceFilters).length > 0,
searchTerm: state.search.search_term, searchTerm: state.search.search_term,
selectedTab: state.search.selectedTab, resource: state.search.resource,
isLoading: state.search.isLoading, isLoading: state.search.isLoading,
tables: state.search.tables, tables: state.search.tables,
users: state.search.users, users: state.search.users,
...@@ -196,7 +194,10 @@ export const mapStateToProps = (state: GlobalState) => { ...@@ -196,7 +194,10 @@ export const mapStateToProps = (state: GlobalState) => {
}; };
export const mapDispatchToProps = (dispatch: any) => { export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ setResource, setPageIndex, urlDidUpdate }, dispatch); return bindActionCreators({
urlDidUpdate,
setPageIndex: (pageIndex: number) => submitSearchResource({ pageIndex, searchType: SearchType.PAGINATION, updateUrl: true }),
}, dispatch);
}; };
export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(SearchPage); export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(SearchPage);
...@@ -35,12 +35,11 @@ describe('SearchPage', () => { ...@@ -35,12 +35,11 @@ describe('SearchPage', () => {
const props: SearchPageProps = { const props: SearchPageProps = {
hasFilters: false, hasFilters: false,
searchTerm: globalState.search.search_term, searchTerm: globalState.search.search_term,
selectedTab: ResourceType.table, resource: ResourceType.table,
isLoading: false, isLoading: false,
dashboards: globalState.search.dashboards, dashboards: globalState.search.dashboards,
tables: globalState.search.tables, tables: globalState.search.tables,
users: globalState.search.users, users: globalState.search.users,
setResource: jest.fn(),
setPageIndex: jest.fn(), setPageIndex: jest.fn(),
urlDidUpdate: jest.fn(), urlDidUpdate: jest.fn(),
...routerProps, ...routerProps,
...@@ -55,7 +54,7 @@ describe('SearchPage', () => { ...@@ -55,7 +54,7 @@ describe('SearchPage', () => {
let wrapper; let wrapper;
beforeAll(() => { beforeAll(() => {
const setupResult = setup(null, { const setupResult = setup(null, {
search: '/search?searchTerm=testName&selectedTab=table&pageIndex=1', search: '/search?searchTerm=testName&resource=table&pageIndex=1',
}); });
props = setupResult.props; props = setupResult.props;
wrapper = setupResult.wrapper; wrapper = setupResult.wrapper;
...@@ -74,7 +73,7 @@ describe('SearchPage', () => { ...@@ -74,7 +73,7 @@ describe('SearchPage', () => {
let mockPrevProps; let mockPrevProps;
beforeAll(() => { beforeAll(() => {
const setupResult = setup(null, { const setupResult = setup(null, {
search: '/search?searchTerm=testName&selectedTab=table&pageIndex=1', search: '/search?searchTerm=testName&resource=table&pageIndex=1',
}); });
props = setupResult.props; props = setupResult.props;
wrapper = setupResult.wrapper; wrapper = setupResult.wrapper;
...@@ -82,7 +81,7 @@ describe('SearchPage', () => { ...@@ -82,7 +81,7 @@ describe('SearchPage', () => {
mockPrevProps = { mockPrevProps = {
searchTerm: 'previous', searchTerm: 'previous',
location: { location: {
search: '/search?searchTerm=previous&selectedTab=table&pageIndex=0', search: '/search?searchTerm=previous&resource=table&pageIndex=0',
pathname: 'mockstr', pathname: 'mockstr',
state: jest.fn(), state: jest.fn(),
hash: 'mockstr', hash: 'mockstr',
...@@ -224,7 +223,7 @@ describe('SearchPage', () => { ...@@ -224,7 +223,7 @@ describe('SearchPage', () => {
describe('renderSearchResults', () => { describe('renderSearchResults', () => {
it('renders the correct content for table resources', () => { it('renders the correct content for table resources', () => {
const { props, wrapper } = setup({ const { props, wrapper } = setup({
selectedTab: ResourceType.table resource: ResourceType.table
}); });
const getTabContentSpy = jest.spyOn(wrapper.instance(), 'getTabContent'); const getTabContentSpy = jest.spyOn(wrapper.instance(), 'getTabContent');
shallow(wrapper.instance().renderSearchResults()); shallow(wrapper.instance().renderSearchResults());
...@@ -233,7 +232,7 @@ describe('SearchPage', () => { ...@@ -233,7 +232,7 @@ describe('SearchPage', () => {
it('renders the correct content for user resources', () => { it('renders the correct content for user resources', () => {
const { props, wrapper } = setup({ const { props, wrapper } = setup({
selectedTab: ResourceType.user resource: ResourceType.user
}); });
const getTabContentSpy = jest.spyOn(wrapper.instance(), 'getTabContent'); const getTabContentSpy = jest.spyOn(wrapper.instance(), 'getTabContent');
shallow(wrapper.instance().renderSearchResults()); shallow(wrapper.instance().renderSearchResults());
...@@ -242,16 +241,16 @@ describe('SearchPage', () => { ...@@ -242,16 +241,16 @@ describe('SearchPage', () => {
it('renders the correct content for dashboard resources', () => { it('renders the correct content for dashboard resources', () => {
const { props, wrapper } = setup({ const { props, wrapper } = setup({
selectedTab: ResourceType.dashboard resource: ResourceType.dashboard
}); });
const getTabContentSpy = jest.spyOn(wrapper.instance(), 'getTabContent'); const getTabContentSpy = jest.spyOn(wrapper.instance(), 'getTabContent');
shallow(wrapper.instance().renderSearchResults()); shallow(wrapper.instance().renderSearchResults());
expect(getTabContentSpy).toHaveBeenCalledWith(props.dashboards, ResourceType.dashboard); expect(getTabContentSpy).toHaveBeenCalledWith(props.dashboards, ResourceType.dashboard);
}); });
it('renders null for an invalid selectedTab', () => { it('renders null for an invalid resource', () => {
const { props, wrapper } = setup({ const { props, wrapper } = setup({
selectedTab: null resource: null
}); });
const renderedSearchResults = wrapper.instance().renderSearchResults(); const renderedSearchResults = wrapper.instance().renderSearchResults();
expect(renderedSearchResults).toBe(null); expect(renderedSearchResults).toBe(null);
...@@ -313,10 +312,6 @@ describe('mapDispatchToProps', () => { ...@@ -313,10 +312,6 @@ describe('mapDispatchToProps', () => {
result = mapDispatchToProps(dispatch); result = mapDispatchToProps(dispatch);
}); });
it('sets setResource on the props', () => {
expect(result.setResource).toBeInstanceOf(Function);
});
it('sets setPageIndex on the props', () => { it('sets setPageIndex on the props', () => {
expect(result.setPageIndex).toBeInstanceOf(Function); expect(result.setPageIndex).toBeInstanceOf(Function);
}); });
...@@ -336,8 +331,8 @@ describe('mapStateToProps', () => { ...@@ -336,8 +331,8 @@ describe('mapStateToProps', () => {
expect(result.searchTerm).toEqual(globalState.search.search_term); expect(result.searchTerm).toEqual(globalState.search.search_term);
}); });
it('sets selectedTab on the props', () => { it('sets resource on the props', () => {
expect(result.selectedTab).toEqual(globalState.search.selectedTab); expect(result.resource).toEqual(globalState.search.resource);
}); });
it('sets isLoading on the props', () => { it('sets isLoading on the props', () => {
...@@ -361,7 +356,7 @@ describe('mapStateToProps', () => { ...@@ -361,7 +356,7 @@ describe('mapStateToProps', () => {
const testState = { const testState = {
...globalState ...globalState
}; };
testState.search.selectedTab = ResourceType.user; testState.search.resource = ResourceType.user;
testState.search.filters = defaultEmptyFilters; testState.search.filters = defaultEmptyFilters;
result = mapStateToProps(testState); result = mapStateToProps(testState);
expect(result.hasFilters).toBeFalsy(); expect(result.hasFilters).toBeFalsy();
...@@ -371,7 +366,7 @@ describe('mapStateToProps', () => { ...@@ -371,7 +366,7 @@ describe('mapStateToProps', () => {
const testState = { const testState = {
...globalState ...globalState
}; };
testState.search.selectedTab = ResourceType.table; testState.search.resource = ResourceType.table;
testState.search.filters = datasetFilterExample; testState.search.filters = datasetFilterExample;
result = mapStateToProps(testState); result = mapStateToProps(testState);
expect(result.hasFilters).toBe(true); expect(result.hasFilters).toBe(true);
......
import * as React from 'react'; import * as React from 'react';
import { bindActionCreators } from 'redux' import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { ResourceType, Tag } from 'interfaces'; import { ResourceType, Tag, SearchType } from 'interfaces';
import { setSearchInputByResource, SetSearchInputRequest } from 'ducks/search/filters/reducer'; import { submitSearchResource } from 'ducks/search/reducer';
import { SubmitSearchResourceRequest } from 'ducks/search/types';
import { logClick } from 'ducks/utilMethods'; import { logClick } from 'ducks/utilMethods';
import './styles.scss'; import './styles.scss';
...@@ -14,7 +15,7 @@ interface OwnProps { ...@@ -14,7 +15,7 @@ interface OwnProps {
} }
export interface DispatchFromProps { export interface DispatchFromProps {
searchTag: (tagName: string) => SetSearchInputRequest; searchTag: (tagName: string) => SubmitSearchResourceRequest;
} }
export type TagInfoProps = OwnProps & DispatchFromProps; export type TagInfoProps = OwnProps & DispatchFromProps;
...@@ -60,9 +61,14 @@ export class TagInfo extends React.Component<TagInfoProps> { ...@@ -60,9 +61,14 @@ export class TagInfo extends React.Component<TagInfoProps> {
export const mapDispatchToProps = (dispatch: any) => { export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ return bindActionCreators({
/* Note: Pattern intentionally isolates component from extraneous hardcoded parameters */ searchTag: (tagName: string) => submitSearchResource({
/* Note: This will have to be extended to all resources that support tags */ resourceFilters: { 'tag': tagName },
searchTag: (tagName: string) => setSearchInputByResource({ 'tag': tagName }, ResourceType.table, 0, '') resource: ResourceType.table,
pageIndex: 0,
searchTerm: '',
searchType: SearchType.FILTER,
updateUrl: true,
})
}, dispatch); }, dispatch);
}; };
......
...@@ -5,8 +5,8 @@ import { withRouter } from 'react-router-dom'; ...@@ -5,8 +5,8 @@ import { withRouter } from 'react-router-dom';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { GlobalState } from 'ducks/rootReducer'; import { GlobalState } from 'ducks/rootReducer';
import { clearSearch, submitSearch, getInlineResultsDebounce, selectInlineResult } from 'ducks/search/reducer'; import { submitSearch, getInlineResultsDebounce, selectInlineResult } from 'ducks/search/reducer';
import { ClearSearchRequest, SubmitSearchRequest, InlineSearchRequest, InlineSearchSelect } from 'ducks/search/types'; import { SubmitSearchRequest, InlineSearchRequest, InlineSearchSelect } from 'ducks/search/types';
import { ResourceType } from 'interfaces'; import { ResourceType } from 'interfaces';
...@@ -25,7 +25,7 @@ export interface StateFromProps { ...@@ -25,7 +25,7 @@ export interface StateFromProps {
} }
export interface DispatchFromProps { export interface DispatchFromProps {
clearSearch?: () => ClearSearchRequest; clearSearch?: () => SubmitSearchRequest;
submitSearch: (searchTerm: string) => SubmitSearchRequest; submitSearch: (searchTerm: string) => SubmitSearchRequest;
onInputChange: (term: string) => InlineSearchRequest; onInputChange: (term: string) => InlineSearchRequest;
onSelectInlineResult: (resourceType: ResourceType, searchTerm: string, updateUrl: boolean) => InlineSearchSelect; onSelectInlineResult: (resourceType: ResourceType, searchTerm: string, updateUrl: boolean) => InlineSearchSelect;
...@@ -190,8 +190,8 @@ export const mapDispatchToProps = (dispatch: any, ownProps) => { ...@@ -190,8 +190,8 @@ export const mapDispatchToProps = (dispatch: any, ownProps) => {
const updateStateOnClear = ownProps.history.location.pathname === '/search'; const updateStateOnClear = ownProps.history.location.pathname === '/search';
return bindActionCreators({ return bindActionCreators({
clearSearch: updateStateOnClear ? clearSearch : null, clearSearch: updateStateOnClear ? () => submitSearch({ useFilters, searchTerm: '' }) : null,
submitSearch: (searchTerm: string) => { return submitSearch(searchTerm, useFilters) }, submitSearch: (searchTerm: string) => submitSearch({ searchTerm, useFilters }),
onInputChange: getInlineResultsDebounce, onInputChange: getInlineResultsDebounce,
onSelectInlineResult: selectInlineResult onSelectInlineResult: selectInlineResult
}, dispatch); }, dispatch);
......
...@@ -23,20 +23,20 @@ import { createIssueWatcher, getIssuesWatcher } from './issue/sagas'; ...@@ -23,20 +23,20 @@ import { createIssueWatcher, getIssuesWatcher } from './issue/sagas';
import { getPopularTablesWatcher } from './popularTables/sagas'; import { getPopularTablesWatcher } from './popularTables/sagas';
// Search // Search
import { import {
clearSearchWatcher,
filterWatcher,
filterWatcher2,
inlineSearchWatcher, inlineSearchWatcher,
inlineSearchWatcherDebounce, inlineSearchWatcherDebounce,
loadPreviousSearchWatcher, loadPreviousSearchWatcher,
searchAllWatcher, searchAllWatcher,
searchResourceWatcher, searchResourceWatcher,
setPageIndexWatcher,
setResourceWatcher,
selectInlineResultsWatcher, selectInlineResultsWatcher,
submitSearchWatcher, submitSearchWatcher,
submitSearchResourceWatcher,
updateSearchStateWatcher,
urlDidUpdateWatcher urlDidUpdateWatcher
} from './search/sagas'; } from './search/sagas';
import {
filterWatcher,
} from './search/filters/sagas';
// TableDetail // TableDetail
import { updateTableOwnerWatcher } from './tableMetadata/owners/sagas'; import { updateTableOwnerWatcher } from './tableMetadata/owners/sagas';
...@@ -74,18 +74,16 @@ export default function* rootSaga() { ...@@ -74,18 +74,16 @@ export default function* rootSaga() {
getIssuesWatcher(), getIssuesWatcher(),
createIssueWatcher(), createIssueWatcher(),
// Search // Search
clearSearchWatcher(),
filterWatcher(), filterWatcher(),
filterWatcher2(),
inlineSearchWatcher(), inlineSearchWatcher(),
inlineSearchWatcherDebounce(), inlineSearchWatcherDebounce(),
loadPreviousSearchWatcher(), loadPreviousSearchWatcher(),
searchAllWatcher(), searchAllWatcher(),
searchResourceWatcher(), searchResourceWatcher(),
selectInlineResultsWatcher(), selectInlineResultsWatcher(),
setPageIndexWatcher(),
setResourceWatcher(),
submitSearchWatcher(), submitSearchWatcher(),
submitSearchResourceWatcher(),
updateSearchStateWatcher(),
urlDidUpdateWatcher(), urlDidUpdateWatcher(),
// PopularTables // PopularTables
getPopularTablesWatcher(), getPopularTablesWatcher(),
......
import { SubmitSearchResource, SubmitSearchResourceRequest } from 'ducks/search/types';
import { ResourceType } from 'interfaces'; import { ResourceType } from 'interfaces';
import { filterFromObj } from 'ducks/utilMethods';
/* ACTION TYPES */ /* ACTION TYPES */
export enum UpdateSearchFilter { export enum UpdateSearchFilter {
CLEAR_ALL = 'amundsen/search/filter/CLEAR_ALL', REQUEST = 'amundsen/search/filter/UPDATE_SEARCH_FILTER_REQUEST',
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 type UpdateFilterPayload = {
export interface ClearFilterRequest {
payload: {
categoryId: string; categoryId: string;
}; value: string | FilterOptions | undefined;
type: UpdateSearchFilter.CLEAR_CATEGORY; }
};
export interface SetSearchInputRequest {
payload: {
filters: ResourceFilterReducerState;
pageIndex?: number;
resourceType: ResourceType;
term?: string;
};
type: UpdateSearchFilter.SET_BY_RESOURCE;
};
export interface UpdateFilterRequest { export interface UpdateFilterRequest {
payload: { payload: UpdateFilterPayload;
categoryId: string; type: UpdateSearchFilter.REQUEST;
value: string | FilterOptions;
};
type: UpdateSearchFilter.UPDATE_CATEGORY;
}; };
/* ACTIONS */ /* ACTIONS */
export function clearAllFilters(): ClearAllFiltersRequest { export function updateFilterByCategory({ categoryId, value }: UpdateFilterPayload): UpdateFilterRequest {
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 { return {
payload: { payload: {
categoryId, categoryId,
value value
}, },
type: UpdateSearchFilter.UPDATE_CATEGORY, type: UpdateSearchFilter.REQUEST,
}; };
}; };
...@@ -98,31 +43,17 @@ export const initialFilterState: FilterReducerState = { ...@@ -98,31 +43,17 @@ export const initialFilterState: FilterReducerState = {
[ResourceType.table]: initialTableFilterState, [ResourceType.table]: initialTableFilterState,
}; };
export default function reducer(state: FilterReducerState = initialFilterState, action, resourceType: ResourceType): FilterReducerState { export default function reducer(state: FilterReducerState = initialFilterState, action): FilterReducerState {
const resourceFilters = state[resourceType]; switch (action.type) {
const { payload, type } = action; case SubmitSearchResource.REQUEST:
const { payload } = <SubmitSearchResourceRequest>action;
switch (type) { if (payload.resource && payload.resourceFilters) {
case UpdateSearchFilter.CLEAR_ALL:
return initialFilterState;
case UpdateSearchFilter.CLEAR_CATEGORY:
return {
...state,
[resourceType]: filterFromObj(resourceFilters, [payload.categoryId])
};
case UpdateSearchFilter.SET_BY_RESOURCE:
return { return {
...state, ...state,
[payload.resourceType]: payload.filters [payload.resource]: payload.resourceFilters
}; };
case UpdateSearchFilter.UPDATE_CATEGORY:
return {
...state,
[resourceType]: {
...resourceFilters,
[payload.categoryId]: payload.value
} }
}; return state;
default: default:
return state; return state;
}; };
......
import { SagaIterator } from 'redux-saga';
import { takeEvery, put, select } from 'redux-saga/effects';
import { SearchType } from 'interfaces';
import {
submitSearchResource,
} from 'ducks/search/reducer';
import { getSearchState } from 'ducks/search/utils';
import { filterFromObj } from 'ducks/utilMethods';
import {
UpdateSearchFilter,
UpdateFilterRequest,
} from './reducer';
/**
* Listens to actions triggers by user updates to the filter state..
*/
export function* filterWatcher(): SagaIterator {
/*
TODO: If we want to minimize api calls on checkbox quick-select,
we will have to debounce and accumulate filter updates elsewhere.
To be revisited when we have more checkbox filters
*/
yield takeEvery(UpdateSearchFilter.REQUEST, filterWorker);
};
/*
* Generates new filter shape from action payload.
* Then executes a search on current resource based with new filters and current search state values.
*/
export function* filterWorker(action: UpdateFilterRequest): SagaIterator {
const { categoryId, value } = action.payload;
const state = yield select(getSearchState);
const { search_term, resource, filters } = state;
let resourceFilters = {
...filters[resource],
}
if (value === undefined) {
resourceFilters = filterFromObj(resourceFilters, [categoryId])
}
else {
resourceFilters[categoryId] = value;
}
yield put(submitSearchResource({
resource,
resourceFilters,
searchTerm: search_term,
pageIndex: 0,
searchType: SearchType.FILTER,
updateUrl: true,
}));
};
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);
});
});
});
import { ResourceType, SearchType } from 'interfaces';
import { submitSearchResource } from 'ducks/search/reducer';
import reducer, {
updateFilterByCategory,
initialFilterState,
FilterReducerState,
UpdateSearchFilter,
} from '../reducer';
describe('filters reducer', () => {
describe('actions', () => {
it('updateFilterByCategory - returns the action to update the filters for a given category', () => {;
const testCategory = 'column';
const testValue = 'column_name';
const action = updateFilterByCategory({ categoryId: testCategory, value: testValue });
const { payload } = action;
expect(action.type).toBe(UpdateSearchFilter.REQUEST);
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' })).toBe(testState);
});
describe('handles SubmitSearchResource.REQUEST', () => {
it('updates the filter state if request contains filter information', () => {
const givenResource = ResourceType.table;
const givenFilters = {'database': { 'testDb': true }}
const result = reducer(initialFilterState, submitSearchResource({
resource: givenResource,
resourceFilters: givenFilters,
searchTerm: 'test',
pageIndex: 0,
searchType: SearchType.FILTER,
updateUrl: true,
}));
expect(result[givenResource]).toBe(givenFilters);
});
it('does not update the filter state if request does not contains filter information', () => {
const givenResource = ResourceType.table;
const givenFilters = {'database': { 'testDb': true }}
const result = reducer(testState, submitSearchResource({
pageIndex: 1,
searchType: SearchType.PAGINATION,
updateUrl: true,
}));
expect(result).toBe(testState);
});
})
});
});
import { testSaga } from 'redux-saga-test-plan';
import { takeEvery } from 'redux-saga/effects';
import { submitSearchResource } from 'ducks/search/reducer';
import * as SearchUtils from 'ducks/search/utils';
import globalState from 'fixtures/globalState';
import { datasetFilterExample } from 'fixtures/search/filters';
import { ResourceType, SearchType } from 'interfaces';
import {
updateFilterByCategory,
UpdateSearchFilter,
} from '../reducer';
import * as Sagas from '../sagas';
describe('filter sagas', () => {
describe('filterWatcher', () => {
it('debounces update filter actions with filterWorker', () => {
testSaga(Sagas.filterWatcher)
.next()
.is(takeEvery(UpdateSearchFilter.REQUEST, Sagas.filterWorker))
.next().isDone();
});
});
describe('filterWorker', () => {
let mockSearchStateWithFilters;
beforeAll(() => {
mockSearchStateWithFilters = {
...globalState.search,
filters: datasetFilterExample,
};
});
/* TODO: Library has issue rectifying {} vs [Object] */
/*it('puts expected actions for clearing a filter', () => {
testSaga(Sagas.filterWorker, updateFilterByCategory({ categoryId: 'database', value: undefined }))
.next().select(SearchUtils.getSearchState).next(mockSearchStateWithFilters)
.put(submitSearchResource({
resource: mockSearchStateWithFilters.resource,
resourceFilters: {},
searchTerm: mockSearchStateWithFilters.search_term,
pageIndex: 0,
searchType: SearchType.FILTER,
updateUrl: true,
}))
.next().isDone();
});*/
it('puts expected actions for updating a filter', () => {
const testCategoryId = 'database';
const testValue = { 'hive': true };
testSaga(Sagas.filterWorker, updateFilterByCategory({ categoryId: testCategoryId, value: testValue }))
.next().select(SearchUtils.getSearchState).next(globalState.search)
.put(submitSearchResource({
resource: mockSearchStateWithFilters.resource,
resourceFilters: { [testCategoryId]: testValue },
searchTerm: mockSearchStateWithFilters.search_term,
pageIndex: 0,
searchType: SearchType.FILTER,
updateUrl: true,
}))
.next().isDone();
});
});
});
...@@ -2,13 +2,12 @@ import { ResourceType, SearchType} from 'interfaces'; ...@@ -2,13 +2,12 @@ import { ResourceType, SearchType} from 'interfaces';
import { Search as UrlSearch } from 'history'; import { Search as UrlSearch } from 'history';
import filterReducer, { initialFilterState, UpdateSearchFilter, FilterReducerState } from './filters/reducer'; import filterReducer, { initialFilterState, FilterReducerState } from './filters/reducer';
import { import {
DashboardSearchResults, DashboardSearchResults,
SearchAll, SearchAll,
SearchAllRequest, SearchAllRequest,
SearchAllReset,
SearchAllResponse, SearchAllResponse,
SearchAllResponsePayload, SearchAllResponsePayload,
SearchResource, SearchResource,
...@@ -24,18 +23,24 @@ import { ...@@ -24,18 +23,24 @@ import {
InlineSearchUpdate, InlineSearchUpdate,
TableSearchResults, TableSearchResults,
UserSearchResults, UserSearchResults,
ClearSearch,
ClearSearchRequest,
SubmitSearchRequest, SubmitSearchRequest,
SubmitSearch, SubmitSearch,
SetResourceRequest, SubmitSearchResourcePayload,
SetResource, SubmitSearchResourceRequest,
SetPageIndexRequest, SetPageIndex, LoadPreviousSearchRequest, LoadPreviousSearch, UrlDidUpdateRequest, UrlDidUpdate, SubmitSearchResource,
LoadPreviousSearchRequest,
LoadPreviousSearch,
UpdateSearchStateRequest,
UpdateSearchStateReset,
UpdateSearchStatePayload,
UpdateSearchState,
UrlDidUpdateRequest,
UrlDidUpdate
} from './types'; } from './types';
export interface SearchReducerState { export interface SearchReducerState {
search_term: string; search_term: string;
selectedTab: ResourceType; resource: ResourceType;
isLoading: boolean; isLoading: boolean;
dashboards: DashboardSearchResults; dashboards: DashboardSearchResults;
tables: TableSearchResults; tables: TableSearchResults;
...@@ -108,6 +113,7 @@ export function getInlineResultsSuccess(inlineResults: InlineSearchResponsePaylo ...@@ -108,6 +113,7 @@ export function getInlineResultsSuccess(inlineResults: InlineSearchResponsePaylo
export function getInlineResultsFailure(): InlineSearchResponse { export function getInlineResultsFailure(): InlineSearchResponse {
return { type: InlineSearch.FAILURE }; return { type: InlineSearch.FAILURE };
}; };
export function selectInlineResult(resourceType: ResourceType, searchTerm: string, updateUrl: boolean = false): InlineSearchSelect { export function selectInlineResult(resourceType: ResourceType, searchTerm: string, updateUrl: boolean = false): InlineSearchSelect {
return { return {
payload: { payload: {
...@@ -118,6 +124,7 @@ export function selectInlineResult(resourceType: ResourceType, searchTerm: strin ...@@ -118,6 +124,7 @@ export function selectInlineResult(resourceType: ResourceType, searchTerm: strin
type: InlineSearch.SELECT type: InlineSearch.SELECT
}; };
}; };
export function updateFromInlineResult(data: InlineSearchUpdatePayload): InlineSearchUpdate { export function updateFromInlineResult(data: InlineSearchUpdatePayload): InlineSearchUpdate {
return { return {
payload: data, payload: data,
...@@ -125,36 +132,33 @@ export function updateFromInlineResult(data: InlineSearchUpdatePayload): InlineS ...@@ -125,36 +132,33 @@ export function updateFromInlineResult(data: InlineSearchUpdatePayload): InlineS
}; };
}; };
export function searchReset(): SearchAllReset { export function submitSearch({ searchTerm, useFilters } : { searchTerm: string, useFilters: boolean }): SubmitSearchRequest {
return {
type: SearchAll.RESET,
};
};
export function submitSearch(searchTerm: string, useFilters: boolean = false): SubmitSearchRequest {
return { return {
payload: { searchTerm, useFilters }, payload: { searchTerm, useFilters },
type: SubmitSearch.REQUEST, type: SubmitSearch.REQUEST,
}; };
}; };
export function clearSearch(): ClearSearchRequest { export function submitSearchResource({ resourceFilters, pageIndex, searchTerm, resource, searchType, updateUrl } : SubmitSearchResourcePayload): SubmitSearchResourceRequest {
return { return {
type: ClearSearch.REQUEST, payload: { resourceFilters, pageIndex, searchTerm, resource, searchType, updateUrl },
type: SubmitSearchResource.REQUEST,
}; };
}; };
export function setResource(resource: ResourceType, updateUrl: boolean = true): SetResourceRequest { export function updateSearchState({ filters, resource, updateUrl }: UpdateSearchStatePayload): UpdateSearchStateRequest {
return { return {
payload: { resource, updateUrl }, payload: {
type: SetResource.REQUEST, filters,
resource,
updateUrl,
},
type: UpdateSearchState.REQUEST,
}; };
}; };
export function resetSearchState(): UpdateSearchStateReset {
export function setPageIndex(pageIndex: number, updateUrl: boolean = true): SetPageIndexRequest {
return { return {
payload: { pageIndex, updateUrl }, type: UpdateSearchState.RESET,
type: SetPageIndex.REQUEST,
}; };
}; };
...@@ -164,14 +168,13 @@ export function loadPreviousSearch(): LoadPreviousSearchRequest { ...@@ -164,14 +168,13 @@ export function loadPreviousSearch(): LoadPreviousSearchRequest {
}; };
}; };
export function urlDidUpdate(urlSearch: UrlSearch): UrlDidUpdateRequest{ export function urlDidUpdate(urlSearch: UrlSearch): UrlDidUpdateRequest {
return { return {
payload: { urlSearch }, payload: { urlSearch },
type: UrlDidUpdate.REQUEST, type: UrlDidUpdate.REQUEST,
}; };
}; };
/* REDUCER */ /* REDUCER */
export const initialInlineResultsState = { export const initialInlineResultsState = {
isLoading: false, isLoading: false,
...@@ -189,7 +192,7 @@ export const initialInlineResultsState = { ...@@ -189,7 +192,7 @@ export const initialInlineResultsState = {
export const initialState: SearchReducerState = { export const initialState: SearchReducerState = {
search_term: '', search_term: '',
isLoading: false, isLoading: false,
selectedTab: ResourceType.table, resource: ResourceType.table,
dashboards: { dashboards: {
page_index: 0, page_index: 0,
results: [], results: [],
...@@ -211,28 +214,29 @@ export const initialState: SearchReducerState = { ...@@ -211,28 +214,29 @@ export const initialState: SearchReducerState = {
export default function reducer(state: SearchReducerState = initialState, action): SearchReducerState { export default function reducer(state: SearchReducerState = initialState, action): SearchReducerState {
switch (action.type) { switch (action.type) {
case UpdateSearchFilter.SET_BY_RESOURCE: case SubmitSearch.REQUEST:
return { return {
...state, ...state,
search_term: action.payload.term, isLoading: true,
filters: filterReducer(state.filters, action, state.selectedTab), search_term: action.payload.searchTerm,
} }
case UpdateSearchFilter.CLEAR_ALL: case SubmitSearchResource.REQUEST:
return { return {
...state, ...state,
filters: filterReducer(state.filters, action, state.selectedTab), isLoading: true,
filters: filterReducer(state.filters, action),
search_term: action.payload.searchTerm || state.search_term,
} }
case UpdateSearchFilter.CLEAR_CATEGORY: case UpdateSearchState.REQUEST:
case UpdateSearchFilter.UPDATE_CATEGORY: const payload = action.payload;
return { return {
...state, ...state,
isLoading: true, filters: payload.filters || state.filters,
filters: filterReducer(state.filters, action, state.selectedTab), resource: payload.resource || state.resource,
} }
case SearchAll.RESET: case UpdateSearchState.RESET:
return initialState; return initialState;
case SearchAll.REQUEST: case SearchAll.REQUEST:
// updates search term to reflect action
return { return {
...state, ...state,
inlineResults: { inlineResults: {
...@@ -273,16 +277,11 @@ export default function reducer(state: SearchReducerState = initialState, action ...@@ -273,16 +277,11 @@ export default function reducer(state: SearchReducerState = initialState, action
...initialState, ...initialState,
search_term: state.search_term, search_term: state.search_term,
}; };
case SetResource.REQUEST:
return {
...state,
selectedTab: (<SetResourceRequest>action).payload.resource
};
case InlineSearch.UPDATE: case InlineSearch.UPDATE:
const { searchTerm, selectedTab, tables, users } = (<InlineSearchUpdate>action).payload; const { searchTerm, resource, tables, users } = (<InlineSearchUpdate>action).payload;
return { return {
...state, ...state,
selectedTab, resource,
tables, tables,
users, users,
search_term: searchTerm, search_term: searchTerm,
......
...@@ -8,8 +8,6 @@ import { ResourceType, SearchType } from 'interfaces'; ...@@ -8,8 +8,6 @@ import { ResourceType, SearchType } from 'interfaces';
import * as API from './api/v0'; import * as API from './api/v0';
import { import {
ClearSearch,
ClearSearchRequest,
LoadPreviousSearch, LoadPreviousSearch,
LoadPreviousSearchRequest, LoadPreviousSearchRequest,
SearchAll, SearchAll,
...@@ -18,10 +16,12 @@ import { ...@@ -18,10 +16,12 @@ import {
SearchResourceRequest, SearchResourceRequest,
InlineSearch, InlineSearch,
InlineSearchRequest, InlineSearchRequest,
SetPageIndex,
SetPageIndexRequest, SetResource, SetResourceRequest,
SubmitSearch, SubmitSearch,
SubmitSearchRequest, SubmitSearchRequest,
SubmitSearchResource,
SubmitSearchResourceRequest,
UpdateSearchState,
UpdateSearchStateRequest,
UrlDidUpdate, UrlDidUpdate,
UrlDidUpdateRequest, UrlDidUpdateRequest,
} from './types'; } from './types';
...@@ -40,166 +40,84 @@ import { ...@@ -40,166 +40,84 @@ import {
getInlineResultsSuccess, getInlineResultsSuccess,
getInlineResultsFailure, getInlineResultsFailure,
updateFromInlineResult, updateFromInlineResult,
setPageIndex, setResource, updateSearchState,
submitSearchResource,
} from './reducer'; } from './reducer';
import { import {
clearAllFilters, initialFilterState,
setSearchInputByResource,
UpdateSearchFilter UpdateSearchFilter
} from './filters/reducer'; } from './filters/reducer';
import { autoSelectResource, getPageIndex, getSearchState } from './utils'; import { autoSelectResource, getPageIndex, getSearchState } from './utils';
import { BrowserHistory, updateSearchUrl } from 'utils/navigationUtils'; import { BrowserHistory, updateSearchUrl } from 'utils/navigationUtils';
/** //////////////////////////////////////////////////////////////////////////////
* Listens to actions triggers by user updates to the filter state. // SEARCH SAGAS
* For better user experience debounce the start of the worker as multiple updates can happen in < 1 second. // The actions that trigger these sagas are fired directly from components.
*/ //////////////////////////////////////////////////////////////////////////////
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;
/* filters must reset pageIndex to 0 as the number of results is expected to change */
const pageIndex = 0;
yield put(searchResource(SearchType.FILTER, 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. * Handles workflow for any user action that causes an update to the searchTerm,
* Actions that trigger this worker will have updated the filter reducer. * which requires that all resources be re-searched.
* 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(SearchType.FILTER, term, resourceType, pageIndex));
updateSearchUrl({ term, filters: state.filters, resource: resourceType, index: pageIndex }, false);
};
export function* inlineSearchWorker(action: InlineSearchRequest): SagaIterator {
const { term } = action.payload;
try {
const [tableResponse, userResponse] = yield all([
call(API.searchResource, 0, ResourceType.table, term, {}, SearchType.INLINE_SEARCH),
call(API.searchResource, 0, ResourceType.user, term, {}, SearchType.INLINE_SEARCH),
]);
const inlineSearchResponse = {
tables: tableResponse.tables || initialInlineResultsState.tables,
users: userResponse.users || initialInlineResultsState.users,
};
yield put(getInlineResultsSuccess(inlineSearchResponse));
} catch (e) {
yield put(getInlineResultsFailure());
}
};
export function* inlineSearchWatcher(): SagaIterator {
yield takeLatest(InlineSearch.REQUEST, inlineSearchWorker);
}
export function* debounceWorker(action): SagaIterator {
yield put(getInlineResults(action.payload.term));
}
export function* inlineSearchWatcherDebounce(): SagaIterator {
yield debounce(350, InlineSearch.REQUEST_DEBOUNCE, debounceWorker);
}
export function* selectInlineResultWorker(action): SagaIterator {
const state = yield select();
const { searchTerm, resourceType, updateUrl } = action.payload;
if (state.search.inlineResults.isLoading) {
yield put(searchAll(SearchType.INLINE_SELECT, searchTerm, resourceType, 0, false))
updateSearchUrl({ term: searchTerm, filters: state.search.filters });
}
else {
if (updateUrl) {
updateSearchUrl({ resource: resourceType, term: searchTerm, index: 0, filters: state.search.filters });
}
const data = {
searchTerm,
selectedTab: resourceType,
tables: state.search.inlineResults.tables,
users: state.search.inlineResults.users,
};
yield put(updateFromInlineResult(data));
}
};
export function* selectInlineResultsWatcher(): SagaIterator {
yield takeEvery(InlineSearch.SELECT, selectInlineResultWorker);
};
export function* submitSearchWorker(action: SubmitSearchRequest): SagaIterator { export function* submitSearchWorker(action: SubmitSearchRequest): SagaIterator {
const state = yield select(getSearchState);
const { searchTerm, useFilters } = action.payload; const { searchTerm, useFilters } = action.payload;
yield put(searchAll(SearchType.SUBMIT_TERM, searchTerm, undefined, undefined, useFilters)); yield put(searchAll(!!searchTerm ? SearchType.SUBMIT_TERM : SearchType.CLEAR_TERM, searchTerm, undefined, 0, useFilters));
updateSearchUrl({ term: searchTerm, filters: state.filters });
}; };
export function* submitSearchWatcher(): SagaIterator { export function* submitSearchWatcher(): SagaIterator {
yield takeEvery(SubmitSearch.REQUEST, submitSearchWorker); yield takeLatest(SubmitSearch.REQUEST, submitSearchWorker);
}; };
export function* setResourceWorker(action: SetResourceRequest): SagaIterator { /**
const { resource, updateUrl } = action.payload; * Handles workflow for any user action that causes an update to the search input for a given resource
*/
export function* submitSearchResourceWorker(action: SubmitSearchResourceRequest): SagaIterator {
const state = yield select(getSearchState); const state = yield select(getSearchState);
let { search_term, resource } = state;
const { filters } = state;
const { pageIndex, searchType, searchTerm, updateUrl } = action.payload;
search_term = searchTerm !== undefined ? searchTerm : search_term;
resource = action.payload.resource || resource;
filters[resource] = action.payload.resourceFilters || filters[resource];
yield put(searchResource(searchType, search_term, resource, pageIndex));
if (updateUrl) { if (updateUrl) {
updateSearchUrl({ updateSearchUrl({
filters,
resource, resource,
term: state.search_term, term: search_term,
index: getPageIndex(state, resource), index: pageIndex,
filters: state.filters,
}); });
} }
}; };
export function* setResourceWatcher(): SagaIterator { export function* submitSearchResourceWatcher(): SagaIterator {
yield takeEvery(SetResource.REQUEST, setResourceWorker); yield takeEvery(SubmitSearchResource.REQUEST, submitSearchResourceWorker);
}; };
export function* setPageIndexWorker(action: SetPageIndexRequest): SagaIterator { /**
const { pageIndex, updateUrl } = action.payload; * Handles workflow for any user action that causes an update to the search state.
* Updates the search url if necessary.
*/
export function* updateSearchStateWorker(action: UpdateSearchStateRequest): SagaIterator {
const { filters, resource, updateUrl } = action.payload;
const state = yield select(getSearchState); const state = yield select(getSearchState);
yield put(searchResource(SearchType.PAGINATION, state.search_term, state.selectedTab, pageIndex));
if (updateUrl) { if (updateUrl) {
updateSearchUrl({ updateSearchUrl({
resource: resource || state.resource,
term: state.search_term, term: state.search_term,
resource: state.selectedTab, index: getPageIndex(state, resource),
index: pageIndex, filters: filters || state.filters,
filters: state.filters,
}); });
} }
}; };
export function* setPageIndexWatcher(): SagaIterator { export function* updateSearchStateWatcher(): SagaIterator {
yield takeEvery(SetPageIndex.REQUEST, setPageIndexWorker); yield takeEvery(UpdateSearchState.REQUEST, updateSearchStateWorker);
};
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(SearchType.CLEAR_TERM, '', undefined, undefined, true));
}
};
export function* clearSearchWatcher(): SagaIterator {
yield takeEvery(ClearSearch.REQUEST, clearSearchWorker);
}; };
/**
* Handles workflow for handling url updates on the /search route.
* Ensures that search state and and search results are updated based on url parameters.
*/
export function* urlDidUpdateWorker(action: UrlDidUpdateRequest): SagaIterator { export function* urlDidUpdateWorker(action: UrlDidUpdateRequest): SagaIterator {
const { urlSearch } = action.payload; const { urlSearch } = action.payload;
const { term = '', resource, index, filters } = qs.parse(urlSearch); const { term = '', resource, index, filters } = qs.parse(urlSearch);
...@@ -208,28 +126,42 @@ export function* urlDidUpdateWorker(action: UrlDidUpdateRequest): SagaIterator { ...@@ -208,28 +126,42 @@ export function* urlDidUpdateWorker(action: UrlDidUpdateRequest): SagaIterator {
const state = yield select(getSearchState); const state = yield select(getSearchState);
if (!!term && state.search_term !== term) { if (!!term && state.search_term !== term) {
yield put(searchAll(SearchType.LOAD_URL, term, resource, parsedIndex)); let updateUrl = false;
if (parsedFilters) {
updateUrl = true;
yield put(updateSearchState({ filters: {
...state.filters,
[resource]: parsedFilters
}}));
}
yield put(searchAll(SearchType.LOAD_URL, term, resource, parsedIndex, updateUrl));
} else if (!!resource) { } else if (!!resource) {
if (resource !== state.selectedTab) { if (resource !== state.resource) {
yield put(setResource(resource, false)) yield put(updateSearchState({ resource }))
} }
if (parsedFilters && !_.isEqual(state.filters[resource], parsedFilters)) { if (parsedFilters && !_.isEqual(state.filters[resource], parsedFilters)) {
/* This will update filter state + search resource */ yield put(submitSearchResource({
yield put(setSearchInputByResource(parsedFilters, resource, parsedIndex, term)); resource,
searchTerm: term,
resourceFilters: parsedFilters,
pageIndex: parsedIndex,
searchType: SearchType.LOAD_URL
}));
}
else if (!isNaN(parsedIndex) && parsedIndex !== getPageIndex(state, resource)) {
yield put(submitSearchResource({ pageIndex: parsedIndex, searchType: SearchType.LOAD_URL }));
} }
} 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));
} }
}; };
export function* urlDidUpdateWatcher(): SagaIterator { export function* urlDidUpdateWatcher(): SagaIterator {
yield takeEvery(UrlDidUpdate.REQUEST, urlDidUpdateWorker); yield takeEvery(UrlDidUpdate.REQUEST, urlDidUpdateWorker);
}; };
/**
* Handles workflow for user actions on navigations components.
* Leverages BrowserHistory or updates search url accordingly.
*/
export function* loadPreviousSearchWorker(action: LoadPreviousSearchRequest): SagaIterator { export function* loadPreviousSearchWorker(action: LoadPreviousSearchRequest): SagaIterator {
const state = yield select(getSearchState); const state = yield select(getSearchState);
if (state.search_term === "") { if (state.search_term === "") {
...@@ -238,7 +170,7 @@ export function* loadPreviousSearchWorker(action: LoadPreviousSearchRequest): Sa ...@@ -238,7 +170,7 @@ export function* loadPreviousSearchWorker(action: LoadPreviousSearchRequest): Sa
} }
updateSearchUrl({ updateSearchUrl({
term: state.search_term, term: state.search_term,
resource: state.selectedTab, resource: state.resource,
index: getPageIndex(state), index: getPageIndex(state),
filters: state.filters, filters: state.filters,
}); });
...@@ -248,10 +180,10 @@ export function* loadPreviousSearchWatcher(): SagaIterator { ...@@ -248,10 +180,10 @@ export function* loadPreviousSearchWatcher(): SagaIterator {
}; };
////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////
// API/END SAGAS // CORE SEARCH SAGAS
// These sagas directly trigger axios search requests. // These sagas are not called directly by any components. They should be
// The actions that trigger them should only be fired by other sagas, // called by other sagas as the final step for all use cases that will update
// and these sagas should be considered the "end" of any saga chain. // search results.
////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////
export function* searchResourceWorker(action: SearchResourceRequest): SagaIterator { export function* searchResourceWorker(action: SearchResourceRequest): SagaIterator {
...@@ -272,7 +204,7 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator { ...@@ -272,7 +204,7 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
let { resource } = action.payload; let { resource } = action.payload;
const { pageIndex, term, useFilters, searchType } = action.payload; const { pageIndex, term, useFilters, searchType } = action.payload;
if (!useFilters) { if (!useFilters) {
yield put(clearAllFilters()) yield put(updateSearchState({ filters: initialFilterState }))
} }
const state = yield select(getSearchState); const state = yield select(getSearchState);
...@@ -287,8 +219,8 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator { ...@@ -287,8 +219,8 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
call(API.searchResource, dashboardIndex, ResourceType.dashboard, term, state.filters[ResourceType.dashboard], searchType), call(API.searchResource, dashboardIndex, ResourceType.dashboard, term, state.filters[ResourceType.dashboard], searchType),
]); ]);
const searchAllResponse = { const searchAllResponse = {
resource,
search_term: term, search_term: term,
selectedTab: resource,
tables: tableResponse.tables || initialState.tables, tables: tableResponse.tables || initialState.tables,
users: userResponse.users || initialState.users, users: userResponse.users || initialState.users,
dashboards: dashboardResponse.dashboards || initialState.dashboards, dashboards: dashboardResponse.dashboards || initialState.dashboards,
...@@ -296,7 +228,7 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator { ...@@ -296,7 +228,7 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
}; };
if (resource === undefined) { if (resource === undefined) {
resource = autoSelectResource(searchAllResponse); resource = autoSelectResource(searchAllResponse);
searchAllResponse.selectedTab = resource; searchAllResponse.resource = resource;
} }
const index = getPageIndex(searchAllResponse); const index = getPageIndex(searchAllResponse);
yield put(searchAllSuccess(searchAllResponse)); yield put(searchAllSuccess(searchAllResponse));
...@@ -309,3 +241,60 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator { ...@@ -309,3 +241,60 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
export function* searchAllWatcher(): SagaIterator { export function* searchAllWatcher(): SagaIterator {
yield takeEvery(SearchAll.REQUEST, searchAllWorker); yield takeEvery(SearchAll.REQUEST, searchAllWorker);
}; };
//////////////////////////////////////////////////////////////////////////////
// INLINE SEARCH RESULTS SAGAS
// These sagas support the inline search results feature.
// TODO: Consider moving into nested directory similar to how filter logic.
//////////////////////////////////////////////////////////////////////////////
export function* inlineSearchWorker(action: InlineSearchRequest): SagaIterator {
const { term } = action.payload;
try {
const [tableResponse, userResponse] = yield all([
call(API.searchResource, 0, ResourceType.table, term, {}, SearchType.INLINE_SEARCH),
call(API.searchResource, 0, ResourceType.user, term, {}, SearchType.INLINE_SEARCH),
]);
const inlineSearchResponse = {
tables: tableResponse.tables || initialInlineResultsState.tables,
users: userResponse.users || initialInlineResultsState.users,
};
yield put(getInlineResultsSuccess(inlineSearchResponse));
} catch (e) {
yield put(getInlineResultsFailure());
}
};
export function* inlineSearchWatcher(): SagaIterator {
yield takeLatest(InlineSearch.REQUEST, inlineSearchWorker);
}
export function* debounceWorker(action): SagaIterator {
yield put(getInlineResults(action.payload.term));
}
export function* inlineSearchWatcherDebounce(): SagaIterator {
yield debounce(350, InlineSearch.REQUEST_DEBOUNCE, debounceWorker);
}
export function* selectInlineResultWorker(action): SagaIterator {
const state = yield select();
const { searchTerm, resourceType, updateUrl } = action.payload;
if (state.search.inlineResults.isLoading) {
yield put(searchAll(SearchType.INLINE_SELECT, searchTerm, resourceType, 0, false))
updateSearchUrl({ term: searchTerm, filters: state.search.filters });
}
else {
if (updateUrl) {
updateSearchUrl({ resource: resourceType, term: searchTerm, index: 0, filters: state.search.filters });
}
const data = {
searchTerm,
resource: resourceType,
tables: state.search.inlineResults.tables,
users: state.search.inlineResults.users,
};
yield put(updateFromInlineResult(data));
}
};
export function* selectInlineResultsWatcher(): SagaIterator {
yield takeEvery(InlineSearch.SELECT, selectInlineResultWorker);
};
import { testSaga } from 'redux-saga-test-plan'; import { ResourceType, SearchType } from 'interfaces';
import { debounce } from 'redux-saga/effects';
import { DEFAULT_RESOURCE_TYPE, ResourceType, SearchType } from 'interfaces';
import * as NavigationUtils from 'utils/navigationUtils'; import * as NavigationUtils from 'utils/navigationUtils';
import * as SearchUtils from 'ducks/search/utils'; import * as SearchUtils from 'ducks/search/utils';
import * as API from '../api/v0'; import * as API from '../api/v0';
import * as Utils from '../utils';
import * as Sagas from '../sagas';
import * as filterReducer from '../filters/reducer'; import * as filterReducer from '../filters/reducer';
const MOCK_TABLE_FILTER_STATE = {'database': { 'hive': true }};
const MOCK_FILTER_STATE = { const MOCK_FILTER_STATE = {
[ResourceType.table]: { [ResourceType.table]: MOCK_TABLE_FILTER_STATE,
'database': { 'hive': true }
}
}; };
const filterReducerSpy = jest.spyOn(filterReducer, 'default').mockImplementation(() => MOCK_FILTER_STATE); const filterReducerSpy = jest.spyOn(filterReducer, 'default').mockImplementation(() => MOCK_FILTER_STATE);
import reducer, { import reducer, {
clearSearch,
getInlineResults, getInlineResults,
getInlineResultsDebounce,
getInlineResultsSuccess, getInlineResultsSuccess,
getInlineResultsFailure, getInlineResultsFailure,
initialState, initialState,
initialInlineResultsState, initialInlineResultsState,
loadPreviousSearch, loadPreviousSearch,
resetSearchState,
searchAll, searchAll,
searchAllFailure, searchAllFailure,
searchAllSuccess, searchAllSuccess,
SearchReducerState, SearchReducerState,
searchReset,
searchResource, searchResource,
searchResourceFailure, searchResourceFailure,
searchResourceSuccess, searchResourceSuccess,
selectInlineResult, selectInlineResult,
setPageIndex,
setResource,
submitSearch, submitSearch,
submitSearchResource,
updateFromInlineResult, updateFromInlineResult,
updateSearchState,
urlDidUpdate, urlDidUpdate,
} from '../reducer'; } from '../reducer';
import { import {
ClearSearch,
LoadPreviousSearch, LoadPreviousSearch,
InlineSearch, InlineSearch,
InlineSearchResponsePayload, InlineSearchResponsePayload,
...@@ -51,8 +44,6 @@ import { ...@@ -51,8 +44,6 @@ import {
SearchAllResponsePayload, SearchAllResponsePayload,
SearchResource, SearchResource,
SearchResponsePayload, SearchResponsePayload,
SetPageIndex,
SetResource,
SubmitSearch, SubmitSearch,
UrlDidUpdate, UrlDidUpdate,
} from '../types'; } from '../types';
...@@ -62,7 +53,7 @@ import globalState from 'fixtures/globalState'; ...@@ -62,7 +53,7 @@ import globalState from 'fixtures/globalState';
const updateSearchUrlSpy = jest.spyOn(NavigationUtils, 'updateSearchUrl'); const updateSearchUrlSpy = jest.spyOn(NavigationUtils, 'updateSearchUrl');
const searchState = globalState.search; const searchState = globalState.search;
describe('search ducks', () => { describe('search reducer', () => {
const expectedSearchResults: SearchResponsePayload = { const expectedSearchResults: SearchResponsePayload = {
search_term: 'testName', search_term: 'testName',
tables: { tables: {
...@@ -84,7 +75,7 @@ describe('search ducks', () => { ...@@ -84,7 +75,7 @@ describe('search ducks', () => {
}; };
const expectedSearchAllResults: SearchAllResponsePayload = { const expectedSearchAllResults: SearchAllResponsePayload = {
search_term: 'testName', search_term: 'testName',
selectedTab: ResourceType.table, resource: ResourceType.table,
dashboards: { dashboards: {
page_index: 0, page_index: 0,
results: [], results: [],
...@@ -128,7 +119,7 @@ describe('search ducks', () => { ...@@ -128,7 +119,7 @@ describe('search ducks', () => {
const inlineUpdatePayload: InlineSearchUpdatePayload = { const inlineUpdatePayload: InlineSearchUpdatePayload = {
searchTerm: 'testName', searchTerm: 'testName',
selectedTab: ResourceType.table, resource: ResourceType.table,
tables: { tables: {
page_index: 0, page_index: 0,
results: [], results: [],
...@@ -210,45 +201,13 @@ describe('search ducks', () => { ...@@ -210,45 +201,13 @@ describe('search ducks', () => {
expect(action.type).toBe(SearchResource.FAILURE); expect(action.type).toBe(SearchResource.FAILURE);
}); });
it('searchReset - returns the action to reset search state', () => { it('submitSearch - returns the action to submit a search', () => {
const action = searchReset();
expect(action.type).toBe(SearchAll.RESET);
});
it('submitSearch - returns the action to submit a search without useFilters', () => {
const term = 'test';
const action = submitSearch(term);
expect(action.type).toBe(SubmitSearch.REQUEST);
expect(action.payload.searchTerm).toBe(term);
expect(action.payload.useFilters).toBe(false);
});
it('submitSearch - returns the action to submit a search with useFilters', () => {
const term = 'test'; const term = 'test';
const action = submitSearch(term, true); const shouldUseFilters = true;
const action = submitSearch({ searchTerm: term, useFilters: shouldUseFilters });
expect(action.type).toBe(SubmitSearch.REQUEST); expect(action.type).toBe(SubmitSearch.REQUEST);
expect(action.payload.searchTerm).toBe(term); expect(action.payload.searchTerm).toBe(term);
expect(action.payload.useFilters).toBe(true); expect(action.payload.useFilters).toBe(shouldUseFilters);
});
it('setResource - returns the action to set the selected resource', () => {
const resource = ResourceType.table;
const updateUrl = true;
const action = setResource(resource, updateUrl);
const { payload } = action;
expect(action.type).toBe(SetResource.REQUEST);
expect(payload.resource).toBe(resource);
expect(payload.updateUrl).toBe(updateUrl);
});
it('setPageIndex - returns the action to set the page index', () => {
const index = 3;
const updateUrl = true;
const action = setPageIndex(index, updateUrl);
const { payload } = action;
expect(action.type).toBe(SetPageIndex.REQUEST);
expect(payload.pageIndex).toBe(index);
expect(payload.updateUrl).toBe(updateUrl);
}); });
it('loadPreviousSearch - returns the action to load the previous search', () => { it('loadPreviousSearch - returns the action to load the previous search', () => {
...@@ -299,17 +258,17 @@ describe('search ducks', () => { ...@@ -299,17 +258,17 @@ describe('search ducks', () => {
expect(action.type).toBe(InlineSearch.UPDATE); expect(action.type).toBe(InlineSearch.UPDATE);
expect(action.payload).toBe(inlineUpdatePayload); expect(action.payload).toBe(inlineUpdatePayload);
}); });
it('clearSearch - returns the action that will clear the search term', () => {
const action = clearSearch();
expect(action.type).toBe(ClearSearch.REQUEST);
});
}); });
describe('reducer', () => { describe('reducer', () => {
let testState: SearchReducerState; let testState: SearchReducerState;
let result;
beforeAll(() => { beforeAll(() => {
testState = initialState; testState = {
...searchState,
filters: MOCK_FILTER_STATE,
resource: ResourceType.user,
}
}); });
it('should return the existing state if action is not handled', () => { it('should return the existing state if action is not handled', () => {
expect(reducer(testState, { type: 'INVALID.ACTION' })).toEqual(testState); expect(reducer(testState, { type: 'INVALID.ACTION' })).toEqual(testState);
...@@ -345,20 +304,16 @@ describe('search ducks', () => { ...@@ -345,20 +304,16 @@ describe('search ducks', () => {
}); });
}); });
it('should handle SearchAll.RESET', () => {
expect(reducer(testState, searchReset())).toEqual(initialState);
});
it('should handle SearchResource.REQUEST', () => { it('should handle SearchResource.REQUEST', () => {
expect(reducer(testState, searchResource(SearchType.SUBMIT_TERM, 'test', ResourceType.table, 0))).toEqual({ expect(reducer(testState, searchResource(SearchType.SUBMIT_TERM, 'test', ResourceType.table, 0))).toEqual({
...initialState, ...testState,
isLoading: true, isLoading: true,
}); });
}); });
it('should handle SearchResource.SUCCESS', () => { it('should handle SearchResource.SUCCESS', () => {
expect(reducer(testState, searchResourceSuccess(expectedSearchResults))).toEqual({ expect(reducer(testState, searchResourceSuccess(expectedSearchResults))).toEqual({
...initialState, ...testState,
...expectedSearchResults, ...expectedSearchResults,
isLoading: false, isLoading: false,
}); });
...@@ -371,19 +326,11 @@ describe('search ducks', () => { ...@@ -371,19 +326,11 @@ describe('search ducks', () => {
}); });
}); });
it('should handle SetResource.REQUEST', () => {
const selectedTab = ResourceType.user;
expect(reducer(testState, setResource(selectedTab))).toEqual({
...testState,
selectedTab,
});
});
it('should handle InlineSearch.UPDATE', () => { it('should handle InlineSearch.UPDATE', () => {
const { searchTerm, selectedTab, tables, users } = inlineUpdatePayload; const { searchTerm, resource, tables, users } = inlineUpdatePayload;
expect(reducer(testState, updateFromInlineResult(inlineUpdatePayload))).toEqual({ expect(reducer(testState, updateFromInlineResult(inlineUpdatePayload))).toEqual({
...testState, ...testState,
selectedTab, resource,
tables, tables,
users, users,
search_term: searchTerm, search_term: searchTerm,
...@@ -391,6 +338,7 @@ describe('search ducks', () => { ...@@ -391,6 +338,7 @@ describe('search ducks', () => {
}); });
}); });
describe('InlineSearch', () => {
it('should handle InlineSearch.SUCCESS', () => { it('should handle InlineSearch.SUCCESS', () => {
const { tables, users } = expectedInlineResults; const { tables, users } = expectedInlineResults;
expect(reducer(testState, getInlineResultsSuccess(expectedInlineResults))).toEqual({ expect(reducer(testState, getInlineResultsSuccess(expectedInlineResults))).toEqual({
...@@ -422,440 +370,84 @@ describe('search ducks', () => { ...@@ -422,440 +370,84 @@ describe('search ducks', () => {
}); });
}); });
describe('handles cases that update the filter state', () => { it('should handle InlineSearch.REQUEST_DEBOUNCE', () => {
describe('cases that update the filter state only', () => { const term = 'testSearch';
it('UpdateSearchFilter.CLEAR_ALL', () => { expect(reducer(testState, getInlineResultsDebounce(term))).toEqual({
filterReducerSpy.mockClear(); ...testState,
const filterAction = filterReducer.clearAllFilters(); inlineResults: {
const result = reducer(testState, filterAction) tables: initialInlineResultsState.tables,
expect(filterReducerSpy).toHaveBeenCalledWith(testState.filters, filterAction, testState.selectedTab); users: initialInlineResultsState.users,
expect(result.filters).toBe(MOCK_FILTER_STATE); isLoading: true,
}) },
});
describe('cases that update the search term & filter state', () => {
it('UpdateSearchFilter.SET_BY_RESOURCE', () => {
filterReducerSpy.mockClear();
const mockTerm = 'rides';
const filterAction = filterReducer.setSearchInputByResource({ 'tag': 'tagName' }, ResourceType.table, 2, mockTerm);
const result = reducer(testState, filterAction)
expect(filterReducerSpy).toHaveBeenCalledWith(testState.filters, filterAction, testState.selectedTab);
expect(result.filters).toBe(MOCK_FILTER_STATE);
expect(result.search_term).toBe(mockTerm);
})
});
describe('cases that update the filter state & trigger a search', () => {
it('UpdateSearchFilter.CLEAR_CATEGORY', () => {
filterReducerSpy.mockClear();
const filterAction = filterReducer.clearFilterByCategory('column');
const result = reducer(testState, filterAction)
expect(filterReducerSpy).toHaveBeenCalledWith(testState.filters, filterAction, testState.selectedTab);
expect(result.filters).toBe(MOCK_FILTER_STATE);
expect(result.isLoading).toBe(true);
})
it('UpdateSearchFilter.UPDATE_CATEGORY', () => {
filterReducerSpy.mockClear();
const filterAction = filterReducer.updateFilterByCategory('column', 'column_name')
const result = reducer(testState, filterAction)
expect(filterReducerSpy).toHaveBeenCalledWith(testState.filters, filterAction, testState.selectedTab);
expect(result.filters).toBe(MOCK_FILTER_STATE);
expect(result.isLoading).toBe(true);
})
})
});
});
describe('sagas', () => {
describe('filter sagas', () => {
describe('filterWatcher', () => {
it('debounces clear and update category actions with filterWorker', () => {
testSaga(Sagas.filterWatcher)
.next()
.is(debounce(
750,
[filterReducer.UpdateSearchFilter.CLEAR_CATEGORY, filterReducer.UpdateSearchFilter.UPDATE_CATEGORY],
Sagas.filterWorker
))
.next().isDone();
});
});
describe('filterWorker', () => {
let mockSearchState;
let saga;
beforeAll(() => {
mockSearchState = globalState.search;
saga = testSaga(Sagas.filterWorker);
})
it('verifies saga executes as written', () => {
/*
Note: This is an experimental pattern for best effort coverage.
Sagas have become a mix of both asynchronous api calls & synchronous helper methods --
unsure if that's a good practice or what it means for writing robust unit tests
*/
updateSearchUrlSpy.mockClear();
saga = saga.next()
.select(SearchUtils.getSearchState).next(mockSearchState)
.put(searchResource(SearchType.FILTER, mockSearchState.search_term, mockSearchState.selectedTab, 0)).next();
expect(updateSearchUrlSpy).toHaveBeenCalledWith({
filters: mockSearchState.filters,
resource: mockSearchState.selectedTab,
term: mockSearchState.search_term,
index: 0,
}, true);
saga.isDone();
});
});
});
describe('searchAllWatcher', () => {
it('takes every SearchAll.REQUEST with searchAllWorker', () => {
testSaga(Sagas.searchAllWatcher)
.next().takeEvery(SearchAll.REQUEST, Sagas.searchAllWorker)
.next().isDone();
});
});
describe('searchAllWorker', () => {
/*
TODO - There seems to be no straughtforward way to test this method.
We should re-evaluate how much logic is wrapped into sagas specifically
question:
1. Processing the response in the saga
2. Helper methods
Can we pass all necessary information to the api method such that the api method
does all of the processing and returns what we need?
*/
it('handles request error', () => {
testSaga(Sagas.searchAllWorker, searchAll(SearchType.SUBMIT_TERM, 'test', ResourceType.table, 0, true))
.next().select(SearchUtils.getSearchState)
.next(globalState.search).throw(new Error()).put(searchAllFailure())
.next().isDone();
});
});
describe('searchResourceWatcher', () => {
it('takes every SearchResource.REQUEST with searchResourceWorker', () => {
testSaga(Sagas.searchResourceWatcher)
.next().takeEvery(SearchResource.REQUEST, Sagas.searchResourceWorker)
.next().isDone();
});
});
describe('searchResourceWorker', () => {
it('executes flow for returning search results', () => {
const pageIndex = 0;
const resource = ResourceType.table;
const term = 'test';
const mockSearchState = globalState.search;
const searchType = SearchType.PAGINATION;
testSaga(Sagas.searchResourceWorker, searchResource(searchType, term, resource, pageIndex))
.next().select(SearchUtils.getSearchState)
.next(mockSearchState).call(API.searchResource, pageIndex, resource, term, mockSearchState.filters[resource], searchType)
.next(expectedSearchResults).put(searchResourceSuccess(expectedSearchResults))
.next().isDone();
});
it('handles request error', () => {
testSaga(Sagas.searchResourceWorker, searchResource(SearchType.PAGINATION, 'test', ResourceType.table, 0))
.next().select(SearchUtils.getSearchState)
.next(globalState.search).throw(new Error()).put(searchResourceFailure())
.next().isDone();
});
});
describe('submitSearchWorker', () => {
it('initiates a searchAll action', () => {
const term = 'test';
const mockSearchState = globalState.search;
updateSearchUrlSpy.mockClear();
testSaga(Sagas.submitSearchWorker, submitSearch(term, true))
.next().select(SearchUtils.getSearchState)
.next(mockSearchState).put(searchAll(SearchType.SUBMIT_TERM, term, undefined, undefined, true))
.next().isDone();
expect(updateSearchUrlSpy).toHaveBeenCalledWith({ term, filters: mockSearchState.filters });
});
});
describe('submitSearchWatcher', () => {
it('takes every SubmitSearch.REQUEST with submitSearchWorker', () => {
testSaga(Sagas.submitSearchWatcher)
.next().takeEvery(SubmitSearch.REQUEST, Sagas.submitSearchWorker)
.next().isDone();
});
});
describe('setResourceWorker', () => {
it('calls updateSearchUrl when updateUrl is true', () => {
const resource = ResourceType.table;
const updateUrl = true;
updateSearchUrlSpy.mockClear();
testSaga(Sagas.setResourceWorker, setResource(resource, updateUrl))
.next().select(SearchUtils.getSearchState)
.next(globalState.search).isDone();
expect(updateSearchUrlSpy).toHaveBeenCalledWith({
resource,
term: searchState.search_term,
index: searchState.tables.page_index,
filters: searchState.filters,
});
});
it('calls updateSearchUrl when updateUrl is true', () => {
const resource = ResourceType.table;
const updateUrl = false;
updateSearchUrlSpy.mockClear();
testSaga(Sagas.setResourceWorker, setResource(resource, updateUrl))
.next().select(SearchUtils.getSearchState)
.next(searchState).isDone();
expect(updateSearchUrlSpy).not.toHaveBeenCalled();
});
});
describe('setResourceWatcher', () => {
it('takes every SetResource.REQUEST with setResourceWorker', () => {
testSaga(Sagas.setResourceWatcher)
.next().takeEvery(SetResource.REQUEST, Sagas.setResourceWorker)
.next().isDone();
});
});
describe('setPageIndexWorker', () => {
it('initiates a searchResource and updates the url search when specified', () => {
const index = 1;
const updateUrl = true;
updateSearchUrlSpy.mockClear();
testSaga(Sagas.setPageIndexWorker, setPageIndex(index, updateUrl))
.next().select(SearchUtils.getSearchState)
.next(searchState).put(searchResource(SearchType.PAGINATION, searchState.search_term, searchState.selectedTab, index))
.next().isDone();
expect(updateSearchUrlSpy).toHaveBeenCalled();
});
it('initiates a searchResource and does not update url search', () => {
const index = 3;
const updateUrl = false;
updateSearchUrlSpy.mockClear();
testSaga(Sagas.setPageIndexWorker, setPageIndex(index, updateUrl))
.next().select(SearchUtils.getSearchState)
.next(searchState).put(searchResource(SearchType.PAGINATION, searchState.search_term, searchState.selectedTab, index))
.next().isDone();
expect(updateSearchUrlSpy).not.toHaveBeenCalled();
});
});
describe('setPageIndexWatcher', () => {
it('takes every SetPageIndex.REQUEST with setPageIndexWorker', () => {
testSaga(Sagas.setPageIndexWatcher)
.next().takeEvery(SetPageIndex.REQUEST, Sagas.setPageIndexWorker)
.next().isDone();
});
});
describe('urlDidUpdateWorker', () => {
let sagaTest;
let term;
let resource;
let index;
beforeEach(() => {
term = searchState.search_term;
resource = searchState.selectedTab;
index = SearchUtils.getPageIndex(searchState, resource);
sagaTest = (action) => {
return testSaga(Sagas.urlDidUpdateWorker, action)
.next().select(SearchUtils.getSearchState)
.next(searchState);
};
});
it('Calls searchAll when search term changes', () => {
term = 'new search';
sagaTest(urlDidUpdate(`term=${term}&resource=${resource}&index=${index}`))
.put(searchAll(SearchType.LOAD_URL, term, resource, index))
.next().isDone();
});
it('Calls setResource when the resource has changed', () => {
resource = ResourceType.user;
sagaTest(urlDidUpdate(`term=${term}&resource=${resource}&index=${index}`))
.put(setResource(resource, false))
.next().isDone();
});
it('when filters have changed', () => {
sagaTest(urlDidUpdate(`term=${term}&resource=${resource}&index=${index}&filters=%7B"database"%3A%7B"hive"%3Atrue%7D%7D`))
.put(filterReducer.setSearchInputByResource({ 'database': { 'hive' : true }}, resource, index, term))
.next().isDone();
});
/*it('Calls setPageIndex when the index changes', () => {
index = 10;
sagaTest(urlDidUpdate(`term=${term}&resource=${resource}&index=${index}`))
.put(setPageIndex(index, false))
.next().isDone();
});*/
});
describe('urlDidUpdateWatcher', () => {
it('takes every UrlDidUpdate.REQUEST with urlDidUpdateWorker', () => {
testSaga(Sagas.urlDidUpdateWatcher)
.next().takeEvery(UrlDidUpdate.REQUEST, Sagas.urlDidUpdateWorker)
.next().isDone();
});
});
describe('loadPreviousSearchWorker', () => {
// TODO - test 'BrowserHistory.goBack' case
it('applies the existing search state into the URL', () => {
updateSearchUrlSpy.mockClear();
testSaga(Sagas.loadPreviousSearchWorker, loadPreviousSearch())
.next().select(SearchUtils.getSearchState)
.next(searchState).isDone();
expect(updateSearchUrlSpy).toHaveBeenCalledWith({
term: searchState.search_term,
resource: searchState.selectedTab,
index: SearchUtils.getPageIndex(searchState, searchState.selectedTab),
filters: searchState.filters,
});
});
});
describe('loadPreviousSearchWatcher', () => {
it('takes every LoadPreviousSearch.REQUEST with loadPreviousSearchWorker', () => {
testSaga(Sagas.loadPreviousSearchWatcher)
.next().takeEvery(LoadPreviousSearch.REQUEST, Sagas.loadPreviousSearchWorker)
.next().isDone();
});
});
describe('inlineSearchWorker', () => {
/* TODO - Considering some cleanup */
}); });
describe('inlineSearchWatcher', () => {
/* TODO - Need to investigate proper test approach
it('debounces InlineSearch.REQUEST and calls inlineSearchWorker', () => {
}); });
*/
}); });
describe('selectInlineResultWorker', () => { describe('UpdateSearchState', () => {
/* TODO - Considering some cleanup */ it('UpdateSearchState.REQUEST returns existing filter state if not provided', () => {
result = reducer(testState, updateSearchState({ updateUrl: true }));
expect(result.filters).toBe(testState.filters);
}); });
describe('selectInlineResultsWatcher', () => { it('UpdateSearchState.REQUEST returns existing resource state if not provided', () => {
it('takes every InlineSearch.REQUEST with selectInlineResultWorker', () => { result = reducer(testState, updateSearchState({ updateUrl: true }));
testSaga(Sagas.selectInlineResultsWatcher) expect(result.resource).toBe(testState.resource);
.next().takeEvery(InlineSearch.SELECT, Sagas.selectInlineResultWorker)
.next().isDone();
});
});
}); });
describe('utils', () => { it('UpdateSearchState.REQUEST updates filter state if provided', () => {
describe('getSearchState', () => { result = reducer(initialState, updateSearchState({ filters: MOCK_FILTER_STATE }));
it('returns the search state', () => { expect(result.filters).toBe(MOCK_FILTER_STATE);
const result = SearchUtils.getSearchState(globalState);
expect(result).toEqual(searchState);
});
});
describe('getPageIndex', () => {
const mockState = {
...searchState,
selectedTab: ResourceType.dashboard,
dashboards: {
...searchState.dashboards,
page_index: 1,
},
tables: {
...searchState.tables,
page_index: 2,
},
users: {
...searchState.users,
page_index: 3,
}
};
it('given ResourceType.dashboard, returns page_index for dashboards', () => {
expect(SearchUtils.getPageIndex(mockState, ResourceType.dashboard)).toEqual(mockState.dashboards.page_index);
}); });
it('given ResourceType.table, returns page_index for table', () => { it('UpdateSearchState.REQUEST updates resource state if provided', () => {
expect(SearchUtils.getPageIndex(mockState, ResourceType.table)).toEqual(mockState.tables.page_index); const testResource = ResourceType.user;
result = reducer(initialState, updateSearchState({ resource: testResource }));
expect(result.resource).toEqual(testResource);
}); });
it('given ResourceType.user, returns page_index for users', () => { it('UpdateSearchState.RESET returns initialState', () => {
expect(SearchUtils.getPageIndex(mockState, ResourceType.user)).toEqual(mockState.users.page_index); result = reducer(testState, resetSearchState());
expect(result).toBe(initialState);
}); });
it('given no resource, returns page_index for the selected resource', () => {
const resourceToUse = mockState[mockState.selectedTab + 's'];
expect(SearchUtils.getPageIndex(mockState)).toEqual(resourceToUse.page_index);
}); });
it('returns 0 if not given a supported ResourceType', () => { it('SubmitSearch.REQUEST updates given search term and enters isLoading state', () => {
// @ts-ignore: cover default case const searchTerm = 'testTerm';
expect(SearchUtils.getPageIndex(mockState, 'not valid input')).toEqual(0); result = reducer(testState, submitSearch({ searchTerm, useFilters: true }));
}); expect(result).toEqual({
...testState,
isLoading: true,
search_term: searchTerm,
})
}); });
describe('autoSelectResource', () => { describe('should handle SubmitSearchResource.REQUEST', () => {
const emptyMockState = { let filterAction;
...searchState, let paginationAction;
dashboards: { beforeAll(() => {
...searchState.dashboards, filterAction = submitSearchResource({
total_results: 0, pageIndex: 0,
}, searchTerm: 'hello',
tables: { searchType: SearchType.FILTER,
...searchState.tables, resourceFilters: MOCK_TABLE_FILTER_STATE,
total_results: 0,
},
users: {
...searchState.users,
total_results: 0,
}
};
it('returns the DEFAULT_RESOURCE_TYPE when search results are empty', () => {
expect(SearchUtils.autoSelectResource(emptyMockState)).toEqual(DEFAULT_RESOURCE_TYPE);
}); });
paginationAction = submitSearchResource({ pageIndex: 1, searchType: SearchType.PAGINATION });
})
it('prefers `table` over `user` and `dashboard`', () => { it('calls filter reducer with existing filters', () => {
const mockState = { ...emptyMockState }; filterReducerSpy.mockClear();
mockState.tables.total_results = 10; const result = reducer(initialState, filterAction);
mockState.users.total_results = 10; expect(filterReducerSpy).toHaveBeenCalledWith(initialState.filters, filterAction);
mockState.dashboards.total_results = 10;
expect(SearchUtils.autoSelectResource(mockState)).toEqual(ResourceType.table);
}); });
it('prefers `user` over `dashboard`', () => { it('updates search term if provided', () => {
const mockState = { ...emptyMockState }; result = reducer(testState, filterAction);
mockState.tables.total_results = 0; expect(result.search_term).toBe(filterAction.payload.searchTerm);
mockState.users.total_results = 10;
mockState.dashboards.total_results = 10;
expect(SearchUtils.autoSelectResource(mockState)).toEqual(ResourceType.user);
}); });
it('returns `dashboard` if there are dashboards but no other results', () => { it('sets search term with existing state if provided', () => {
const mockState = { ...emptyMockState }; result = reducer(testState, paginationAction);
mockState.tables.total_results = 0; expect(result.search_term).toBe(testState.search_term);
mockState.users.total_results = 0;
mockState.dashboards.total_results = 10;
expect(SearchUtils.autoSelectResource(mockState)).toEqual(ResourceType.dashboard);
}); });
}); });
}); });
......
import { testSaga } from 'redux-saga-test-plan';
import { debounce } from 'redux-saga/effects';
import { DEFAULT_RESOURCE_TYPE, ResourceType, SearchType } from 'interfaces';
import * as NavigationUtils from 'utils/navigationUtils';
import * as SearchUtils from 'ducks/search/utils';
import * as API from '../api/v0';
import * as Sagas from '../sagas';
import {
initialState,
initialInlineResultsState,
loadPreviousSearch,
searchAll,
searchAllFailure,
searchAllSuccess,
SearchReducerState,
searchResource,
searchResourceFailure,
searchResourceSuccess,
selectInlineResult,
submitSearch,
submitSearchResource,
updateFromInlineResult,
updateSearchState,
urlDidUpdate,
} from '../reducer';
import {
LoadPreviousSearch,
InlineSearch,
InlineSearchResponsePayload,
InlineSearchUpdatePayload,
SearchAll,
SearchAllResponsePayload,
SearchResource,
SearchResponsePayload,
SubmitSearch,
SubmitSearchResource,
UpdateSearchState,
UrlDidUpdate,
} from '../types';
import globalState from 'fixtures/globalState';
const updateSearchUrlSpy = jest.spyOn(NavigationUtils, 'updateSearchUrl');
const searchState = globalState.search;
describe('search sagas', () => {
const expectedSearchResults: SearchResponsePayload = {
search_term: 'testName',
tables: {
page_index: 0,
results: [
{
cluster: 'testCluster',
database: 'testDatabase',
description: 'I have a lot of users',
key: 'testDatabase://testCluster.testSchema/testName',
last_updated_timestamp: 946684799,
name: 'testName',
schema: 'testSchema',
type: ResourceType.table,
},
],
total_results: 1,
},
};
describe('searchAllWatcher', () => {
it('takes every SearchAll.REQUEST with searchAllWorker', () => {
testSaga(Sagas.searchAllWatcher)
.next().takeEvery(SearchAll.REQUEST, Sagas.searchAllWorker)
.next().isDone();
});
});
describe('searchAllWorker', () => {
/*
TODO - There seems to be no straughtforward way to test this method.
We should re-evaluate how much logic is wrapped into sagas specifically
question:
1. Processing the response in the saga
2. Helper methods
Can we pass all necessary information to the api method such that the api method
does all of the processing and returns what we need?
*/
it('handles request error', () => {
testSaga(Sagas.searchAllWorker, searchAll(SearchType.SUBMIT_TERM, 'test', ResourceType.table, 0, true))
.next().select(SearchUtils.getSearchState)
.next(globalState.search).throw(new Error()).put(searchAllFailure())
.next().isDone();
});
});
describe('searchResourceWatcher', () => {
it('takes every SearchResource.REQUEST with searchResourceWorker', () => {
testSaga(Sagas.searchResourceWatcher)
.next().takeEvery(SearchResource.REQUEST, Sagas.searchResourceWorker)
.next().isDone();
});
});
describe('searchResourceWorker', () => {
it('executes flow for returning search results', () => {
const pageIndex = 0;
const resource = ResourceType.table;
const term = 'test';
const mockSearchState = globalState.search;
const searchType = SearchType.PAGINATION;
testSaga(Sagas.searchResourceWorker, searchResource(searchType, term, resource, pageIndex))
.next().select(SearchUtils.getSearchState)
.next(mockSearchState).call(API.searchResource, pageIndex, resource, term, mockSearchState.filters[resource], searchType)
.next(expectedSearchResults).put(searchResourceSuccess(expectedSearchResults))
.next().isDone();
});
it('handles request error', () => {
testSaga(Sagas.searchResourceWorker, searchResource(SearchType.PAGINATION, 'test', ResourceType.table, 0))
.next().select(SearchUtils.getSearchState)
.next(globalState.search).throw(new Error()).put(searchResourceFailure())
.next().isDone();
});
});
describe('submitSearchWorker', () => {
it('initiates flow to search with a term and optional filters', () => {
const term = 'test';
testSaga(Sagas.submitSearchWorker, submitSearch({ searchTerm: term, useFilters: true }))
.next().put(searchAll(SearchType.SUBMIT_TERM, term, undefined, 0, true))
.next().isDone();
});
it('initiates flow to search with empty term and existing filters', () => {
testSaga(Sagas.submitSearchWorker, submitSearch({ searchTerm: '', useFilters: false }))
.next().put(searchAll(SearchType.CLEAR_TERM, '', undefined, 0, false))
.next().isDone();
});
});
describe('submitSearchWatcher', () => {
it('takes latest SubmitSearch.REQUEST with submitSearchWorker', () => {
testSaga(Sagas.submitSearchWatcher)
.next().takeLatest(SubmitSearch.REQUEST, Sagas.submitSearchWorker)
.next().isDone();
});
});
describe('submitSearchResourceWorker', () => {
describe('when no new search state input is passed', () => {
it('it updates url if necessary + calls searchResource with given pageIndex and existing search input', () => {
updateSearchUrlSpy.mockClear();
const paginationAction = submitSearchResource({ pageIndex: 1, searchType: SearchType.PAGINATION, updateUrl: true });
const { search_term, resource } = searchState;
testSaga(Sagas.submitSearchResourceWorker, paginationAction)
.next().select(SearchUtils.getSearchState)
.next(searchState).put(searchResource(SearchType.PAGINATION, search_term, resource, 1))
.next().isDone();
expect(updateSearchUrlSpy).toHaveBeenCalledWith({
term: searchState.search_term,
resource: searchState.resource,
index: 1,
filters: searchState.filters,
});
});
})
it('calls searchResource with given term', () => {
const filterAction = submitSearchResource({
pageIndex: 0,
searchTerm: '',
searchType: SearchType.FILTER,
resourceFilters: {'database': {'hive': true}},
});
const { search_term, resource } = searchState;
testSaga(Sagas.submitSearchResourceWorker, filterAction)
.next().select(SearchUtils.getSearchState)
.next(searchState).put(searchResource(SearchType.FILTER, '', resource, 0))
.next().isDone();
});
it('calls searchResource with given resource', () => {
const filterAction = submitSearchResource({
pageIndex: 0,
searchTerm: 'hello',
searchType: SearchType.FILTER,
resourceFilters: {'database': {'hive': true}},
resource: ResourceType.table,
});
const { search_term, resource } = searchState;
testSaga(Sagas.submitSearchResourceWorker, filterAction)
.next().select(SearchUtils.getSearchState)
.next(searchState).put(searchResource(SearchType.FILTER, 'hello', ResourceType.table, 0))
.next().isDone();
});
});
describe('submitSearchResourceWatcher', () => {
it('takes every SubmitSearchResource.REQUEST with submitSearchResourceWorker', () => {
testSaga(Sagas.submitSearchResourceWatcher)
.next().takeEvery(SubmitSearchResource.REQUEST, Sagas.submitSearchResourceWorker)
.next().isDone();
});
});
describe('updateSearchStateWorker', () => {
it('it update url if necessary with existing state values', () => {
updateSearchUrlSpy.mockClear();
const action = updateSearchState({ updateUrl: true });
const { search_term, resource } = searchState;
testSaga(Sagas.updateSearchStateWorker, action)
.next().select(SearchUtils.getSearchState)
.next(searchState).isDone();
expect(updateSearchUrlSpy).toHaveBeenCalledWith({
term: searchState.search_term,
resource: searchState.resource,
index: 0,
filters: searchState.filters,
});
});
});
describe('updateSearchStateWatcher', () => {
it('takes every UpdateSearchState.REQUEST with updateSearchStateWorker', () => {
testSaga(Sagas.updateSearchStateWatcher)
.next().takeEvery(UpdateSearchState.REQUEST, Sagas.updateSearchStateWorker)
.next().isDone();
});
});
describe('urlDidUpdateWorker', () => {
let sagaTest;
let term;
let resource;
let index;
beforeEach(() => {
term = searchState.search_term;
resource = searchState.resource;
index = SearchUtils.getPageIndex(searchState, resource);
sagaTest = (action) => {
return testSaga(Sagas.urlDidUpdateWorker, action)
.next().select(SearchUtils.getSearchState)
.next(searchState);
};
});
it('calls searchAll when search term changes', () => {
term = 'new search';
sagaTest(urlDidUpdate(`term=${term}&resource=${resource}&index=${index}`))
.put(searchAll(SearchType.LOAD_URL, term, resource, index, false))
.next().isDone();
});
it('calls updateSearchState & searchAll when filter & search term changes', () => {
term = 'new search';
sagaTest(urlDidUpdate(`term=${term}&resource=${resource}&index=${index}&filters=%7B"database"%3A%7B"hive"%3Atrue%7D%7D`))
.put(updateSearchState({
// @ts-ignore: Has trouble with resource = 'table' vs explicityly being ResourceType.table
filters: {
[resource]: {'database': {'hive': true}},
}
}))
.next().put(searchAll(SearchType.LOAD_URL, term, resource, index, true))
.next().isDone();
});
it('calls updateSearchState when the resource has changed', () => {
resource = ResourceType.user;
sagaTest(urlDidUpdate(`term=${term}&resource=${resource}&index=${index}`))
.put(updateSearchState({ resource }))
.next().isDone();
});
it('calls submitSearchResource when the filters changes', () => {
sagaTest(urlDidUpdate(`term=${term}&resource=${resource}&index=${index}&filters=%7B"database"%3A%7B"bigquery"%3Atrue%7D%7D`))
.put(submitSearchResource({
resource,
searchTerm: term,
resourceFilters: { 'database': { 'bigquery' : true }},
pageIndex: index,
searchType: SearchType.LOAD_URL
}))
.next().isDone();
});
it('calls submitSearchResource when the index changes', () => {
index = 10;
sagaTest(urlDidUpdate(`term=${term}&resource=${resource}&index=${index}`))
.put(submitSearchResource({ pageIndex: index, searchType: SearchType.LOAD_URL }))
.next().isDone();
});
});
describe('urlDidUpdateWatcher', () => {
it('takes every UrlDidUpdate.REQUEST with urlDidUpdateWorker', () => {
testSaga(Sagas.urlDidUpdateWatcher)
.next().takeEvery(UrlDidUpdate.REQUEST, Sagas.urlDidUpdateWorker)
.next().isDone();
});
});
describe('loadPreviousSearchWorker', () => {
// TODO - test 'BrowserHistory.goBack' case
it('applies the existing search state into the URL', () => {
updateSearchUrlSpy.mockClear();
testSaga(Sagas.loadPreviousSearchWorker, loadPreviousSearch())
.next().select(SearchUtils.getSearchState)
.next(searchState).isDone();
expect(updateSearchUrlSpy).toHaveBeenCalledWith({
term: searchState.search_term,
resource: searchState.resource,
index: SearchUtils.getPageIndex(searchState, searchState.resource),
filters: searchState.filters,
});
});
});
describe('loadPreviousSearchWatcher', () => {
it('takes every LoadPreviousSearch.REQUEST with loadPreviousSearchWorker', () => {
testSaga(Sagas.loadPreviousSearchWatcher)
.next().takeEvery(LoadPreviousSearch.REQUEST, Sagas.loadPreviousSearchWorker)
.next().isDone();
});
});
describe('inlineSearchWorker', () => {
/* TODO - Considering some cleanup */
});
describe('inlineSearchWatcher', () => {
/* TODO - Need to investigate proper test approach
it('debounces InlineSearch.REQUEST and calls inlineSearchWorker', () => {
});
*/
});
describe('selectInlineResultWorker', () => {
/* TODO - Considering some cleanup */
});
describe('selectInlineResultsWatcher', () => {
it('takes every InlineSearch.REQUEST with selectInlineResultWorker', () => {
testSaga(Sagas.selectInlineResultsWatcher)
.next().takeEvery(InlineSearch.SELECT, Sagas.selectInlineResultWorker)
.next().isDone();
});
});
});
import { DEFAULT_RESOURCE_TYPE, ResourceType } from 'interfaces';
import * as SearchUtils from 'ducks/search/utils';
import globalState from 'fixtures/globalState';
const searchState = globalState.search;
describe('search utils', () => {
describe('getSearchState', () => {
it('returns the search state', () => {
const result = SearchUtils.getSearchState(globalState);
expect(result).toEqual(searchState);
});
});
describe('getPageIndex', () => {
const mockState = {
...searchState,
resource: ResourceType.dashboard,
dashboards: {
...searchState.dashboards,
page_index: 1,
},
tables: {
...searchState.tables,
page_index: 2,
},
users: {
...searchState.users,
page_index: 3,
}
};
it('given ResourceType.dashboard, returns page_index for dashboards', () => {
expect(SearchUtils.getPageIndex(mockState, ResourceType.dashboard)).toEqual(mockState.dashboards.page_index);
});
it('given ResourceType.table, returns page_index for table', () => {
expect(SearchUtils.getPageIndex(mockState, ResourceType.table)).toEqual(mockState.tables.page_index);
});
it('given ResourceType.user, returns page_index for users', () => {
expect(SearchUtils.getPageIndex(mockState, ResourceType.user)).toEqual(mockState.users.page_index);
});
it('given no resource, returns page_index for the selected resource', () => {
const resourceToUse = mockState[mockState.resource + 's'];
expect(SearchUtils.getPageIndex(mockState)).toEqual(resourceToUse.page_index);
});
it('returns 0 if not given a supported ResourceType', () => {
// @ts-ignore: cover default case
expect(SearchUtils.getPageIndex(mockState, 'not valid input')).toEqual(0);
});
});
describe('autoSelectResource', () => {
const emptyMockState = {
...searchState,
dashboards: {
...searchState.dashboards,
total_results: 0,
},
tables: {
...searchState.tables,
total_results: 0,
},
users: {
...searchState.users,
total_results: 0,
}
};
it('returns the DEFAULT_RESOURCE_TYPE when search results are empty', () => {
expect(SearchUtils.autoSelectResource(emptyMockState)).toEqual(DEFAULT_RESOURCE_TYPE);
});
it('prefers `table` over `user` and `dashboard`', () => {
const mockState = { ...emptyMockState };
mockState.tables.total_results = 10;
mockState.users.total_results = 10;
mockState.dashboards.total_results = 10;
expect(SearchUtils.autoSelectResource(mockState)).toEqual(ResourceType.table);
});
it('prefers `user` over `dashboard`', () => {
const mockState = { ...emptyMockState };
mockState.tables.total_results = 0;
mockState.users.total_results = 10;
mockState.dashboards.total_results = 10;
expect(SearchUtils.autoSelectResource(mockState)).toEqual(ResourceType.user);
});
it('returns `dashboard` if there are dashboards but no other results', () => {
const mockState = { ...emptyMockState };
mockState.tables.total_results = 0;
mockState.users.total_results = 0;
mockState.dashboards.total_results = 10;
expect(SearchUtils.autoSelectResource(mockState)).toEqual(ResourceType.dashboard);
});
});
});
...@@ -9,6 +9,11 @@ import { ...@@ -9,6 +9,11 @@ import {
UserResource, UserResource,
} from 'interfaces'; } from 'interfaces';
import {
FilterReducerState,
ResourceFilterReducerState,
} from 'ducks/search/filters/reducer';
export interface SearchResults<T extends Resource> { export interface SearchResults<T extends Resource> {
page_index: number; page_index: number;
total_results: number; total_results: number;
...@@ -25,7 +30,7 @@ export interface SearchResponsePayload { ...@@ -25,7 +30,7 @@ export interface SearchResponsePayload {
users?: UserSearchResults; users?: UserSearchResults;
}; };
export interface SearchAllResponsePayload extends SearchResponsePayload { export interface SearchAllResponsePayload extends SearchResponsePayload {
selectedTab: ResourceType; resource: ResourceType;
dashboards: DashboardSearchResults; dashboards: DashboardSearchResults;
tables: TableSearchResults; tables: TableSearchResults;
users: UserSearchResults; users: UserSearchResults;
...@@ -36,7 +41,7 @@ export interface InlineSearchResponsePayload { ...@@ -36,7 +41,7 @@ export interface InlineSearchResponsePayload {
}; };
export interface InlineSearchUpdatePayload { export interface InlineSearchUpdatePayload {
searchTerm: string; searchTerm: string;
selectedTab: ResourceType; resource: ResourceType;
tables: TableSearchResults; tables: TableSearchResults;
users: UserSearchResults; users: UserSearchResults;
}; };
...@@ -46,7 +51,6 @@ export enum SearchAll { ...@@ -46,7 +51,6 @@ export enum SearchAll {
REQUEST = 'amundsen/search/SEARCH_ALL_REQUEST', REQUEST = 'amundsen/search/SEARCH_ALL_REQUEST',
SUCCESS = 'amundsen/search/SEARCH_ALL_SUCCESS', SUCCESS = 'amundsen/search/SEARCH_ALL_SUCCESS',
FAILURE = 'amundsen/search/SEARCH_ALL_FAILURE', FAILURE = 'amundsen/search/SEARCH_ALL_FAILURE',
RESET = 'amundsen/search/SEARCH_ALL_RESET',
}; };
export interface SearchAllRequest { export interface SearchAllRequest {
payload: { payload: {
...@@ -62,10 +66,6 @@ export interface SearchAllResponse { ...@@ -62,10 +66,6 @@ export interface SearchAllResponse {
type: SearchAll.SUCCESS | SearchAll.FAILURE; type: SearchAll.SUCCESS | SearchAll.FAILURE;
payload?: SearchAllResponsePayload; payload?: SearchAllResponsePayload;
}; };
export interface SearchAllReset {
type: SearchAll.RESET;
};
export enum SearchResource { export enum SearchResource {
REQUEST = 'amundsen/search/SEARCH_RESOURCE_REQUEST', REQUEST = 'amundsen/search/SEARCH_RESOURCE_REQUEST',
...@@ -124,42 +124,44 @@ export enum SubmitSearch { ...@@ -124,42 +124,44 @@ export enum SubmitSearch {
export interface SubmitSearchRequest { export interface SubmitSearchRequest {
payload: { payload: {
searchTerm: string; searchTerm: string;
useFilters?: boolean; useFilters: boolean;
}; };
type: SubmitSearch.REQUEST; type: SubmitSearch.REQUEST;
}; };
export enum ClearSearch { export enum SubmitSearchResource {
REQUEST = 'amundsen/search/CLEAR_SEARCH_REQUEST', REQUEST = 'amundsen/search/SUBMIT_SEARCH_RESOURCE_REQUEST',
}; };
export interface ClearSearchRequest { export type SubmitSearchResourcePayload = {
type: ClearSearch.REQUEST; pageIndex: number;
searchType: SearchType;
updateUrl?: boolean;
resourceFilters?: ResourceFilterReducerState;
searchTerm?: string;
resource?: ResourceType;
}
export interface SubmitSearchResourceRequest {
payload: SubmitSearchResourcePayload;
type: SubmitSearchResource.REQUEST;
}; };
export enum SetResource { export enum UpdateSearchState {
REQUEST = 'amundsen/search/SET_RESOURCE_REQUEST', REQUEST = 'amundsen/search/UPDATE_SEARCH_STATE',
}; RESET = 'amundsen/search/RESET_SEARCH_STATE',
export interface SetResourceRequest {
payload: {
resource: ResourceType;
updateUrl: boolean;
};
type: SetResource.REQUEST;
}; };
export type UpdateSearchStatePayload = {
filters?: FilterReducerState;
export enum SetPageIndex { resource?: ResourceType;
REQUEST = 'amundsen/search/SET_PAGE_INDEX_REQUEST', updateUrl?: boolean;
}
export interface UpdateSearchStateRequest {
payload?: UpdateSearchStatePayload;
type: UpdateSearchState.REQUEST;
}; };
export interface SetPageIndexRequest { export interface UpdateSearchStateReset {
payload: { type: UpdateSearchState.RESET;
pageIndex: number;
updateUrl: boolean;
};
type: SetPageIndex.REQUEST;
}; };
export enum LoadPreviousSearch { export enum LoadPreviousSearch {
REQUEST = 'amundsen/search/LOAD_PREVIOUS_SEARCH_REQUEST', REQUEST = 'amundsen/search/LOAD_PREVIOUS_SEARCH_REQUEST',
}; };
...@@ -167,7 +169,6 @@ export interface LoadPreviousSearchRequest { ...@@ -167,7 +169,6 @@ export interface LoadPreviousSearchRequest {
type: LoadPreviousSearch.REQUEST; type: LoadPreviousSearch.REQUEST;
}; };
export enum UrlDidUpdate { export enum UrlDidUpdate {
REQUEST = 'amundsen/search/URL_DID_UPDATE_REQUEST', REQUEST = 'amundsen/search/URL_DID_UPDATE_REQUEST',
}; };
......
...@@ -13,7 +13,7 @@ can be the combination of multiple responses. ...@@ -13,7 +13,7 @@ can be the combination of multiple responses.
*/ */
export const getPageIndex = (state: Partial<SearchReducerState>, resource?: ResourceType) => { export const getPageIndex = (state: Partial<SearchReducerState>, resource?: ResourceType) => {
resource = resource || state.selectedTab; resource = resource || state.resource;
switch(resource) { switch(resource) {
case ResourceType.table: case ResourceType.table:
return state.tables.page_index; return state.tables.page_index;
......
...@@ -66,7 +66,7 @@ const globalState: GlobalState = { ...@@ -66,7 +66,7 @@ const globalState: GlobalState = {
], ],
search: { search: {
search_term: 'testName', search_term: 'testName',
selectedTab: ResourceType.table, resource: ResourceType.table,
isLoading: false, isLoading: false,
dashboards: { dashboards: {
page_index: 0, page_index: 0,
......
...@@ -31,4 +31,5 @@ We use [Jest](https://jestjs.io/) as our test framework. We leverage utility met ...@@ -31,4 +31,5 @@ We use [Jest](https://jestjs.io/) as our test framework. We leverage utility met
#### Redux #### Redux
1. Because the majority of Redux code consists of functions, we unit test those methods as usual and ensure the functions produce the expected output for any given input. See Redux's documentation on testing [action creators](https://redux.js.org/recipes/writing-tests#action-creators), [async action creators](https://redux.js.org/recipes/writing-tests#async-action-creators), and [reducers](https://redux.js.org/recipes/writing-tests#reducers), or check out examples in our code. 1. Because the majority of Redux code consists of functions, we unit test those methods as usual and ensure the functions produce the expected output for any given input. See Redux's documentation on testing [action creators](https://redux.js.org/recipes/writing-tests#action-creators), [async action creators](https://redux.js.org/recipes/writing-tests#async-action-creators), and [reducers](https://redux.js.org/recipes/writing-tests#reducers), or check out examples in our code.
2. `redux-saga` generator functions can be tested by iterating through it step-by-step and running assertions at each step, or by executing the entire saga and running assertions on the side effects. See redux-saga's documentation on [testing sagas](https://redux-saga.js.org/docs/advanced/Testing.html) for a wider breadth of examples. 2. Unless an action creator includes any logic other than returning the action, unit testing the reducer and saga middleware logic is sufficient and provides the most value.
3. `redux-saga` generator functions can be tested by iterating through it step-by-step and running assertions at each step, or by executing the entire saga and running assertions on the side effects. See redux-saga's documentation on [testing sagas](https://redux-saga.js.org/docs/advanced/Testing.html) for a wider breadth of examples.
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