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 = {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
statements: 85,
},
'./js/fixtures': {
branches: 100,
......
......@@ -10,14 +10,14 @@ import { SEARCH_BREADCRUMB_TEXT } from './constants';
import MyBookmarks from 'components/common/Bookmark/MyBookmarks';
import Breadcrumb from 'components/common/Breadcrumb';
import PopularTables from 'components/common/PopularTables';
import { SearchAllReset } from 'ducks/search/types';
import { searchReset } from 'ducks/search/reducer';
import { resetSearchState } from 'ducks/search/reducer';
import { UpdateSearchStateReset } from 'ducks/search/types';
import SearchBar from 'components/common/SearchBar';
import TagsList from 'components/common/TagsList';
export interface DispatchFromProps {
searchReset: () => SearchAllReset;
searchReset: () => UpdateSearchStateReset;
}
export type HomePageProps = DispatchFromProps & RouteComponentProps<any>;
......@@ -62,7 +62,9 @@ export class HomePage extends React.Component<HomePageProps> {
}
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ searchReset } , dispatch);
return bindActionCreators({
searchReset: () => resetSearchState(),
}, dispatch);
};
export default connect<DispatchFromProps>(null, mapDispatchToProps)(HomePage);
......@@ -15,7 +15,7 @@ import { getMockRouterProps } from 'fixtures/mockRouter';
describe('HomePage', () => {
const setup = (propOverrides?: Partial<HomePageProps>) => {
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 props: HomePageProps = {
......
......@@ -5,24 +5,24 @@ import { connect } from 'react-redux';
import { TABLE_RESOURCE_TITLE, USER_RESOURCE_TITLE } from 'components/SearchPage/constants';
import AppConfig from 'config/config';
import { GlobalState } from 'ducks/rootReducer';
import { updateSearchState } from 'ducks/search/reducer';
import {
DashboardSearchResults,
SetResourceRequest,
TableSearchResults,
UpdateSearchStateRequest,
UserSearchResults
} from 'ducks/search/types';
import { ResourceType } from 'interfaces/Resources';
import { setResource } from 'ducks/search/reducer';
export interface StateFromProps {
selectedTab: ResourceType,
resource: ResourceType,
tables: TableSearchResults;
dashboards: DashboardSearchResults;
users: UserSearchResults;
}
export interface DispatchFromProps {
setResource: (resource: ResourceType) => SetResourceRequest;
setResource: (resource: ResourceType) => UpdateSearchStateRequest;
}
export type ResourceSelectorProps = StateFromProps & DispatchFromProps;
......@@ -50,7 +50,7 @@ export class ResourceSelector extends React.Component<ResourceSelectorProps > {
type="radio"
name="resource"
value={ option.type }
checked={ this.props.selectedTab === option.type }
checked={ this.props.resource === option.type }
onChange={ this.onChange }
/>
<span className="subtitle-2">{ option.label }</span>
......@@ -88,7 +88,7 @@ export class ResourceSelector extends React.Component<ResourceSelectorProps > {
export const mapStateToProps = (state: GlobalState) => {
return {
selectedTab: state.search.selectedTab,
resource: state.search.resource,
tables: state.search.tables,
users: state.search.users,
dashboards: state.search.dashboards,
......@@ -96,7 +96,9 @@ export const mapStateToProps = (state: GlobalState) => {
};
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);
......@@ -16,7 +16,7 @@ import { ResourceType } from 'interfaces/Resources';
describe('ResourceSelector', () => {
const setup = (propOverrides?: Partial<ResourceSelectorProps>) => {
const props = {
selectedTab: ResourceType.table,
resource: ResourceType.table,
tables: globalState.search.tables,
users: globalState.search.users,
dashboards: globalState.search.dashboards,
......@@ -42,7 +42,7 @@ describe('ResourceSelector', () => {
expect(inputProps.type).toEqual("radio");
expect(inputProps.name).toEqual("resource");
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);
});
......@@ -121,8 +121,8 @@ describe('mapStateToProps', () => {
result = mapStateToProps(globalState);
});
it('sets selectedTab on the props', () => {
expect(result.selectedTab).toEqual(globalState.search.selectedTab);
it('sets resource on the props', () => {
expect(result.resource).toEqual(globalState.search.resource);
});
it('sets tables on the props', () => {
......
......@@ -3,7 +3,7 @@ import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { GlobalState } from 'ducks/rootReducer';
import { clearFilterByCategory, updateFilterByCategory, ClearFilterRequest, UpdateFilterRequest, FilterOptions } from 'ducks/search/filters/reducer';
import { updateFilterByCategory, UpdateFilterRequest, FilterOptions } from 'ducks/search/filters/reducer';
import CheckBoxItem from 'components/common/Inputs/CheckBoxItem';
......@@ -22,8 +22,7 @@ interface StateFromProps {
}
interface DispatchFromProps {
clearFilterByCategory: (categoryId: string) => ClearFilterRequest;
updateFilterByCategory: (categoryId: string, value: FilterOptions) => UpdateFilterRequest;
updateFilter: (categoryId: string, checkedValues: FilterOptions | undefined) => UpdateFilterRequest;
}
export type CheckBoxFilterProps = OwnProps & DispatchFromProps & StateFromProps;
......@@ -64,10 +63,10 @@ export class CheckBoxFilter extends React.Component<CheckBoxFilterProps> {
}
if (Object.keys(checkedValues).length === 0) {
this.props.clearFilterByCategory(categoryId);
this.props.updateFilter(categoryId, undefined);
}
else {
this.props.updateFilterByCategory(categoryId, checkedValues);
this.props.updateFilter(categoryId, checkedValues);
}
};
......@@ -83,7 +82,7 @@ export class CheckBoxFilter extends React.Component<CheckBoxFilterProps> {
export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
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) {
filterValues = {};
}
......@@ -95,8 +94,7 @@ export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({
clearFilterByCategory,
updateFilterByCategory,
updateFilter: (categoryId: string, checkedValues: FilterOptions | undefined) => updateFilterByCategory({ categoryId, value: checkedValues }),
}, dispatch);
};
......
......@@ -27,8 +27,7 @@ describe('CheckBoxFilter', () => {
checkedValues: {
'hive': true,
},
clearFilterByCategory: jest.fn(),
updateFilterByCategory: jest.fn(),
updateFilter: jest.fn(),
...propOverrides
};
const wrapper = shallow<CheckBoxFilter>(<CheckBoxFilter {...props} />);
......@@ -74,32 +73,30 @@ describe('CheckBoxFilter', () => {
let wrapper;
let mockEvent;
let clearCategorySpy;
let updateCategorySpy;
let updateFilterSpy;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
clearCategorySpy = jest.spyOn(props, 'clearFilterByCategory');
updateCategorySpy = jest.spyOn(props, 'updateFilterByCategory');
updateFilterSpy = jest.spyOn(props, 'updateFilter');
})
it('calls props.clearFilterByCategory if no items will be checked', () => {
clearCategorySpy.mockClear();
it('calls props.updateFilter if no items will be checked', () => {
updateFilterSpy.mockClear();
mockEvent = { target: { name: mockCategoryId, value: 'hive', checked: false }};
wrapper.instance().onCheckboxChange(mockEvent);
expect(clearCategorySpy).toHaveBeenCalledWith(mockCategoryId)
expect(updateFilterSpy).toHaveBeenCalledWith(mockCategoryId, undefined)
});
it('calls props.updateFilterByCategory with expected parameters', () => {
updateCategorySpy.mockClear();
it('calls props.updateFilter with expected parameters', () => {
updateFilterSpy.mockClear();
mockEvent = { target: { name: mockCategoryId, value: 'bigquery', checked: true}};
const expectedCheckedValues = {
...props.checkedValues,
'bigquery': true
}
wrapper.instance().onCheckboxChange(mockEvent);
expect(updateCategorySpy).toHaveBeenCalledWith(mockCategoryId, expectedCheckedValues)
expect(updateFilterSpy).toHaveBeenCalledWith(mockCategoryId, expectedCheckedValues)
});
});
......@@ -133,7 +130,7 @@ describe('CheckBoxFilter', () => {
...globalState,
search: {
...globalState.search,
selectedTab: ResourceType.table,
resource: ResourceType.table,
filters: {
[ResourceType.table]: {
[mockCategoryId]: mockFilters
......@@ -146,7 +143,7 @@ describe('CheckBoxFilter', () => {
...globalState,
search: {
...globalState.search,
selectedTab: ResourceType.user,
resource: ResourceType.user,
filters: {
[ResourceType.table]: {}
}
......@@ -183,12 +180,8 @@ describe('CheckBoxFilter', () => {
result = mapDispatchToProps(dispatch);
});
it('sets clearFilterByCategory on the props', () => {
expect(result.clearFilterByCategory).toBeInstanceOf(Function);
});
it('sets updateFilterByCategory on the props', () => {
expect(result.updateFilterByCategory).toBeInstanceOf(Function);
it('sets updateFilter on the props', () => {
expect(result.updateFilter).toBeInstanceOf(Function);
});
});
});
......@@ -2,7 +2,7 @@ import * as React from 'react';
import { bindActionCreators } from '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';
......@@ -28,7 +28,7 @@ export interface StateFromProps {
};
export interface DispatchFromProps {
clearFilterByCategory: (categoryId: string) => ClearFilterRequest;
clearFilter: (categoryId: string) => UpdateFilterRequest;
};
export type FilterSectionProps = OwnProps & DispatchFromProps & StateFromProps;
......@@ -39,7 +39,7 @@ export class FilterSection extends React.Component<FilterSectionProps> {
}
onClearFilter = () => {
this.props.clearFilterByCategory(this.props.categoryId);
this.props.clearFilter(this.props.categoryId);
}
renderFilterComponent = () => {
......@@ -93,7 +93,7 @@ export class FilterSection extends React.Component<FilterSectionProps> {
export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
const filterState = state.search.filters;
const filterValue = filterState[state.search.selectedTab] ? filterState[state.search.selectedTab][ownProps.categoryId] : null;
const filterValue = filterState[state.search.resource] ? filterState[state.search.resource][ownProps.categoryId] : null;
let hasValue = false;
if (filterValue && ownProps.type === FilterType.CHECKBOX_SELECT) {
Object.keys(filterValue).forEach(key => {
......@@ -110,7 +110,7 @@ export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({
clearFilterByCategory,
clearFilter: (categoryId: string) => updateFilterByCategory({ categoryId, value: undefined }),
}, dispatch);
};
......
......@@ -2,7 +2,6 @@ import * as React from 'react';
import { shallow } from 'enzyme';
import { GlobalState } from 'ducks/rootReducer';
import { clearFilterByCategory } from 'ducks/search/filters/reducer';
import { FilterSection, FilterSectionProps, mapDispatchToProps, mapStateToProps } from '../';
......@@ -19,7 +18,7 @@ describe('FilterSection', () => {
categoryId: 'testId',
hasValue: true,
title: 'Category',
clearFilterByCategory: jest.fn(),
clearFilter: jest.fn(),
type: FilterType.INPUT_SELECT,
...propOverrides
};
......@@ -36,10 +35,10 @@ describe('FilterSection', () => {
const setupResult = setup();
props = setupResult.props;
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();
expect(clearFilterSpy).toHaveBeenCalledWith(props.categoryId);
})
......@@ -109,7 +108,7 @@ describe('FilterSection', () => {
...globalState,
search: {
...globalState.search,
selectedTab: ResourceType.table,
resource: ResourceType.table,
filters: {
[ResourceType.table]: {
'database': { 'hive': true },
......@@ -123,7 +122,7 @@ describe('FilterSection', () => {
...globalState,
search: {
...globalState.search,
selectedTab: ResourceType.user,
resource: ResourceType.user,
filters: {
[ResourceType.table]: {}
}
......@@ -175,8 +174,8 @@ describe('FilterSection', () => {
result = mapDispatchToProps(dispatch);
});
it('sets clearFilterByCategory on the props', () => {
expect(result.clearFilterByCategory).toBeInstanceOf(Function);
it('sets clearFilter on the props', () => {
expect(result.clearFilter).toBeInstanceOf(Function);
});
});
});
......@@ -2,7 +2,7 @@ import * as React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { clearFilterByCategory, updateFilterByCategory, ClearFilterRequest, UpdateFilterRequest } from 'ducks/search/filters/reducer';
import { updateFilterByCategory, UpdateFilterRequest } from 'ducks/search/filters/reducer';
import { APPLY_BTN_TEXT } from '../constants';
......@@ -17,8 +17,7 @@ interface StateFromProps {
}
interface DispatchFromProps {
clearFilterByCategory: (categoryId: string) => ClearFilterRequest;
updateFilterByCategory: (categoryId: string, value: string) => UpdateFilterRequest;
updateFilter: (categoryId: string, value: string | undefined) => UpdateFilterRequest;
}
export type InputFilterProps = StateFromProps & DispatchFromProps & OwnProps;
......@@ -46,10 +45,10 @@ export class InputFilter extends React.Component<InputFilterProps, InputFilterSt
onApplyChanges = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if(!!this.state.value) {
this.props.updateFilterByCategory(this.props.categoryId, this.state.value);
this.props.updateFilter(this.props.categoryId, this.state.value);
}
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
export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
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 {
value: value || '',
}
......@@ -90,8 +89,7 @@ export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({
clearFilterByCategory,
updateFilterByCategory,
updateFilter: (categoryId: string, value: string | undefined) => updateFilterByCategory({ categoryId, value }),
}, dispatch);
};
......
......@@ -6,7 +6,6 @@ import { InputFilter, InputFilterProps, mapDispatchToProps, mapStateToProps } fr
import { APPLY_BTN_TEXT } from '../../constants';
import { GlobalState } from 'ducks/rootReducer';
import { clearFilterByCategory, updateFilterByCategory } from 'ducks/search/filters/reducer';
import globalState from 'fixtures/globalState';
......@@ -19,8 +18,7 @@ describe('InputFilter', () => {
const props: InputFilterProps = {
categoryId: 'schema',
value: 'schema_name',
clearFilterByCategory: jest.fn(),
updateFilterByCategory: jest.fn(),
updateFilter: jest.fn(),
...propOverrides
};
const wrapper = shallow<InputFilter>(<InputFilter {...props} />);
......@@ -78,29 +76,27 @@ describe('InputFilter', () => {
let props;
let wrapper;
let clearCategorySpy;
let updateCategorySpy;
let updateFilterSpy;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
clearCategorySpy = jest.spyOn(props, 'clearFilterByCategory');
updateCategorySpy = jest.spyOn(props, 'updateFilterByCategory');
updateFilterSpy = jest.spyOn(props, 'updateFilter');
});
it('calls props.clearFilterByCategory if state.value is falsy', () => {
clearCategorySpy.mockClear();
it('calls props.updateFilter if state.value is falsy', () => {
updateFilterSpy.mockClear();
wrapper.setState({ value: '' });
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', () => {
updateCategorySpy.mockClear();
it('calls props.updateFilter if state.value has a truthy value', () => {
updateFilterSpy.mockClear();
const mockValue = 'hello';
wrapper.setState({ value: mockValue });
wrapper.instance().onApplyChanges({ preventDefault: jest.fn() });
expect(updateCategorySpy).toHaveBeenCalledWith(props.categoryId, mockValue)
expect(updateFilterSpy).toHaveBeenCalledWith(props.categoryId, mockValue);
});
});
......@@ -162,7 +158,7 @@ describe('InputFilter', () => {
...globalState,
search: {
...globalState.search,
selectedTab: ResourceType.table,
resource: ResourceType.table,
filters: {
[ResourceType.table]: {
[mockCategoryId]: mockFilters
......@@ -175,7 +171,7 @@ describe('InputFilter', () => {
...globalState,
search: {
...globalState.search,
selectedTab: ResourceType.user,
resource: ResourceType.user,
filters: {
[ResourceType.table]: {}
}
......@@ -212,12 +208,8 @@ describe('InputFilter', () => {
result = mapDispatchToProps(dispatch);
});
it('sets clearFilterByCategory on the props', () => {
expect(result.clearFilterByCategory).toBeInstanceOf(Function);
});
it('sets updateFilterByCategory on the props', () => {
expect(result.updateFilterByCategory).toBeInstanceOf(Function);
it('sets updateFilter on the props', () => {
expect(result.updateFilter).toBeInstanceOf(Function);
});
});
});
......@@ -63,7 +63,7 @@ export class SearchFilter extends React.Component<SearchFilterProps> {
};
export const mapStateToProps = (state: GlobalState) => {
const resourceType = state.search.selectedTab;
const resourceType = state.search.resource;
const filterCategories = getFilterConfigByResource(resourceType);
const filterSections = [];
......
......@@ -156,7 +156,7 @@ describe('mapStateToProps', () => {
...globalState,
search: {
...globalState.search,
selectedTab: ResourceType.table,
resource: ResourceType.table,
filters: {
[ResourceType.table]: {
[mockSchemaId]: mockSchemaValue,
......@@ -170,10 +170,10 @@ describe('mapStateToProps', () => {
let getFilterConfigByResourceSpy;
let result;
it('calls getFilterConfigByResource with selectedTab', () => {
it('calls getFilterConfigByResource with resource', () => {
getFilterConfigByResourceSpy = jest.spyOn(ConfigUtils, 'getFilterConfigByResource').mockReturnValue(MOCK_CATEGORY_CONFIG);
mapStateToProps(mockStateWithFilters);
expect(getFilterConfigByResourceSpy).toHaveBeenCalledWith(mockStateWithFilters.search.selectedTab);
expect(getFilterConfigByResourceSpy).toHaveBeenCalledWith(mockStateWithFilters.search.resource);
});
it('sets expected filterSections on the result', () => {
......
......@@ -12,18 +12,17 @@ import SearchFilter from './SearchFilter';
import SearchPanel from './SearchPanel';
import { GlobalState } from 'ducks/rootReducer';
import { setPageIndex, setResource, urlDidUpdate } from 'ducks/search/reducer';
import { submitSearchResource, urlDidUpdate } from 'ducks/search/reducer';
import {
DashboardSearchResults,
SearchResults,
SetPageIndexRequest,
SetResourceRequest,
SubmitSearchResourceRequest,
TableSearchResults,
UrlDidUpdateRequest,
UserSearchResults,
} from 'ducks/search/types';
import { Resource, ResourceType } from 'interfaces';
import { Resource, ResourceType, SearchType } from 'interfaces';
// TODO: Use css-modules instead of 'import'
import './styles.scss';
......@@ -43,7 +42,7 @@ import {
export interface StateFromProps {
hasFilters: boolean;
searchTerm: string;
selectedTab: ResourceType;
resource: ResourceType;
isLoading: boolean;
tables: TableSearchResults;
dashboards: DashboardSearchResults;
......@@ -51,8 +50,7 @@ export interface StateFromProps {
}
export interface DispatchFromProps {
setResource: (resource: ResourceType) => SetResourceRequest;
setPageIndex: (pageIndex: number) => SetPageIndexRequest;
setPageIndex: (pageIndex: number) => SubmitSearchResourceRequest;
urlDidUpdate: (urlSearch: UrlSearch) => UrlDidUpdateRequest;
}
......@@ -76,7 +74,7 @@ export class SearchPage extends React.Component<SearchPageProps> {
}
renderSearchResults = () => {
switch(this.props.selectedTab) {
switch(this.props.resource) {
case ResourceType.table:
return this.getTabContent(this.props.tables, ResourceType.table);
case ResourceType.user:
......@@ -183,11 +181,11 @@ export class SearchPage extends React.Component<SearchPageProps> {
}
export const mapStateToProps = (state: GlobalState) => {
const resourceFilters = state.search.filters[state.search.selectedTab];
const resourceFilters = state.search.filters[state.search.resource];
return {
hasFilters: resourceFilters && Object.keys(resourceFilters).length > 0,
searchTerm: state.search.search_term,
selectedTab: state.search.selectedTab,
resource: state.search.resource,
isLoading: state.search.isLoading,
tables: state.search.tables,
users: state.search.users,
......@@ -196,7 +194,10 @@ export const mapStateToProps = (state: GlobalState) => {
};
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);
......@@ -35,12 +35,11 @@ describe('SearchPage', () => {
const props: SearchPageProps = {
hasFilters: false,
searchTerm: globalState.search.search_term,
selectedTab: ResourceType.table,
resource: ResourceType.table,
isLoading: false,
dashboards: globalState.search.dashboards,
tables: globalState.search.tables,
users: globalState.search.users,
setResource: jest.fn(),
setPageIndex: jest.fn(),
urlDidUpdate: jest.fn(),
...routerProps,
......@@ -55,7 +54,7 @@ describe('SearchPage', () => {
let wrapper;
beforeAll(() => {
const setupResult = setup(null, {
search: '/search?searchTerm=testName&selectedTab=table&pageIndex=1',
search: '/search?searchTerm=testName&resource=table&pageIndex=1',
});
props = setupResult.props;
wrapper = setupResult.wrapper;
......@@ -74,7 +73,7 @@ describe('SearchPage', () => {
let mockPrevProps;
beforeAll(() => {
const setupResult = setup(null, {
search: '/search?searchTerm=testName&selectedTab=table&pageIndex=1',
search: '/search?searchTerm=testName&resource=table&pageIndex=1',
});
props = setupResult.props;
wrapper = setupResult.wrapper;
......@@ -82,7 +81,7 @@ describe('SearchPage', () => {
mockPrevProps = {
searchTerm: 'previous',
location: {
search: '/search?searchTerm=previous&selectedTab=table&pageIndex=0',
search: '/search?searchTerm=previous&resource=table&pageIndex=0',
pathname: 'mockstr',
state: jest.fn(),
hash: 'mockstr',
......@@ -224,7 +223,7 @@ describe('SearchPage', () => {
describe('renderSearchResults', () => {
it('renders the correct content for table resources', () => {
const { props, wrapper } = setup({
selectedTab: ResourceType.table
resource: ResourceType.table
});
const getTabContentSpy = jest.spyOn(wrapper.instance(), 'getTabContent');
shallow(wrapper.instance().renderSearchResults());
......@@ -233,7 +232,7 @@ describe('SearchPage', () => {
it('renders the correct content for user resources', () => {
const { props, wrapper } = setup({
selectedTab: ResourceType.user
resource: ResourceType.user
});
const getTabContentSpy = jest.spyOn(wrapper.instance(), 'getTabContent');
shallow(wrapper.instance().renderSearchResults());
......@@ -242,16 +241,16 @@ describe('SearchPage', () => {
it('renders the correct content for dashboard resources', () => {
const { props, wrapper } = setup({
selectedTab: ResourceType.dashboard
resource: ResourceType.dashboard
});
const getTabContentSpy = jest.spyOn(wrapper.instance(), 'getTabContent');
shallow(wrapper.instance().renderSearchResults());
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({
selectedTab: null
resource: null
});
const renderedSearchResults = wrapper.instance().renderSearchResults();
expect(renderedSearchResults).toBe(null);
......@@ -313,10 +312,6 @@ describe('mapDispatchToProps', () => {
result = mapDispatchToProps(dispatch);
});
it('sets setResource on the props', () => {
expect(result.setResource).toBeInstanceOf(Function);
});
it('sets setPageIndex on the props', () => {
expect(result.setPageIndex).toBeInstanceOf(Function);
});
......@@ -336,8 +331,8 @@ describe('mapStateToProps', () => {
expect(result.searchTerm).toEqual(globalState.search.search_term);
});
it('sets selectedTab on the props', () => {
expect(result.selectedTab).toEqual(globalState.search.selectedTab);
it('sets resource on the props', () => {
expect(result.resource).toEqual(globalState.search.resource);
});
it('sets isLoading on the props', () => {
......@@ -361,7 +356,7 @@ describe('mapStateToProps', () => {
const testState = {
...globalState
};
testState.search.selectedTab = ResourceType.user;
testState.search.resource = ResourceType.user;
testState.search.filters = defaultEmptyFilters;
result = mapStateToProps(testState);
expect(result.hasFilters).toBeFalsy();
......@@ -371,7 +366,7 @@ describe('mapStateToProps', () => {
const testState = {
...globalState
};
testState.search.selectedTab = ResourceType.table;
testState.search.resource = ResourceType.table;
testState.search.filters = datasetFilterExample;
result = mapStateToProps(testState);
expect(result.hasFilters).toBe(true);
......
import * as React from 'react';
import { bindActionCreators } from '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 './styles.scss';
......@@ -14,7 +15,7 @@ interface OwnProps {
}
export interface DispatchFromProps {
searchTag: (tagName: string) => SetSearchInputRequest;
searchTag: (tagName: string) => SubmitSearchResourceRequest;
}
export type TagInfoProps = OwnProps & DispatchFromProps;
......@@ -60,9 +61,14 @@ export class TagInfo extends React.Component<TagInfoProps> {
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({
/* Note: Pattern intentionally isolates component from extraneous hardcoded parameters */
/* Note: This will have to be extended to all resources that support tags */
searchTag: (tagName: string) => setSearchInputByResource({ 'tag': tagName }, ResourceType.table, 0, '')
searchTag: (tagName: string) => submitSearchResource({
resourceFilters: { 'tag': tagName },
resource: ResourceType.table,
pageIndex: 0,
searchTerm: '',
searchType: SearchType.FILTER,
updateUrl: true,
})
}, dispatch);
};
......
......@@ -5,8 +5,8 @@ import { withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { GlobalState } from 'ducks/rootReducer';
import { clearSearch, submitSearch, getInlineResultsDebounce, selectInlineResult } from 'ducks/search/reducer';
import { ClearSearchRequest, SubmitSearchRequest, InlineSearchRequest, InlineSearchSelect } from 'ducks/search/types';
import { submitSearch, getInlineResultsDebounce, selectInlineResult } from 'ducks/search/reducer';
import { SubmitSearchRequest, InlineSearchRequest, InlineSearchSelect } from 'ducks/search/types';
import { ResourceType } from 'interfaces';
......@@ -25,7 +25,7 @@ export interface StateFromProps {
}
export interface DispatchFromProps {
clearSearch?: () => ClearSearchRequest;
clearSearch?: () => SubmitSearchRequest;
submitSearch: (searchTerm: string) => SubmitSearchRequest;
onInputChange: (term: string) => InlineSearchRequest;
onSelectInlineResult: (resourceType: ResourceType, searchTerm: string, updateUrl: boolean) => InlineSearchSelect;
......@@ -190,8 +190,8 @@ export const mapDispatchToProps = (dispatch: any, ownProps) => {
const updateStateOnClear = ownProps.history.location.pathname === '/search';
return bindActionCreators({
clearSearch: updateStateOnClear ? clearSearch : null,
submitSearch: (searchTerm: string) => { return submitSearch(searchTerm, useFilters) },
clearSearch: updateStateOnClear ? () => submitSearch({ useFilters, searchTerm: '' }) : null,
submitSearch: (searchTerm: string) => submitSearch({ searchTerm, useFilters }),
onInputChange: getInlineResultsDebounce,
onSelectInlineResult: selectInlineResult
}, dispatch);
......
......@@ -23,20 +23,20 @@ import { createIssueWatcher, getIssuesWatcher } from './issue/sagas';
import { getPopularTablesWatcher } from './popularTables/sagas';
// Search
import {
clearSearchWatcher,
filterWatcher,
filterWatcher2,
inlineSearchWatcher,
inlineSearchWatcherDebounce,
loadPreviousSearchWatcher,
searchAllWatcher,
searchResourceWatcher,
setPageIndexWatcher,
setResourceWatcher,
selectInlineResultsWatcher,
submitSearchWatcher,
submitSearchResourceWatcher,
updateSearchStateWatcher,
urlDidUpdateWatcher
} from './search/sagas';
import {
filterWatcher,
} from './search/filters/sagas';
// TableDetail
import { updateTableOwnerWatcher } from './tableMetadata/owners/sagas';
......@@ -70,22 +70,20 @@ export default function* rootSaga() {
submitNotificationWatcher(),
// FeedbackForm
submitFeedbackWatcher(),
// Issues
getIssuesWatcher(),
createIssueWatcher(),
// Issues
getIssuesWatcher(),
createIssueWatcher(),
// Search
clearSearchWatcher(),
filterWatcher(),
filterWatcher2(),
inlineSearchWatcher(),
inlineSearchWatcherDebounce(),
loadPreviousSearchWatcher(),
searchAllWatcher(),
searchResourceWatcher(),
selectInlineResultsWatcher(),
setPageIndexWatcher(),
setResourceWatcher(),
submitSearchWatcher(),
submitSearchResourceWatcher(),
updateSearchStateWatcher(),
urlDidUpdateWatcher(),
// PopularTables
getPopularTablesWatcher(),
......
import { SubmitSearchResource, SubmitSearchResourceRequest } from 'ducks/search/types';
import { ResourceType } from 'interfaces';
import { filterFromObj } from 'ducks/utilMethods';
/* ACTION TYPES */
export enum UpdateSearchFilter {
CLEAR_ALL = 'amundsen/search/filter/CLEAR_ALL',
CLEAR_CATEGORY = 'amundsen/search/filter/CLEAR_CATEGORY',
SET_BY_RESOURCE = 'amundsen/search/filter/SET_BY_RESOURCE',
UPDATE_CATEGORY = 'amundsen/search/filter/UPDATE_CATEGORY',
};
interface ClearAllFiltersRequest {
type: UpdateSearchFilter.CLEAR_ALL;
};
export interface ClearFilterRequest {
payload: {
categoryId: string;
};
type: UpdateSearchFilter.CLEAR_CATEGORY;
REQUEST = 'amundsen/search/filter/UPDATE_SEARCH_FILTER_REQUEST',
};
export interface SetSearchInputRequest {
payload: {
filters: ResourceFilterReducerState;
pageIndex?: number;
resourceType: ResourceType;
term?: string;
};
type: UpdateSearchFilter.SET_BY_RESOURCE;
};
export type UpdateFilterPayload = {
categoryId: string;
value: string | FilterOptions | undefined;
}
export interface UpdateFilterRequest {
payload: {
categoryId: string;
value: string | FilterOptions;
};
type: UpdateSearchFilter.UPDATE_CATEGORY;
payload: UpdateFilterPayload;
type: UpdateSearchFilter.REQUEST;
};
/* ACTIONS */
export function clearAllFilters(): ClearAllFiltersRequest {
return {
type: UpdateSearchFilter.CLEAR_ALL,
};
};
export function clearFilterByCategory(categoryId: string): ClearFilterRequest {
return {
payload: {
categoryId,
},
type: UpdateSearchFilter.CLEAR_CATEGORY,
};
};
export function setSearchInputByResource(filters: ResourceFilterReducerState,
resourceType: ResourceType,
pageIndex?: number,
term?: string): SetSearchInputRequest {
return {
payload: {
filters,
pageIndex,
resourceType,
term
},
type: UpdateSearchFilter.SET_BY_RESOURCE,
};
};
export function updateFilterByCategory(categoryId: string, value: string | FilterOptions): UpdateFilterRequest {
export function updateFilterByCategory({ categoryId, value }: UpdateFilterPayload): UpdateFilterRequest {
return {
payload: {
categoryId,
value
},
type: UpdateSearchFilter.UPDATE_CATEGORY,
type: UpdateSearchFilter.REQUEST,
};
};
......@@ -98,31 +43,17 @@ export const initialFilterState: FilterReducerState = {
[ResourceType.table]: initialTableFilterState,
};
export default function reducer(state: FilterReducerState = initialFilterState, action, resourceType: ResourceType): FilterReducerState {
const resourceFilters = state[resourceType];
const { payload, type } = action;
switch (type) {
case UpdateSearchFilter.CLEAR_ALL:
return initialFilterState;
case UpdateSearchFilter.CLEAR_CATEGORY:
return {
...state,
[resourceType]: filterFromObj(resourceFilters, [payload.categoryId])
};
case UpdateSearchFilter.SET_BY_RESOURCE:
return {
...state,
[payload.resourceType]: payload.filters
};
case UpdateSearchFilter.UPDATE_CATEGORY:
return {
...state,
[resourceType]: {
...resourceFilters,
[payload.categoryId]: payload.value
}
};
export default function reducer(state: FilterReducerState = initialFilterState, action): FilterReducerState {
switch (action.type) {
case SubmitSearchResource.REQUEST:
const { payload } = <SubmitSearchResourceRequest>action;
if (payload.resource && payload.resourceFilters) {
return {
...state,
[payload.resource]: payload.resourceFilters
};
}
return state;
default:
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';
import { Search as UrlSearch } from 'history';
import filterReducer, { initialFilterState, UpdateSearchFilter, FilterReducerState } from './filters/reducer';
import filterReducer, { initialFilterState, FilterReducerState } from './filters/reducer';
import {
DashboardSearchResults,
SearchAll,
SearchAllRequest,
SearchAllReset,
SearchAllResponse,
SearchAllResponsePayload,
SearchResource,
......@@ -24,18 +23,24 @@ import {
InlineSearchUpdate,
TableSearchResults,
UserSearchResults,
ClearSearch,
ClearSearchRequest,
SubmitSearchRequest,
SubmitSearch,
SetResourceRequest,
SetResource,
SetPageIndexRequest, SetPageIndex, LoadPreviousSearchRequest, LoadPreviousSearch, UrlDidUpdateRequest, UrlDidUpdate,
SubmitSearchResourcePayload,
SubmitSearchResourceRequest,
SubmitSearchResource,
LoadPreviousSearchRequest,
LoadPreviousSearch,
UpdateSearchStateRequest,
UpdateSearchStateReset,
UpdateSearchStatePayload,
UpdateSearchState,
UrlDidUpdateRequest,
UrlDidUpdate
} from './types';
export interface SearchReducerState {
search_term: string;
selectedTab: ResourceType;
resource: ResourceType;
isLoading: boolean;
dashboards: DashboardSearchResults;
tables: TableSearchResults;
......@@ -108,6 +113,7 @@ export function getInlineResultsSuccess(inlineResults: InlineSearchResponsePaylo
export function getInlineResultsFailure(): InlineSearchResponse {
return { type: InlineSearch.FAILURE };
};
export function selectInlineResult(resourceType: ResourceType, searchTerm: string, updateUrl: boolean = false): InlineSearchSelect {
return {
payload: {
......@@ -118,6 +124,7 @@ export function selectInlineResult(resourceType: ResourceType, searchTerm: strin
type: InlineSearch.SELECT
};
};
export function updateFromInlineResult(data: InlineSearchUpdatePayload): InlineSearchUpdate {
return {
payload: data,
......@@ -125,36 +132,33 @@ export function updateFromInlineResult(data: InlineSearchUpdatePayload): InlineS
};
};
export function searchReset(): SearchAllReset {
return {
type: SearchAll.RESET,
};
};
export function submitSearch(searchTerm: string, useFilters: boolean = false): SubmitSearchRequest {
export function submitSearch({ searchTerm, useFilters } : { searchTerm: string, useFilters: boolean }): SubmitSearchRequest {
return {
payload: { searchTerm, useFilters },
type: SubmitSearch.REQUEST,
};
};
export function clearSearch(): ClearSearchRequest {
export function submitSearchResource({ resourceFilters, pageIndex, searchTerm, resource, searchType, updateUrl } : SubmitSearchResourcePayload): SubmitSearchResourceRequest {
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 {
payload: { resource, updateUrl },
type: SetResource.REQUEST,
payload: {
filters,
resource,
updateUrl,
},
type: UpdateSearchState.REQUEST,
};
};
export function setPageIndex(pageIndex: number, updateUrl: boolean = true): SetPageIndexRequest {
export function resetSearchState(): UpdateSearchStateReset {
return {
payload: { pageIndex, updateUrl },
type: SetPageIndex.REQUEST,
type: UpdateSearchState.RESET,
};
};
......@@ -164,14 +168,13 @@ export function loadPreviousSearch(): LoadPreviousSearchRequest {
};
};
export function urlDidUpdate(urlSearch: UrlSearch): UrlDidUpdateRequest{
export function urlDidUpdate(urlSearch: UrlSearch): UrlDidUpdateRequest {
return {
payload: { urlSearch },
type: UrlDidUpdate.REQUEST,
};
};
/* REDUCER */
export const initialInlineResultsState = {
isLoading: false,
......@@ -189,7 +192,7 @@ export const initialInlineResultsState = {
export const initialState: SearchReducerState = {
search_term: '',
isLoading: false,
selectedTab: ResourceType.table,
resource: ResourceType.table,
dashboards: {
page_index: 0,
results: [],
......@@ -211,28 +214,29 @@ export const initialState: SearchReducerState = {
export default function reducer(state: SearchReducerState = initialState, action): SearchReducerState {
switch (action.type) {
case UpdateSearchFilter.SET_BY_RESOURCE:
case SubmitSearch.REQUEST:
return {
...state,
search_term: action.payload.term,
filters: filterReducer(state.filters, action, state.selectedTab),
isLoading: true,
search_term: action.payload.searchTerm,
}
case UpdateSearchFilter.CLEAR_ALL:
case SubmitSearchResource.REQUEST:
return {
...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 UpdateSearchFilter.UPDATE_CATEGORY:
case UpdateSearchState.REQUEST:
const payload = action.payload;
return {
...state,
isLoading: true,
filters: filterReducer(state.filters, action, state.selectedTab),
filters: payload.filters || state.filters,
resource: payload.resource || state.resource,
}
case SearchAll.RESET:
return initialState;
case UpdateSearchState.RESET:
return initialState;
case SearchAll.REQUEST:
// updates search term to reflect action
return {
...state,
inlineResults: {
......@@ -273,16 +277,11 @@ export default function reducer(state: SearchReducerState = initialState, action
...initialState,
search_term: state.search_term,
};
case SetResource.REQUEST:
return {
...state,
selectedTab: (<SetResourceRequest>action).payload.resource
};
case InlineSearch.UPDATE:
const { searchTerm, selectedTab, tables, users } = (<InlineSearchUpdate>action).payload;
const { searchTerm, resource, tables, users } = (<InlineSearchUpdate>action).payload;
return {
...state,
selectedTab,
resource,
tables,
users,
search_term: searchTerm,
......
......@@ -8,8 +8,6 @@ import { ResourceType, SearchType } from 'interfaces';
import * as API from './api/v0';
import {
ClearSearch,
ClearSearchRequest,
LoadPreviousSearch,
LoadPreviousSearchRequest,
SearchAll,
......@@ -18,10 +16,12 @@ import {
SearchResourceRequest,
InlineSearch,
InlineSearchRequest,
SetPageIndex,
SetPageIndexRequest, SetResource, SetResourceRequest,
SubmitSearch,
SubmitSearchRequest,
SubmitSearchResource,
SubmitSearchResourceRequest,
UpdateSearchState,
UpdateSearchStateRequest,
UrlDidUpdate,
UrlDidUpdateRequest,
} from './types';
......@@ -40,166 +40,84 @@ import {
getInlineResultsSuccess,
getInlineResultsFailure,
updateFromInlineResult,
setPageIndex, setResource,
updateSearchState,
submitSearchResource,
} from './reducer';
import {
clearAllFilters,
setSearchInputByResource,
initialFilterState,
UpdateSearchFilter
} from './filters/reducer';
import { autoSelectResource, getPageIndex, getSearchState } from './utils';
import { BrowserHistory, updateSearchUrl } from 'utils/navigationUtils';
//////////////////////////////////////////////////////////////////////////////
// SEARCH SAGAS
// The actions that trigger these sagas are fired directly from components.
//////////////////////////////////////////////////////////////////////////////
/**
* Listens to actions triggers by user updates to the filter state.
* For better user experience debounce the start of the worker as multiple updates can happen in < 1 second.
* Handles workflow for any user action that causes an update to the searchTerm,
* which requires that all resources be re-searched.
*/
export function* filterWatcher(): SagaIterator {
yield debounce(750, [UpdateSearchFilter.CLEAR_CATEGORY, UpdateSearchFilter.UPDATE_CATEGORY], filterWorker);
export function* submitSearchWorker(action: SubmitSearchRequest): SagaIterator {
const { searchTerm, useFilters } = action.payload;
yield put(searchAll(!!searchTerm ? SearchType.SUBMIT_TERM : SearchType.CLEAR_TERM, searchTerm, undefined, 0, useFilters));
};
/*
* 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);
export function* submitSearchWatcher(): SagaIterator {
yield takeLatest(SubmitSearch.REQUEST, submitSearchWorker);
};
/**
* 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.
* Handles workflow for any user action that causes an update to the search input for a given resource
*/
export function* filterWatcher2(): SagaIterator {
yield takeLatest(UpdateSearchFilter.SET_BY_RESOURCE, filterWorker2);
};
export function* submitSearchResourceWorker(action: SubmitSearchResourceRequest): SagaIterator {
const state = yield select(getSearchState);
let { search_term, resource } = state;
const { filters } = state;
const { pageIndex, searchType, searchTerm, updateUrl } = action.payload;
/**
* Executes a search on the given resource.
* Actions that trigger this worker will have updated the filter reducer.
* The updated filter state is applied in searchResourceWorker().
* Updates the search url to reflect the change in filters.
* This is intended to be temporary code. searchResource Saga restructring will allow us to consolidate this support.
*/
export function* filterWorker2(action: any): SagaIterator {
const state = yield select(getSearchState);
const { pageIndex = 0, resourceType, term = '' } = action.payload;
yield put(searchResource(SearchType.FILTER, term, resourceType, pageIndex));
updateSearchUrl({ term, filters: state.filters, resource: resourceType, index: pageIndex }, false);
};
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));
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);
if (updateUrl) {
updateSearchUrl({
filters,
resource,
term: search_term,
index: pageIndex,
});
}
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 {
const state = yield select(getSearchState);
const { searchTerm, useFilters } = action.payload;
yield put(searchAll(SearchType.SUBMIT_TERM, searchTerm, undefined, undefined, useFilters));
updateSearchUrl({ term: searchTerm, filters: state.filters });
};
export function* submitSearchWatcher(): SagaIterator {
yield takeEvery(SubmitSearch.REQUEST, submitSearchWorker);
export function* submitSearchResourceWatcher(): SagaIterator {
yield takeEvery(SubmitSearchResource.REQUEST, submitSearchResourceWorker);
};
export function* setResourceWorker(action: SetResourceRequest): SagaIterator {
const { resource, 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);
if (updateUrl) {
updateSearchUrl({
resource,
resource: resource || state.resource,
term: state.search_term,
index: getPageIndex(state, resource),
filters: state.filters,
filters: filters || state.filters,
});
}
};
export function* setResourceWatcher(): SagaIterator {
yield takeEvery(SetResource.REQUEST, setResourceWorker);
};
export function* setPageIndexWorker(action: SetPageIndexRequest): SagaIterator {
const { pageIndex, updateUrl } = action.payload;
const state = yield select(getSearchState);
yield put(searchResource(SearchType.PAGINATION, state.search_term, state.selectedTab, pageIndex));
if (updateUrl) {
updateSearchUrl({
term: state.search_term,
resource: state.selectedTab,
index: pageIndex,
filters: state.filters,
});
}
};
export function* setPageIndexWatcher(): SagaIterator {
yield takeEvery(SetPageIndex.REQUEST, setPageIndexWorker);
};
export function* clearSearchWorker(action: ClearSearchRequest): SagaIterator {
/* If there was a previous search term, search each resource using filters */
const state = yield select(getSearchState);
if (!!state.search_term) {
yield put(searchAll(SearchType.CLEAR_TERM, '', undefined, undefined, true));
}
};
export function* clearSearchWatcher(): SagaIterator {
yield takeEvery(ClearSearch.REQUEST, clearSearchWorker);
export function* updateSearchStateWatcher(): SagaIterator {
yield takeEvery(UpdateSearchState.REQUEST, updateSearchStateWorker);
};
/**
* 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 {
const { urlSearch } = action.payload;
const { term = '', resource, index, filters } = qs.parse(urlSearch);
......@@ -208,28 +126,42 @@ export function* urlDidUpdateWorker(action: UrlDidUpdateRequest): SagaIterator {
const state = yield select(getSearchState);
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) {
if (resource !== state.selectedTab) {
yield put(setResource(resource, false))
if (resource !== state.resource) {
yield put(updateSearchState({ resource }))
}
if (parsedFilters && !_.isEqual(state.filters[resource], parsedFilters)) {
/* This will update filter state + search resource */
yield put(setSearchInputByResource(parsedFilters, resource, parsedIndex, term));
yield put(submitSearchResource({
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 {
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 {
const state = yield select(getSearchState);
if (state.search_term === "") {
......@@ -238,7 +170,7 @@ export function* loadPreviousSearchWorker(action: LoadPreviousSearchRequest): Sa
}
updateSearchUrl({
term: state.search_term,
resource: state.selectedTab,
resource: state.resource,
index: getPageIndex(state),
filters: state.filters,
});
......@@ -248,10 +180,10 @@ export function* loadPreviousSearchWatcher(): SagaIterator {
};
//////////////////////////////////////////////////////////////////////////////
// API/END SAGAS
// These sagas directly trigger axios search requests.
// The actions that trigger them should only be fired by other sagas,
// and these sagas should be considered the "end" of any saga chain.
// CORE SEARCH SAGAS
// These sagas are not called directly by any components. They should be
// called by other sagas as the final step for all use cases that will update
// search results.
//////////////////////////////////////////////////////////////////////////////
export function* searchResourceWorker(action: SearchResourceRequest): SagaIterator {
......@@ -272,7 +204,7 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
let { resource } = action.payload;
const { pageIndex, term, useFilters, searchType } = action.payload;
if (!useFilters) {
yield put(clearAllFilters())
yield put(updateSearchState({ filters: initialFilterState }))
}
const state = yield select(getSearchState);
......@@ -287,8 +219,8 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
call(API.searchResource, dashboardIndex, ResourceType.dashboard, term, state.filters[ResourceType.dashboard], searchType),
]);
const searchAllResponse = {
resource,
search_term: term,
selectedTab: resource,
tables: tableResponse.tables || initialState.tables,
users: userResponse.users || initialState.users,
dashboards: dashboardResponse.dashboards || initialState.dashboards,
......@@ -296,7 +228,7 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
};
if (resource === undefined) {
resource = autoSelectResource(searchAllResponse);
searchAllResponse.selectedTab = resource;
searchAllResponse.resource = resource;
}
const index = getPageIndex(searchAllResponse);
yield put(searchAllSuccess(searchAllResponse));
......@@ -309,3 +241,60 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
export function* searchAllWatcher(): SagaIterator {
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 { debounce } from 'redux-saga/effects';
import { DEFAULT_RESOURCE_TYPE, ResourceType, SearchType } from 'interfaces';
import { 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 Utils from '../utils';
import * as Sagas from '../sagas';
import * as filterReducer from '../filters/reducer';
const MOCK_TABLE_FILTER_STATE = {'database': { 'hive': true }};
const MOCK_FILTER_STATE = {
[ResourceType.table]: {
'database': { 'hive': true }
}
[ResourceType.table]: MOCK_TABLE_FILTER_STATE,
};
const filterReducerSpy = jest.spyOn(filterReducer, 'default').mockImplementation(() => MOCK_FILTER_STATE);
import reducer, {
clearSearch,
getInlineResults,
getInlineResultsDebounce,
getInlineResultsSuccess,
getInlineResultsFailure,
initialState,
initialInlineResultsState,
loadPreviousSearch,
resetSearchState,
searchAll,
searchAllFailure,
searchAllSuccess,
SearchReducerState,
searchReset,
searchResource,
searchResourceFailure,
searchResourceSuccess,
selectInlineResult,
setPageIndex,
setResource,
submitSearch,
submitSearchResource,
updateFromInlineResult,
updateSearchState,
urlDidUpdate,
} from '../reducer';
import {
ClearSearch,
LoadPreviousSearch,
InlineSearch,
InlineSearchResponsePayload,
......@@ -51,8 +44,6 @@ import {
SearchAllResponsePayload,
SearchResource,
SearchResponsePayload,
SetPageIndex,
SetResource,
SubmitSearch,
UrlDidUpdate,
} from '../types';
......@@ -62,7 +53,7 @@ import globalState from 'fixtures/globalState';
const updateSearchUrlSpy = jest.spyOn(NavigationUtils, 'updateSearchUrl');
const searchState = globalState.search;
describe('search ducks', () => {
describe('search reducer', () => {
const expectedSearchResults: SearchResponsePayload = {
search_term: 'testName',
tables: {
......@@ -84,7 +75,7 @@ describe('search ducks', () => {
};
const expectedSearchAllResults: SearchAllResponsePayload = {
search_term: 'testName',
selectedTab: ResourceType.table,
resource: ResourceType.table,
dashboards: {
page_index: 0,
results: [],
......@@ -128,7 +119,7 @@ describe('search ducks', () => {
const inlineUpdatePayload: InlineSearchUpdatePayload = {
searchTerm: 'testName',
selectedTab: ResourceType.table,
resource: ResourceType.table,
tables: {
page_index: 0,
results: [],
......@@ -210,45 +201,13 @@ describe('search ducks', () => {
expect(action.type).toBe(SearchResource.FAILURE);
});
it('searchReset - returns the action to reset search state', () => {
const action = searchReset();
expect(action.type).toBe(SearchAll.RESET);
});
it('submitSearch - returns the action to submit a search without useFilters', () => {
it('submitSearch - returns the action to submit a search', () => {
const term = 'test';
const action = submitSearch(term);
const shouldUseFilters = true;
const action = submitSearch({ searchTerm: term, useFilters: shouldUseFilters });
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 action = submitSearch(term, true);
expect(action.type).toBe(SubmitSearch.REQUEST);
expect(action.payload.searchTerm).toBe(term);
expect(action.payload.useFilters).toBe(true);
});
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);
expect(action.payload.useFilters).toBe(shouldUseFilters);
});
it('loadPreviousSearch - returns the action to load the previous search', () => {
......@@ -299,17 +258,17 @@ describe('search ducks', () => {
expect(action.type).toBe(InlineSearch.UPDATE);
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', () => {
let testState: SearchReducerState;
let result;
beforeAll(() => {
testState = initialState;
testState = {
...searchState,
filters: MOCK_FILTER_STATE,
resource: ResourceType.user,
}
});
it('should return the existing state if action is not handled', () => {
expect(reducer(testState, { type: 'INVALID.ACTION' })).toEqual(testState);
......@@ -345,20 +304,16 @@ describe('search ducks', () => {
});
});
it('should handle SearchAll.RESET', () => {
expect(reducer(testState, searchReset())).toEqual(initialState);
});
it('should handle SearchResource.REQUEST', () => {
expect(reducer(testState, searchResource(SearchType.SUBMIT_TERM, 'test', ResourceType.table, 0))).toEqual({
...initialState,
...testState,
isLoading: true,
});
});
it('should handle SearchResource.SUCCESS', () => {
expect(reducer(testState, searchResourceSuccess(expectedSearchResults))).toEqual({
...initialState,
...testState,
...expectedSearchResults,
isLoading: false,
});
......@@ -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', () => {
const { searchTerm, selectedTab, tables, users } = inlineUpdatePayload;
const { searchTerm, resource, tables, users } = inlineUpdatePayload;
expect(reducer(testState, updateFromInlineResult(inlineUpdatePayload))).toEqual({
...testState,
selectedTab,
resource,
tables,
users,
search_term: searchTerm,
......@@ -391,471 +338,116 @@ describe('search ducks', () => {
});
});
it('should handle InlineSearch.SUCCESS', () => {
const { tables, users } = expectedInlineResults;
expect(reducer(testState, getInlineResultsSuccess(expectedInlineResults))).toEqual({
...testState,
inlineResults: {
tables,
users,
isLoading: false,
}
});
});
it('should handle InlineSearch.FAILURE', () => {
expect(reducer(testState, getInlineResultsFailure())).toEqual({
...testState,
inlineResults: initialInlineResultsState,
});
});
it('should handle InlineSearch.REQUEST', () => {
const term = 'testSearch';
expect(reducer(testState, getInlineResults(term))).toEqual({
...testState,
inlineResults: {
tables: initialInlineResultsState.tables,
users: initialInlineResultsState.users,
isLoading: true,
},
});
});
describe('handles cases that update the filter state', () => {
describe('cases that update the filter state only', () => {
it('UpdateSearchFilter.CLEAR_ALL', () => {
filterReducerSpy.mockClear();
const filterAction = filterReducer.clearAllFilters();
const result = reducer(testState, filterAction)
expect(filterReducerSpy).toHaveBeenCalledWith(testState.filters, filterAction, testState.selectedTab);
expect(result.filters).toBe(MOCK_FILTER_STATE);
})
});
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('InlineSearch', () => {
it('should handle InlineSearch.SUCCESS', () => {
const { tables, users } = expectedInlineResults;
expect(reducer(testState, getInlineResultsSuccess(expectedInlineResults))).toEqual({
...testState,
inlineResults: {
tables,
users,
isLoading: false,
}
});
});
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();
it('should handle InlineSearch.FAILURE', () => {
expect(reducer(testState, getInlineResultsFailure())).toEqual({
...testState,
inlineResults: initialInlineResultsState,
});
});
});
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('should handle InlineSearch.REQUEST', () => {
const term = 'testSearch';
expect(reducer(testState, getInlineResults(term))).toEqual({
...testState,
inlineResults: {
tables: initialInlineResultsState.tables,
users: initialInlineResultsState.users,
isLoading: true,
},
});
});
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();
it('should handle InlineSearch.REQUEST_DEBOUNCE', () => {
const term = 'testSearch';
expect(reducer(testState, getInlineResultsDebounce(term))).toEqual({
...testState,
inlineResults: {
tables: initialInlineResultsState.tables,
users: initialInlineResultsState.users,
isLoading: true,
},
});
});
});
describe('setPageIndexWatcher', () => {
it('takes every SetPageIndex.REQUEST with setPageIndexWorker', () => {
testSaga(Sagas.setPageIndexWatcher)
.next().takeEvery(SetPageIndex.REQUEST, Sagas.setPageIndexWorker)
.next().isDone();
describe('UpdateSearchState', () => {
it('UpdateSearchState.REQUEST returns existing filter state if not provided', () => {
result = reducer(testState, updateSearchState({ updateUrl: true }));
expect(result.filters).toBe(testState.filters);
});
});
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('UpdateSearchState.REQUEST returns existing resource state if not provided', () => {
result = reducer(testState, updateSearchState({ updateUrl: true }));
expect(result.resource).toBe(testState.resource);
});
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('UpdateSearchState.REQUEST updates filter state if provided', () => {
result = reducer(initialState, updateSearchState({ filters: MOCK_FILTER_STATE }));
expect(result.filters).toBe(MOCK_FILTER_STATE);
});
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('UpdateSearchState.REQUEST updates resource state if provided', () => {
const testResource = ResourceType.user;
result = reducer(initialState, updateSearchState({ resource: testResource }));
expect(result.resource).toEqual(testResource);
});
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('UpdateSearchState.RESET returns initialState', () => {
result = reducer(testState, resetSearchState());
expect(result).toBe(initialState);
});
/*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();
});
it('SubmitSearch.REQUEST updates given search term and enters isLoading state', () => {
const searchTerm = 'testTerm';
result = reducer(testState, submitSearch({ searchTerm, useFilters: true }));
expect(result).toEqual({
...testState,
isLoading: true,
search_term: searchTerm,
})
});
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('should handle SubmitSearchResource.REQUEST', () => {
let filterAction;
let paginationAction;
beforeAll(() => {
filterAction = submitSearchResource({
pageIndex: 0,
searchTerm: 'hello',
searchType: SearchType.FILTER,
resourceFilters: MOCK_TABLE_FILTER_STATE,
});
});
});
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();
});
});
});
describe('utils', () => {
describe('getSearchState', () => {
it('returns the search 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', () => {
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.selectedTab + '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);
});
paginationAction = submitSearchResource({ pageIndex: 1, searchType: SearchType.PAGINATION });
})
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('calls filter reducer with existing filters', () => {
filterReducerSpy.mockClear();
const result = reducer(initialState, filterAction);
expect(filterReducerSpy).toHaveBeenCalledWith(initialState.filters, filterAction);
});
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('updates search term if provided', () => {
result = reducer(testState, filterAction);
expect(result.search_term).toBe(filterAction.payload.searchTerm);
});
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);
it('sets search term with existing state if provided', () => {
result = reducer(testState, paginationAction);
expect(result.search_term).toBe(testState.search_term);
});
});
});
......
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 {
UserResource,
} from 'interfaces';
import {
FilterReducerState,
ResourceFilterReducerState,
} from 'ducks/search/filters/reducer';
export interface SearchResults<T extends Resource> {
page_index: number;
total_results: number;
......@@ -25,7 +30,7 @@ export interface SearchResponsePayload {
users?: UserSearchResults;
};
export interface SearchAllResponsePayload extends SearchResponsePayload {
selectedTab: ResourceType;
resource: ResourceType;
dashboards: DashboardSearchResults;
tables: TableSearchResults;
users: UserSearchResults;
......@@ -36,7 +41,7 @@ export interface InlineSearchResponsePayload {
};
export interface InlineSearchUpdatePayload {
searchTerm: string;
selectedTab: ResourceType;
resource: ResourceType;
tables: TableSearchResults;
users: UserSearchResults;
};
......@@ -46,7 +51,6 @@ export enum SearchAll {
REQUEST = 'amundsen/search/SEARCH_ALL_REQUEST',
SUCCESS = 'amundsen/search/SEARCH_ALL_SUCCESS',
FAILURE = 'amundsen/search/SEARCH_ALL_FAILURE',
RESET = 'amundsen/search/SEARCH_ALL_RESET',
};
export interface SearchAllRequest {
payload: {
......@@ -62,10 +66,6 @@ export interface SearchAllResponse {
type: SearchAll.SUCCESS | SearchAll.FAILURE;
payload?: SearchAllResponsePayload;
};
export interface SearchAllReset {
type: SearchAll.RESET;
};
export enum SearchResource {
REQUEST = 'amundsen/search/SEARCH_RESOURCE_REQUEST',
......@@ -124,42 +124,44 @@ export enum SubmitSearch {
export interface SubmitSearchRequest {
payload: {
searchTerm: string;
useFilters?: boolean;
useFilters: boolean;
};
type: SubmitSearch.REQUEST;
};
export enum ClearSearch {
REQUEST = 'amundsen/search/CLEAR_SEARCH_REQUEST',
export enum SubmitSearchResource {
REQUEST = 'amundsen/search/SUBMIT_SEARCH_RESOURCE_REQUEST',
};
export interface ClearSearchRequest {
type: ClearSearch.REQUEST;
export type SubmitSearchResourcePayload = {
pageIndex: number;
searchType: SearchType;
updateUrl?: boolean;
resourceFilters?: ResourceFilterReducerState;
searchTerm?: string;
resource?: ResourceType;
}
export interface SubmitSearchResourceRequest {
payload: SubmitSearchResourcePayload;
type: SubmitSearchResource.REQUEST;
};
export enum SetResource {
REQUEST = 'amundsen/search/SET_RESOURCE_REQUEST',
};
export interface SetResourceRequest {
payload: {
resource: ResourceType;
updateUrl: boolean;
};
type: SetResource.REQUEST;
export enum UpdateSearchState {
REQUEST = 'amundsen/search/UPDATE_SEARCH_STATE',
RESET = 'amundsen/search/RESET_SEARCH_STATE',
};
export enum SetPageIndex {
REQUEST = 'amundsen/search/SET_PAGE_INDEX_REQUEST',
export type UpdateSearchStatePayload = {
filters?: FilterReducerState;
resource?: ResourceType;
updateUrl?: boolean;
}
export interface UpdateSearchStateRequest {
payload?: UpdateSearchStatePayload;
type: UpdateSearchState.REQUEST;
};
export interface SetPageIndexRequest {
payload: {
pageIndex: number;
updateUrl: boolean;
};
type: SetPageIndex.REQUEST;
export interface UpdateSearchStateReset {
type: UpdateSearchState.RESET;
};
export enum LoadPreviousSearch {
REQUEST = 'amundsen/search/LOAD_PREVIOUS_SEARCH_REQUEST',
};
......@@ -167,7 +169,6 @@ export interface LoadPreviousSearchRequest {
type: LoadPreviousSearch.REQUEST;
};
export enum UrlDidUpdate {
REQUEST = 'amundsen/search/URL_DID_UPDATE_REQUEST',
};
......
......@@ -13,7 +13,7 @@ can be the combination of multiple responses.
*/
export const getPageIndex = (state: Partial<SearchReducerState>, resource?: ResourceType) => {
resource = resource || state.selectedTab;
resource = resource || state.resource;
switch(resource) {
case ResourceType.table:
return state.tables.page_index;
......
......@@ -66,7 +66,7 @@ const globalState: GlobalState = {
],
search: {
search_term: 'testName',
selectedTab: ResourceType.table,
resource: ResourceType.table,
isLoading: false,
dashboards: {
page_index: 0,
......
......@@ -31,4 +31,5 @@ We use [Jest](https://jestjs.io/) as our test framework. We leverage utility met
#### 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.
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