Unverified Commit 5972e74e authored by Daniel's avatar Daniel Committed by GitHub

Use Redux-Saga for Search Actions (#265)

* Added redux actions and sagas instead for each search action: `submitSearch`, `setResource`, `setPageIndex`, `loadPreviousSearch`, and `UrlDidUpdate`. This greatly simplifies the `SearchPage` logic in preparation for adding filters.
* Added `navigation-utils`.
parent 7d845856
import * as React from 'react';
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router';
// TODO: Use css-modules instead of 'import'
import './styles.scss';
......@@ -14,17 +14,23 @@ import {
SYNTAX_ERROR_SPACING_SUFFIX,
} from './constants';
import { GlobalState } from 'ducks/rootReducer';
import { submitSearch } from 'ducks/search/reducer';
import { SubmitSearchRequest } from 'ducks/search/types';
export interface StateFromProps {
searchTerm: string;
}
export interface DispatchFromProps {
submitSearch: (searchTerm: string) => SubmitSearchRequest;
}
export interface OwnProps {
placeholder?: string;
subText?: string;
}
export type SearchBarProps = StateFromProps & OwnProps & RouteComponentProps<any>;
export type SearchBarProps = StateFromProps & DispatchFromProps & OwnProps;
interface SearchBarState {
subTextClassName: string;
......@@ -61,15 +67,15 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
const searchTerm = this.state.searchTerm.trim();
event.preventDefault();
if (this.isFormValid(searchTerm)) {
let pathName = '/';
if (searchTerm !== '') {
pathName = `/search?searchTerm=${searchTerm}`;
}
this.props.history.push(pathName);
this.props.submitSearch(searchTerm);
}
};
isFormValid = (searchTerm: string) : boolean => {
if (searchTerm.length === 0) {
return false;
}
const hasAtMostOneCategory = searchTerm.split(':').length <= 2;
if (!hasAtMostOneCategory) {
this.setState({
......@@ -126,4 +132,8 @@ export const mapStateToProps = (state: GlobalState) => {
};
};
export default connect<StateFromProps>(mapStateToProps, null)(withRouter(SearchBar));
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ submitSearch }, dispatch);
};
export default connect<StateFromProps>(mapStateToProps, mapDispatchToProps)(SearchBar);
......@@ -2,7 +2,7 @@ import * as React from 'react';
import { shallow } from 'enzyme';
import { mapStateToProps, SearchBar, SearchBarProps } from '../';
import { mapStateToProps, mapDispatchToProps, SearchBar, SearchBarProps } from '../';
import {
ERROR_CLASSNAME,
SUBTEXT_DEFAULT,
......@@ -11,18 +11,15 @@ import {
SYNTAX_ERROR_SPACING_SUFFIX,
} from '../constants';
import globalState from 'fixtures/globalState';
import { getMockRouterProps } from 'fixtures/mockRouter';
describe('SearchBar', () => {
const valueChangeMockEvent = { target: { value: 'Data Resources' } };
const submitMockEvent = { preventDefault: jest.fn() };
const setStateSpy = jest.spyOn(SearchBar.prototype, 'setState');
const routerProps = getMockRouterProps<any>(null, null);
const historyPushSpy = jest.spyOn(routerProps.history, 'push');
const setup = (propOverrides?: Partial<SearchBarProps>) => {
const props: SearchBarProps = {
searchTerm: '',
...routerProps,
submitSearch: jest.fn(),
...propOverrides
};
const wrapper = shallow<SearchBar>(<SearchBar {...props} />)
......@@ -82,27 +79,18 @@ describe('SearchBar', () => {
expect(submitMockEvent.preventDefault).toHaveBeenCalled();
});
it('redirects back to home if searchTerm is empty', () => {
historyPushSpy.mockClear();
// @ts-ignore: mocked events throw type errors
wrapper.instance().handleValueSubmit(submitMockEvent);
expect(historyPushSpy).toHaveBeenCalledWith('/');
});
it('submits with correct props if isFormValid()', () => {
historyPushSpy.mockClear();
const { props, wrapper } = setup({ searchTerm: 'testTerm' });
// @ts-ignore: mocked events throw type errors
wrapper.instance().handleValueSubmit(submitMockEvent);
expect(historyPushSpy).toHaveBeenCalledWith(`/search?searchTerm=${wrapper.state().searchTerm}`);
expect(props.submitSearch).toHaveBeenCalledWith(props.searchTerm);
});
it('does not submit if !isFormValid()', () => {
historyPushSpy.mockClear();
const { props, wrapper } = setup({ searchTerm: 'tag:tag1 tag:tag2' });
// @ts-ignore: mocked events throw type errors
wrapper.instance().handleValueSubmit(submitMockEvent);
expect(historyPushSpy).not.toHaveBeenCalled();
expect(props.submitSearch).not.toHaveBeenCalled();
});
});
......@@ -111,12 +99,16 @@ describe('SearchBar', () => {
let wrapper;
beforeAll(() => {
wrapper = setup().wrapper;
})
});
it('returns false', () => {
it('does not accept multiple search categories', () => {
expect(wrapper.instance().isFormValid('tag:tag1 tag:tag2')).toEqual(false);
});
it('does not accept empty search term', () => {
expect(wrapper.instance().isFormValid('')).toEqual(false);
});
it('sets state.subText correctly', () => {
expect(wrapper.state().subText).toEqual(SYNTAX_ERROR_CATEGORY);
});
......@@ -130,7 +122,7 @@ describe('SearchBar', () => {
let wrapper;
beforeAll(() => {
wrapper = setup({ searchTerm: 'tag : tag1' }).wrapper;
})
});
it('returns false', () => {
expect(wrapper.instance().isFormValid('tag : tag1')).toEqual(false);
......@@ -149,7 +141,7 @@ describe('SearchBar', () => {
let wrapper;
beforeAll(() => {
wrapper = setup().wrapper;
})
});
it('returns true', () => {
expect(wrapper.instance().isFormValid('tag:tag1')).toEqual(true);
......@@ -237,6 +229,19 @@ describe('SearchBar', () => {
});
});
describe('mapDispatchToProps', () => {
let dispatch;
let result;
beforeAll(() => {
dispatch = jest.fn(() => Promise.resolve());
result = mapDispatchToProps(dispatch);
});
it('sets searchAll on the props', () => {
expect(result.submitSearch).toBeInstanceOf(Function);
});
});
describe('mapStateToProps', () => {
let result;
beforeAll(() => {
......
......@@ -2,9 +2,8 @@ import * as React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as DocumentTitle from 'react-document-title';
import * as qs from 'simple-query-string';
import { RouteComponentProps } from 'react-router';
import { Search } from 'history';
import { Search as UrlSearch } from 'history';
import AppConfig from 'config/config';
import LoadingSpinner from 'components/common/LoadingSpinner';
......@@ -14,14 +13,14 @@ import TabsComponent from 'components/common/Tabs';
import SearchBar from './SearchBar';
import { GlobalState } from 'ducks/rootReducer';
import { searchAll, searchResource, updateSearchTab } from 'ducks/search/reducer';
import { setPageIndex, setResource, urlDidUpdate } from 'ducks/search/reducer';
import {
DashboardSearchResults,
SearchAllRequest,
SearchResourceRequest,
SearchResults,
SetPageIndexRequest,
SetResourceRequest,
TableSearchResults,
UpdateSearchTabRequest,
UrlDidUpdateRequest,
UserSearchResults,
} from 'ducks/search/types';
......@@ -54,9 +53,9 @@ export interface StateFromProps {
}
export interface DispatchFromProps {
searchAll: (term: string, selectedTab: ResourceType, pageIndex: number) => SearchAllRequest;
searchResource: (term: string, resource: ResourceType, pageIndex: number) => SearchResourceRequest;
updateSearchTab: (selectedTab: ResourceType) => UpdateSearchTabRequest;
setResource: (resource: ResourceType) => SetResourceRequest;
setPageIndex: (pageIndex: number) => SetPageIndexRequest;
urlDidUpdate: (urlSearch: UrlSearch) => UrlDidUpdateRequest;
}
export type SearchPageProps = StateFromProps & DispatchFromProps & RouteComponentProps<any>;
......@@ -69,126 +68,14 @@ export class SearchPage extends React.Component<SearchPageProps> {
}
componentDidMount() {
const urlSearchParams = this.getUrlParams(this.props.location.search);
const globalStateParams = this.getGlobalStateParams();
if (this.shouldUpdateFromGlobalState(urlSearchParams, globalStateParams)) {
this.updatePageUrl(globalStateParams.term, globalStateParams.tab, globalStateParams.index, true);
} else if (this.shouldUpdateFromUrlParams(urlSearchParams, globalStateParams)) {
this.props.searchAll(urlSearchParams.term, urlSearchParams.tab, urlSearchParams.index);
this.updatePageUrl(urlSearchParams.term, urlSearchParams.tab, urlSearchParams.index, true);
}
}
shouldUpdateFromGlobalState(urlParams, globalStateParams): boolean {
return urlParams.term === '' && globalStateParams.term !== '';
}
shouldUpdateFromUrlParams(urlParams, globalStateParams): boolean {
return urlParams.term !== '' && urlParams.term !== globalStateParams.term;
this.props.urlDidUpdate(this.props.location.search);
}
componentDidUpdate(prevProps: SearchPageProps) {
const nextUrlParams = this.getUrlParams(this.props.location.search);
const prevUrlParams = this.getUrlParams(prevProps.location.search);
// If urlParams and globalState are synced, no need to update
if (this.isUrlStateSynced(nextUrlParams)) return;
// Capture any updates in URL
if (this.shouldUpdateSearchTerm(nextUrlParams, prevUrlParams)) {
this.props.searchAll(nextUrlParams.term, nextUrlParams.tab, nextUrlParams.index);
} else if (this.shouldUpdateTab(nextUrlParams, prevUrlParams)) {
this.props.updateSearchTab(nextUrlParams.tab)
} else if (this.shouldUpdatePageIndex(nextUrlParams, prevUrlParams)) {
this.props.searchResource(nextUrlParams.term, nextUrlParams.tab, nextUrlParams.index);
}
}
isUrlStateSynced(urlParams): boolean {
const globalStateParams = this.getGlobalStateParams();
return urlParams.term === globalStateParams.term &&
urlParams.tab === globalStateParams.tab &&
urlParams.index === globalStateParams.index;
}
shouldUpdateSearchTerm(nextUrlParams, prevUrlParams): boolean {
return nextUrlParams.term !== prevUrlParams.term;
}
shouldUpdateTab(nextUrlParams, prevUrlParams): boolean {
return nextUrlParams.tab !== prevUrlParams.tab;
}
shouldUpdatePageIndex(nextUrlParams, prevUrlParams): boolean {
return nextUrlParams.index !== prevUrlParams.index;
}
getSelectedTabByResourceType = (newTab: ResourceType): ResourceType => {
switch(newTab) {
case ResourceType.table:
case ResourceType.user:
return newTab;
case ResourceType.dashboard:
default:
return this.props.selectedTab;
}
};
getUrlParams(search: Search) {
const urlParams = qs.parse(search);
const { searchTerm, pageIndex, selectedTab } = urlParams;
const index = parseInt(pageIndex, 10);
return {
term: (searchTerm || '').trim(),
tab: this.getSelectedTabByResourceType(selectedTab),
index: isNaN(index) ? 0 : index,
};
};
getGlobalStateParams() {
return {
term: this.props.searchTerm,
tab: this.props.selectedTab,
index: this.getPageIndexByResourceType(this.props.selectedTab),
};
}
getPageIndexByResourceType = (tab: ResourceType): number => {
switch(tab) {
case ResourceType.table:
return this.props.tables.page_index;
case ResourceType.user:
return this.props.users.page_index;
case ResourceType.dashboard:
return this.props.dashboards.page_index;
if (this.props.location.search !== prevProps.location.search) {
this.props.urlDidUpdate(this.props.location.search);
}
return 0;
};
onPaginationChange = (index: number): void => {
this.updatePageUrl(this.props.searchTerm, this.props.selectedTab, index);
};
onTabChange = (tab: ResourceType): void => {
const newTab = this.getSelectedTabByResourceType(tab);
this.updatePageUrl(this.props.searchTerm, newTab, this.getPageIndexByResourceType(newTab));
};
updatePageUrl = (searchTerm: string, tab: ResourceType, pageIndex: number, replace: boolean = false): void => {
const pathName = `/search?searchTerm=${searchTerm}&selectedTab=${tab}&pageIndex=${pageIndex}`;
if (replace) {
this.props.history.replace(pathName);
} else {
this.props.history.push(pathName);
}
};
renderSearchResults = () => {
const tabConfig = [
......@@ -212,7 +99,7 @@ export class SearchPage extends React.Component<SearchPageProps> {
tabs={ tabConfig }
defaultTab={ ResourceType.table }
activeKey={ this.props.selectedTab }
onSelect={ this.onTabChange }
onSelect={ this.props.setResource }
/>
</div>
);
......@@ -282,7 +169,7 @@ export class SearchPage extends React.Component<SearchPageProps> {
source={ SEARCH_SOURCE_NAME }
itemsPerPage={ RESULTS_PER_PAGE }
activePage={ page_index }
onPagination={ this.onPaginationChange }
onPagination={ this.props.setPageIndex }
/>
</div>
);
......@@ -330,7 +217,7 @@ export const mapStateToProps = (state: GlobalState) => {
};
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ searchAll, searchResource, updateSearchTab }, dispatch);
return bindActionCreators({ setResource, setPageIndex, urlDidUpdate }, dispatch);
};
export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(SearchPage);
......@@ -30,7 +30,6 @@ import LoadingSpinner from 'components/common/LoadingSpinner';
import ResourceList from 'components/common/ResourceList';
import globalState from 'fixtures/globalState';
import { searchAll, updateSearchTab } from 'ducks/search/reducer';
import { getMockRouterProps } from 'fixtures/mockRouter';
describe('SearchPage', () => {
......@@ -44,9 +43,9 @@ describe('SearchPage', () => {
dashboards: globalState.search.dashboards,
tables: globalState.search.tables,
users: globalState.search.users,
searchAll: jest.fn(),
searchResource: jest.fn(),
updateSearchTab: jest.fn(),
setResource: jest.fn(),
setPageIndex: jest.fn(),
urlDidUpdate: jest.fn(),
...routerProps,
...propOverrides,
};
......@@ -57,147 +56,17 @@ describe('SearchPage', () => {
describe('componentDidMount', () => {
let props;
let wrapper;
let getGlobalStateParamsSpy;
let shouldUpdateFromGlobalStateSpy;
let shouldUpdateFromUrlParamsSpy;
let getUrlParamsSpy;
let updatePageUrlSpy;
let searchAllSpy;
let mockUrlParams;
let mockGlobalStateParams;
beforeAll(() => {
const setupResult = setup(null, {
search: '/search?searchTerm=testName&selectedTab=table&pageIndex=1',
});
props = setupResult.props;
wrapper = setupResult.wrapper;
getUrlParamsSpy = jest.spyOn(wrapper.instance(), 'getUrlParams');
getGlobalStateParamsSpy = jest.spyOn(wrapper.instance(), 'getGlobalStateParams');
shouldUpdateFromUrlParamsSpy = jest.spyOn(wrapper.instance(), 'shouldUpdateFromUrlParams');
shouldUpdateFromGlobalStateSpy = jest.spyOn(wrapper.instance(), 'shouldUpdateFromGlobalState');
updatePageUrlSpy = jest.spyOn(wrapper.instance(), 'updatePageUrl');
searchAllSpy = jest.spyOn(props, 'searchAll');
});
it('calls getUrlParams and getGlobalStateParams', () => {
wrapper.instance().componentDidMount();
expect(getUrlParamsSpy).toHaveBeenCalledWith(props.location.search);
expect(getGlobalStateParamsSpy).toHaveBeenCalled();
});
describe('when rendering from GlobalState', () => {
beforeAll(() => {
mockUrlParams = { term: '', tab: ResourceType.table, index: 0 };
mockGlobalStateParams = { term: 'test', tab: ResourceType.table, index: 2 };
getUrlParamsSpy.mockReset().mockImplementationOnce(() => mockUrlParams);
getGlobalStateParamsSpy.mockReset().mockImplementationOnce(() => mockGlobalStateParams);
shouldUpdateFromGlobalStateSpy.mockReset().mockImplementationOnce(() => true);
shouldUpdateFromUrlParamsSpy.mockReset().mockImplementationOnce(() => false);
wrapper.instance().componentDidMount();
});
it('calls updatePageUrl with correct parameters', () => {
expect(updatePageUrlSpy).toHaveBeenCalledWith(mockGlobalStateParams.term, mockGlobalStateParams.tab, mockGlobalStateParams.index, true)
});
it('does not call shouldUpdateFromUrlParams', () => {
expect(shouldUpdateFromUrlParamsSpy).not.toHaveBeenCalled()
});
});
describe('when rendering from URL state', () => {
beforeAll(() => {
mockUrlParams = { term: '', tab: ResourceType.table, index: 0 };
mockGlobalStateParams = { term: 'test', tab: ResourceType.table, index: 2 };
getUrlParamsSpy.mockReset().mockImplementationOnce(() => mockUrlParams);
getGlobalStateParamsSpy.mockReset().mockImplementationOnce(() => mockGlobalStateParams);
searchAllSpy.mockClear();
updatePageUrlSpy.mockClear();
shouldUpdateFromGlobalStateSpy.mockReset().mockImplementationOnce(() => false);
shouldUpdateFromUrlParamsSpy.mockReset().mockImplementationOnce(() => true);
wrapper.instance().componentDidMount();
});
it('calls shouldUpdateFromGlobalState with correct params', () => {
expect(searchAllSpy).toHaveBeenCalledWith(mockUrlParams.term, mockUrlParams.tab, mockUrlParams.index);
});
it('calls updatePageUrl with correct params', () => {
expect(updatePageUrlSpy).toHaveBeenCalledWith(mockUrlParams.term, mockUrlParams.tab, mockUrlParams.index, true);
});
});
});
describe('shouldUpdateFromGlobalState', () => {
let wrapper;
beforeAll(() => {
const setupResult = setup();
wrapper = setupResult.wrapper;
});
describe('when `urlParams.term` is empty and `globalState.term` is initialized', () => {
it('returns a value of true', () => {
const mockUrlParams = { term: '', tab: ResourceType.table, index: 0 };
const mockGlobalStateParams = { term: 'test', tab: ResourceType.table, index: 2 };
expect(wrapper.instance().shouldUpdateFromGlobalState(mockUrlParams, mockGlobalStateParams)).toBe(true);
});
});
describe('when `urlParams.term` is initialized', () => {
it('returns a value of false', () => {
const mockUrlParams = { term: 'testTerm', tab: ResourceType.table, index: 0 };
const mockGlobalStateParams = { term: '', tab: ResourceType.table, index: 2 };
expect(wrapper.instance().shouldUpdateFromGlobalState(mockUrlParams, mockGlobalStateParams)).toBe(false);
});
});
describe('when `globalState.term` is empty', () => {
it('returns a value of false', () => {
const mockUrlParams = { term: '', tab: ResourceType.table, index: 0 };
const mockGlobalStateParams = { term: '', tab: ResourceType.table, index: 0 };
expect(wrapper.instance().shouldUpdateFromGlobalState(mockUrlParams, mockGlobalStateParams)).toBe(false);
});
});
});
describe('shouldUpdateFromUrlParams', () => {
let wrapper;
beforeAll(() => {
const setupResult = setup();
wrapper = setupResult.wrapper;
});
describe('when urlParams.term is empty', () => {
it('returns a value of false', () => {
const mockUrlParams = { term: '', tab: ResourceType.table, index: 0 };
const mockGlobalStateParams = { term: '', tab: ResourceType.table, index: 0 };
expect(wrapper.instance().shouldUpdateFromUrlParams(mockUrlParams, mockGlobalStateParams)).toBe(false);
});
});
describe('when urlParams.term is initialized and equals globalState.term', () => {
it('returns a value of false', () => {
const mockUrlParams = { term: 'test', tab: ResourceType.table, index: 0 };
const mockGlobalStateParams = { term: 'test', tab: ResourceType.table, index: 0 };
expect(wrapper.instance().shouldUpdateFromUrlParams(mockUrlParams, mockGlobalStateParams)).toBe(false);
});
});
describe('when urlParams are initialized and not equal to global state', () => {
it('returns a value of true', () => {
const mockUrlParams = { term: 'test', tab: ResourceType.table, index: 0 };
const mockGlobalStateParams = { term: '', tab: ResourceType.table, index: 0 };
expect(wrapper.instance().shouldUpdateFromUrlParams(mockUrlParams, mockGlobalStateParams)).toBe(true);
});
expect(props.urlDidUpdate).toHaveBeenCalledWith(props.location.search);
});
});
......@@ -205,19 +74,7 @@ describe('SearchPage', () => {
let props;
let wrapper;
let mockNextUrlParams;
let mockPrevUrlParams;
let mockPrevProps;
let getUrlParamsSpy;
let isUrlStateSyncedSpy;
let shouldUpdateSearchTermSpy;
let shouldUpdateTabSpy;
let shouldUpdatePageIndexSpy;
let searchAllSpy;
let updateSearchTabSpy;
let searchResourceSpy;
beforeAll(() => {
const setupResult = setup(null, {
search: '/search?searchTerm=testName&selectedTab=table&pageIndex=1',
......@@ -234,383 +91,18 @@ describe('SearchPage', () => {
hash: 'mockstr',
}
};
getUrlParamsSpy = jest.spyOn(wrapper.instance(), 'getUrlParams');
isUrlStateSyncedSpy = jest.spyOn(wrapper.instance(), 'isUrlStateSynced');
shouldUpdateSearchTermSpy = jest.spyOn(wrapper.instance(), 'shouldUpdateSearchTerm');
shouldUpdateTabSpy = jest.spyOn(wrapper.instance(), 'shouldUpdateTab');
shouldUpdatePageIndexSpy = jest.spyOn(wrapper.instance(), 'shouldUpdatePageIndex');
searchAllSpy = jest.spyOn(props, 'searchAll');
updateSearchTabSpy = jest.spyOn(props, 'updateSearchTab');
searchResourceSpy = jest.spyOn(props, 'searchResource');
});
it('calls getUrlParams for both current and prev props', () => {
wrapper.instance().componentDidUpdate(mockPrevProps);
expect(getUrlParamsSpy).toHaveBeenCalledTimes(2);
expect(getUrlParamsSpy).toHaveBeenCalledWith(mockPrevProps.location.search);
expect(getUrlParamsSpy).toHaveBeenCalledWith(props.location.search);
});
it('exits the function when isUrlStateSynced returns true', () => {
isUrlStateSyncedSpy.mockImplementationOnce(() => true);
shouldUpdateSearchTermSpy.mockClear();
shouldUpdateTabSpy.mockClear();
wrapper.instance().componentDidUpdate(mockPrevProps);
expect(shouldUpdateSearchTermSpy).not.toHaveBeenCalled();
expect(shouldUpdateTabSpy).not.toHaveBeenCalled();
});
describe('when the search term has changed', () => {
beforeAll(() => {
mockNextUrlParams = { term: 'new term', tab: ResourceType.table, index: 0 };
mockPrevUrlParams = { term: 'old term', tab: ResourceType.table, index: 0 };
getUrlParamsSpy.mockReset()
.mockImplementationOnce(() => mockNextUrlParams)
.mockImplementationOnce(() => mockPrevUrlParams);
isUrlStateSyncedSpy.mockClear().mockImplementationOnce(() => false);
shouldUpdateSearchTermSpy.mockReset().mockImplementationOnce(() => true);
shouldUpdateTabSpy.mockReset().mockImplementationOnce(() => false);
shouldUpdatePageIndexSpy.mockReset().mockImplementationOnce(() => false);
searchAllSpy.mockClear();
updateSearchTabSpy.mockClear();
searchResourceSpy.mockClear();
wrapper.instance().componentDidUpdate(mockPrevProps);
});
it('calls searchAll', () => {
expect(searchAllSpy).toHaveBeenCalledWith(mockNextUrlParams.term, mockNextUrlParams.tab, mockNextUrlParams.index);
});
it('does not call updateSearchTab nor searchResource', () => {
expect(updateSearchTabSpy).not.toHaveBeenCalled();
expect(searchResourceSpy).not.toHaveBeenCalled();
});
});
describe('when the search tab has changed', () => {
beforeAll(() => {
mockNextUrlParams = { term: 'old term', tab: ResourceType.user, index: 0 };
mockPrevUrlParams = { term: 'old term', tab: ResourceType.table, index: 0 };
getUrlParamsSpy.mockReset()
.mockImplementationOnce(() => mockNextUrlParams)
.mockImplementationOnce(() => mockPrevUrlParams);
isUrlStateSyncedSpy.mockClear().mockImplementationOnce(() => false);
shouldUpdateSearchTermSpy.mockReset().mockImplementationOnce(() => false);
shouldUpdateTabSpy.mockReset().mockImplementationOnce(() => true);
shouldUpdatePageIndexSpy.mockReset().mockImplementationOnce(() => false);
searchAllSpy.mockClear();
updateSearchTabSpy.mockClear();
searchResourceSpy.mockClear();
wrapper.instance().componentDidUpdate(mockPrevProps);
});
it('calls updateSearchTab', () => {
expect(updateSearchTabSpy).toHaveBeenCalledWith(mockNextUrlParams.tab)
});
it('does not call searchAll nor searchResource', () => {
expect(searchAllSpy).not.toHaveBeenCalled();
expect(searchResourceSpy).not.toHaveBeenCalled();
});
});
describe('when the page index has changed', () => {
beforeAll(() => {
mockNextUrlParams = { term: 'old term', tab: ResourceType.table, index: 1 };
mockPrevUrlParams = { term: 'old term', tab: ResourceType.table, index: 0 };
getUrlParamsSpy.mockReset()
.mockImplementationOnce(() => mockNextUrlParams)
.mockImplementationOnce(() => mockPrevUrlParams);
isUrlStateSyncedSpy.mockClear().mockImplementationOnce(() => false);
shouldUpdateSearchTermSpy.mockReset().mockImplementationOnce(() => false);
shouldUpdateTabSpy.mockReset().mockImplementationOnce(() => false);
shouldUpdatePageIndexSpy.mockReset().mockImplementationOnce(() => true);
searchAllSpy.mockClear();
updateSearchTabSpy.mockClear();
searchResourceSpy.mockClear();
it('calls urlDidUpdate when location.search changes', () => {
props.urlDidUpdate.mockClear();
wrapper.instance().componentDidUpdate(mockPrevProps);
expect(props.urlDidUpdate).toHaveBeenCalledWith(props.location.search);
});
it('calls searchResource', () => {
expect(searchResourceSpy).toHaveBeenCalledWith(mockNextUrlParams.term, mockNextUrlParams.tab, mockNextUrlParams.index)
});
it('does not call searchAll nor updateSearchTab', () => {
expect(searchAllSpy).not.toHaveBeenCalled();
expect(updateSearchTabSpy).not.toHaveBeenCalled();
});
});
});
describe('shouldUpdateSearchTerm', () => {
const wrapper = setup().wrapper;
it('returns true when the search term is different', () => {
const nextUrlParams = { term: 'new term', tab: ResourceType.table, index: 0 };
const prevUrlParams = { term: 'old term', tab: ResourceType.table, index: 0 };
expect(wrapper.instance().shouldUpdateSearchTerm(nextUrlParams, prevUrlParams)).toBe(true)
});
it('returns false when the search term is the same', () => {
const nextUrlParams = { term: 'old term', tab: ResourceType.table, index: 0 };
const prevUrlParams = { term: 'old term', tab: ResourceType.table, index: 0 };
expect(wrapper.instance().shouldUpdateSearchTerm(nextUrlParams, prevUrlParams)).toBe(false)
});
});
describe('shouldUpdateTab', () => {
const wrapper = setup().wrapper;
it('returns true when the tab is different', () => {
const nextUrlParams = { term: 'old term', tab: ResourceType.user, index: 0 };
const prevUrlParams = { term: 'old term', tab: ResourceType.table, index: 0 };
expect(wrapper.instance().shouldUpdateTab(nextUrlParams, prevUrlParams)).toBe(true)
});
it('returns false when the tab is the same', () => {
const nextUrlParams = { term: 'old term', tab: ResourceType.table, index: 0 };
const prevUrlParams = { term: 'old term', tab: ResourceType.table, index: 0 };
expect(wrapper.instance().shouldUpdateTab(nextUrlParams, prevUrlParams)).toBe(false)
});
});
describe('shouldUpdatePageIndex', () => {
const wrapper = setup().wrapper;
it('returns true when the pageIndex is different', () => {
const nextUrlParams = { term: 'old term', tab: ResourceType.table, index: 1 };
const prevUrlParams = { term: 'old term', tab: ResourceType.table, index: 0 };
expect(wrapper.instance().shouldUpdatePageIndex(nextUrlParams, prevUrlParams)).toBe(true)
});
it('returns false when the pageIndex is the same', () => {
const nextUrlParams = { term: 'old term', tab: ResourceType.table, index: 0 };
const prevUrlParams = { term: 'old term', tab: ResourceType.table, index: 0 };
expect(wrapper.instance().shouldUpdatePageIndex(nextUrlParams, prevUrlParams)).toBe(false)
});
});
describe('getUrlParams', () => {
let props;
let wrapper;
let urlString;
let urlParams;
let getSelectedTabByResourceTypeSpy;
beforeAll(() => {
const setupResult = setup(null, {
search: '/search?searchTerm=current&selectedTab=table&pageIndex=0',
});
props = setupResult.props;
wrapper = setupResult.wrapper;
getSelectedTabByResourceTypeSpy = jest.spyOn(wrapper.instance(), 'getSelectedTabByResourceType')
.mockImplementation((selectedTab) => selectedTab);
});
it('parses url params correctly', () => {
urlString = '/search?searchTerm=tableSearch&selectedTab=table&pageIndex=3';
urlParams = wrapper.instance().getUrlParams(urlString);
expect(getSelectedTabByResourceTypeSpy).toHaveBeenLastCalledWith('table');
expect(urlParams.term).toEqual('tableSearch');
expect(urlParams.tab).toEqual('table');
expect(urlParams.index).toEqual(3);
});
it('trims empty spaces from searchTerm', () => {
urlString = '/search?searchTerm= term%20&selectedTab=user&pageIndex=0';
urlParams = wrapper.instance().getUrlParams(urlString);
expect(urlParams.term).toEqual('term');
});
it('defaults NaN pageIndex as 0', () => {
urlString = '/search?searchTerm=current&selectedTab=table&pageIndex=NotANumber';
urlParams = wrapper.instance().getUrlParams(urlString);
expect(urlParams.index).toEqual(0);
});
it('defaults invalid tabs to the current tab', () => {
getSelectedTabByResourceTypeSpy.mockRestore();
getSelectedTabByResourceTypeSpy = jest.spyOn(wrapper.instance(), 'getSelectedTabByResourceType');
urlString = '/search?searchTerm=current&selectedTab=invalidTabType&pageIndex=0';
urlParams = wrapper.instance().getUrlParams(urlString);
expect(getSelectedTabByResourceTypeSpy).toHaveBeenLastCalledWith('invalidTabType');
expect(urlParams.tab).toEqual(props.selectedTab);
});
});
describe('getSelectedTabByResourceType', () => {
let wrapper;
beforeAll(() => {
const setupResult = setup();
wrapper = setupResult.wrapper;
});
it('returns given tab if equal to ResourceType.table', () => {
expect(wrapper.instance().getSelectedTabByResourceType(ResourceType.table)).toEqual(ResourceType.table);
});
it('returns given tab if equal to ResourceType.user', () => {
expect(wrapper.instance().getSelectedTabByResourceType(ResourceType.user)).toEqual(ResourceType.user);
});
it('returns state.selectedTab in default case', () => {
wrapper.setState({ selectedTab: 'table' })
// @ts-ignore: cover default case
expect(wrapper.instance().getSelectedTabByResourceType('not valid')).toEqual('table');
});
});
describe('getPageIndexByResourceType', () => {
let props;
let wrapper;
beforeAll(() => {
const setupResult = setup({
dashboards: { ...globalState.search.dashboards, page_index: 1 },
tables: { ...globalState.search.tables, page_index: 2 },
users: { ...globalState.search.users, page_index: 3 },
});
props = setupResult.props;
wrapper = setupResult.wrapper;
});
it('given ResourceType.dashboard, returns page_index from props for dashboards', () => {
expect(wrapper.instance().getPageIndexByResourceType(ResourceType.dashboard)).toEqual(props.dashboards.page_index);
});
it('given ResourceType.table, returns page_index from props for tables', () => {
expect(wrapper.instance().getPageIndexByResourceType(ResourceType.table)).toEqual(props.tables.page_index);
});
it('given ResourceType.user, returns page_index from props for users', () => {
expect(wrapper.instance().getPageIndexByResourceType(ResourceType.user)).toEqual(props.users.page_index);
});
it('returns 0 if not given a supported ResourceType', () => {
// @ts-ignore: cover default case
expect(wrapper.instance().getPageIndexByResourceType('not valid')).toEqual(0);
});
});
describe('onPaginationChange', () => {
const testIndex = 10;
let props;
let wrapper;
let searchResourceSpy;
let updatePageUrlSpy;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
searchResourceSpy = jest.spyOn(props, 'searchResource');
updatePageUrlSpy = jest.spyOn(wrapper.instance(), 'updatePageUrl');
wrapper.instance().onPaginationChange(testIndex);
});
it('calls updatePageUrl with correct parameters', () => {
expect(updatePageUrlSpy).toHaveBeenCalledWith(props.searchTerm, props.selectedTab, testIndex);
});
});
describe('onTabChange', () => {
const givenTab = ResourceType.user;
const mockPageIndex = 2;
let props;
let wrapper;
let getPageIndexByResourceTypeSpy;
let getSelectedTabByResourceTypeSpy;
let updatePageUrlSpy;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
getSelectedTabByResourceTypeSpy = jest.spyOn(wrapper.instance(), 'getSelectedTabByResourceType').mockImplementation(() => {
return givenTab;
});
getPageIndexByResourceTypeSpy = jest.spyOn(wrapper.instance(), 'getPageIndexByResourceType').mockImplementation(() => {
return mockPageIndex;
});
updatePageUrlSpy = jest.spyOn(wrapper.instance(), 'updatePageUrl');
setStateSpy.mockClear();
wrapper.instance().onTabChange(givenTab);
});
it('calls getSelectedTabByResourceType with correct parameters', () => {
expect(getSelectedTabByResourceTypeSpy).toHaveBeenCalledWith(givenTab);
});
it('calls updatePageUrl with correct parameters', () => {
expect(updatePageUrlSpy).toHaveBeenCalledWith(props.searchTerm, givenTab, mockPageIndex);
});
afterAll(() => {
getSelectedTabByResourceTypeSpy.mockRestore();
getPageIndexByResourceTypeSpy.mockRestore();
});
});
describe('updatePageUrl', () => {
let props;
let wrapper;
let historyPushSpy;
let historyReplaceSpy;
const pageIndex = 2;
const searchTerm = 'testing';
const tab = ResourceType.user;
const expectedPath = `/search?searchTerm=${searchTerm}&selectedTab=${tab}&pageIndex=${pageIndex}`;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
historyPushSpy = jest.spyOn(props.history, 'push');
historyReplaceSpy = jest.spyOn(props.history, 'replace');
});
it('pushes correct update to the window state', () => {
historyPushSpy.mockClear();
historyReplaceSpy.mockClear();
wrapper.instance().updatePageUrl(searchTerm, tab, pageIndex);
expect(historyPushSpy).toHaveBeenCalledWith(expectedPath);
expect(historyReplaceSpy).not.toHaveBeenCalled();
});
it('calls `history.replace` when replace is set to true', () => {
historyPushSpy.mockClear();
historyReplaceSpy.mockClear();
wrapper.instance().updatePageUrl(searchTerm, tab, pageIndex, true);
expect(historyPushSpy).not.toHaveBeenCalled();
expect(historyReplaceSpy).toHaveBeenCalledWith(expectedPath);
it('does not call urldidUpdate when location.search is the same', () => {
props.urlDidUpdate.mockClear();
wrapper.instance().componentDidUpdate(props);
expect(props.urlDidUpdate).not.toHaveBeenCalled();
});
});
......@@ -720,7 +212,7 @@ describe('SearchPage', () => {
slicedItems: testResults.results,
slicedItemsCount: testResults.total_results,
itemsPerPage: RESULTS_PER_PAGE,
onPagination: wrapper.instance().onPaginationChange,
onPagination: props.setPageIndex,
source: SEARCH_SOURCE_NAME,
});
});
......@@ -755,7 +247,7 @@ describe('SearchPage', () => {
const tabProps = content.find(TabsComponent).props();
expect(tabProps.activeKey).toEqual(props.selectedTab);
expect(tabProps.defaultTab).toEqual(ResourceType.table);
expect(tabProps.onSelect).toEqual(wrapper.instance().onTabChange);
expect(tabProps.onSelect).toEqual(props.setResource);
const firstTab = tabProps.tabs[0];
expect(firstTab.key).toEqual(ResourceType.table);
......@@ -815,12 +307,16 @@ describe('mapDispatchToProps', () => {
result = mapDispatchToProps(dispatch);
});
it('sets searchAll on the props', () => {
expect(result.searchAll).toBeInstanceOf(Function);
it('sets setResource on the props', () => {
expect(result.setResource).toBeInstanceOf(Function);
});
it('sets searchResource on the props', () => {
expect(result.searchResource).toBeInstanceOf(Function);
it('sets setPageIndex on the props', () => {
expect(result.setPageIndex).toBeInstanceOf(Function);
});
it('sets urlDidUpdate on the props', () => {
expect(result.urlDidUpdate).toBeInstanceOf(Function);
});
});
......@@ -834,6 +330,10 @@ 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 isLoading on the props', () => {
expect(result.isLoading).toEqual(globalState.search.isLoading);
});
......
import * as React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import './styles.scss';
import { GlobalState } from 'ducks/rootReducer';
import { loadPreviousSearch } from 'ducks/search/reducer';
import { LoadPreviousSearchRequest } from 'ducks/search/types';
export interface OwnProps {
path?: string;
......@@ -14,7 +17,11 @@ export interface StateFromProps {
searchTerm: string;
}
export type BreadcrumbProps = OwnProps & StateFromProps;
export interface MapDispatchToProps {
loadPreviousSearch: () => LoadPreviousSearchRequest;
}
export type BreadcrumbProps = OwnProps & StateFromProps & MapDispatchToProps;
export const Breadcrumb: React.SFC<BreadcrumbProps> = (props) => {
let path = props.path;
......@@ -23,8 +30,14 @@ export const Breadcrumb: React.SFC<BreadcrumbProps> = (props) => {
path = '/';
text = 'Home';
if (props.searchTerm) {
path = `/search`;
text = 'Search Results';
return (
<div className="amundsen-breadcrumb">
<a onClick={ props.loadPreviousSearch } className='btn btn-flat-icon title-3'>
<img className='icon icon-left'/>
<span>Search Results</span>
</a>
</div>
);
}
}
return (
......@@ -43,4 +56,8 @@ export const mapStateToProps = (state: GlobalState) => {
};
};
export default connect<StateFromProps>(mapStateToProps, null)(Breadcrumb);
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ loadPreviousSearch }, dispatch);
};
export default connect<StateFromProps, MapDispatchToProps>(mapStateToProps, mapDispatchToProps)(Breadcrumb);
......@@ -3,7 +3,8 @@ import * as React from 'react';
import { shallow } from 'enzyme';
import { Link } from 'react-router-dom';
import { Breadcrumb, BreadcrumbProps } from '../';
import { Breadcrumb, BreadcrumbProps, mapStateToProps, mapDispatchToProps } from '../';
import globalState from '../../../../fixtures/globalState';
describe('Breadcrumb', () => {
let props: BreadcrumbProps;
......@@ -15,6 +16,7 @@ describe('Breadcrumb', () => {
path: 'testPath',
text: 'testText',
searchTerm: '',
loadPreviousSearch: jest.fn(),
};
subject = shallow(<Breadcrumb {...props} />);
});
......@@ -34,18 +36,19 @@ describe('Breadcrumb', () => {
beforeEach(() => {
props = {
searchTerm: 'testTerm',
loadPreviousSearch: jest.fn(),
};
subject = shallow(<Breadcrumb {...props} />);
});
it('renders Link with correct path', () => {
expect(subject.find(Link).props()).toMatchObject({
to: '/search',
expect(subject.find('a').props()).toMatchObject({
onClick: props.loadPreviousSearch,
});
});
it('renders Link with correct text', () => {
expect(subject.find(Link).find('span').text()).toEqual('Search Results');
expect(subject.find('a').find('span').text()).toEqual('Search Results');
});
});
......@@ -55,6 +58,7 @@ describe('Breadcrumb', () => {
path: 'testPath',
text: 'testText',
searchTerm: 'testTerm',
loadPreviousSearch: jest.fn(),
};
subject = shallow(<Breadcrumb {...props} />);
});
......@@ -69,4 +73,28 @@ describe('Breadcrumb', () => {
expect(subject.find(Link).find('span').text()).toEqual('testText');
});
});
describe('mapStateToProps', () => {
let result;
beforeAll(() => {
result = mapStateToProps(globalState);
});
it('sets searchTerm on the props', () => {
expect(result.searchTerm).toEqual(globalState.search.search_term);
});
});
describe('mapDispatchToProps', () => {
let dispatch;
let result;
beforeAll(() => {
dispatch = jest.fn(() => Promise.resolve());
result = mapDispatchToProps(dispatch);
});
it('sets loadPreviousSearch on the props', () => {
expect(result.loadPreviousSearch).toBeInstanceOf(Function);
});
});
});
......@@ -12,10 +12,18 @@ import {
// FeedbackForm
import { submitFeedbackWatcher } from './feedback/sagas';
// SearchPage
// PopularTables
import { getPopularTablesWatcher } from './popularTables/sagas';
import { searchAllWatcher, searchResourceWatcher } from './search/sagas';
// SearchPage
import {
loadPreviousSearchWatcher,
searchAllWatcher,
searchResourceWatcher,
setPageIndexWatcher,
setResourceWatcher,
submitSearchWatcher,
urlDidUpdateWatcher
} from './search/sagas';
// TableDetail
import { updateTableOwnerWatcher } from './tableMetadata/owners/sagas';
......@@ -48,8 +56,14 @@ export default function* rootSaga() {
// FeedbackForm
submitFeedbackWatcher(),
// SearchPage
loadPreviousSearchWatcher(),
searchAllWatcher(),
searchResourceWatcher(),
setPageIndexWatcher(),
setResourceWatcher(),
submitSearchWatcher(),
urlDidUpdateWatcher(),
// PopularTables
getPopularTablesWatcher(),
// Tags
getAllTagsWatcher(),
......
import { ResourceType } from 'interfaces';
import { Search as UrlSearch } from 'history';
import {
DashboardSearchResults,
SearchAll,
......@@ -12,9 +14,12 @@ import {
SearchResourceRequest,
SearchResourceResponse,
TableSearchResults,
UpdateSearchTab,
UpdateSearchTabRequest,
UserSearchResults,
SubmitSearchRequest,
SubmitSearch,
SetResourceRequest,
SetResource,
SetPageIndexRequest, SetPageIndex, LoadPreviousSearchRequest, LoadPreviousSearch, UrlDidUpdateRequest, UrlDidUpdate,
} from './types';
export interface SearchReducerState {
......@@ -67,12 +72,39 @@ export function searchReset(): SearchAllReset {
};
};
export function updateSearchTab(selectedTab: ResourceType): UpdateSearchTabRequest {
export function submitSearch(searchTerm: string): SubmitSearchRequest {
return {
payload: { searchTerm },
type: SubmitSearch.REQUEST,
};
};
export function setResource(resource: ResourceType, updateUrl: boolean = true): SetResourceRequest {
return {
payload: { resource, updateUrl },
type: SetResource.REQUEST,
};
};
export function setPageIndex(pageIndex: number, updateUrl: boolean = true): SetPageIndexRequest {
return {
payload: { selectedTab },
type: UpdateSearchTab.REQUEST,
payload: { pageIndex, updateUrl },
type: SetPageIndex.REQUEST,
};
}
};
export function loadPreviousSearch(): LoadPreviousSearchRequest {
return {
type: LoadPreviousSearch.REQUEST,
};
};
export function urlDidUpdate(urlSearch: UrlSearch): UrlDidUpdateRequest{
return {
payload: { urlSearch },
type: UrlDidUpdate.REQUEST,
};
};
/* REDUCER */
......@@ -135,10 +167,10 @@ export default function reducer(state: SearchReducerState = initialState, action
...initialState,
search_term: state.search_term,
};
case UpdateSearchTab.REQUEST:
case SetResource.REQUEST:
return {
...state,
selectedTab: (<UpdateSearchTabRequest>action).payload.selectedTab
selectedTab: (<SetResourceRequest>action).payload.resource
};
default:
return state;
......
import { SagaIterator } from 'redux-saga';
import { all, call, put, takeEvery } from 'redux-saga/effects';
import { all, call, put, select, takeEvery } from 'redux-saga/effects';
import * as qs from 'simple-query-string';
import { ResourceType } from 'interfaces/Resources';
import * as API from './api/v0';
import {
LoadPreviousSearch,
LoadPreviousSearchRequest,
SearchAll,
SearchAllRequest,
SearchResource,
SearchResourceRequest,
SetPageIndex,
SetPageIndexRequest, SetResource, SetResourceRequest,
SubmitSearch,
SubmitSearchRequest,
UrlDidUpdate,
UrlDidUpdateRequest,
} from './types';
import {
initialState, searchAllSuccess, searchAllFailure,
searchResourceSuccess, searchResourceFailure,
initialState,
searchAll,
searchAllFailure,
searchAllSuccess,
searchResource,
searchResourceFailure,
searchResourceSuccess, setPageIndex, setResource,
} from './reducer';
import { getPageIndex, getSearchState } from './utils';
import { updateSearchUrl } from 'utils/navigation-utils';
export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
const { resource, pageIndex, term } = action.payload;
......@@ -35,8 +51,12 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
tables: tableResponse.tables || initialState.tables,
users: userResponse.users || initialState.users,
dashboards: dashboardResponse.dashboards || initialState.dashboards,
isLoading: false,
};
const index = getPageIndex(searchAllResponse, resource);
yield put(searchAllSuccess(searchAllResponse));
updateSearchUrl({ term, resource, index, }, true);
} catch (e) {
yield put(searchAllFailure());
}
......@@ -53,7 +73,78 @@ export function* searchResourceWorker(action: SearchResourceRequest): SagaIterat
} catch (e) {
yield put(searchResourceFailure());
}
}
};
export function* searchResourceWatcher(): SagaIterator {
yield takeEvery(SearchResource.REQUEST, searchResourceWorker);
}
};
export function* submitSearchWorker(action: SubmitSearchRequest): SagaIterator {
const { searchTerm } = action.payload;
yield put(searchAll(searchTerm, ResourceType.table, 0));
updateSearchUrl({ term: searchTerm });
};
export function* submitSearchWatcher(): SagaIterator {
yield takeEvery(SubmitSearch.REQUEST, submitSearchWorker);
};
export function* setResourceWorker(action: SetResourceRequest): SagaIterator {
const { resource, updateUrl } = action.payload;
const state = yield select(getSearchState);
if (updateUrl) {
updateSearchUrl({
resource,
term: state.search_term,
index: getPageIndex(state, resource),
});
}
};
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(state.search_term, state.selectedTab, pageIndex));
if (updateUrl) {
updateSearchUrl({
term: state.search_term,
resource: state.selectedTab,
index: pageIndex,
});
}
};
export function* setPageIndexWatcher(): SagaIterator {
yield takeEvery(SetPageIndex.REQUEST, setPageIndexWorker);
};
export function* urlDidUpdateWorker(action: UrlDidUpdateRequest): SagaIterator {
const { urlSearch } = action.payload;
const { term, resource, index} = qs.parse(urlSearch);
const parsedIndex = parseInt(index, 10);
const state = yield select(getSearchState);
if (!!term && state.search_term !== term) {
yield put(searchAll(term, resource, parsedIndex));
} else if (!!resource && resource !== state.selectedTab) {
yield put(setResource(resource, false))
} else if (!isNaN(parsedIndex) && parsedIndex !== getPageIndex(state, resource)) {
yield put(setPageIndex(parsedIndex, false));
}
};
export function* urlDidUpdateWatcher(): SagaIterator {
yield takeEvery(UrlDidUpdate.REQUEST, urlDidUpdateWorker);
};
export function* loadPreviousSearchWorker(action: LoadPreviousSearchRequest): SagaIterator {
const state = yield select(getSearchState);
updateSearchUrl({
term: state.search_term,
resource: state.selectedTab,
index: getPageIndex(state, state.selectedTab),
});
};
export function* loadPreviousSearchWatcher(): SagaIterator {
yield takeEvery(LoadPreviousSearch.REQUEST, loadPreviousSearchWorker);
};
......@@ -6,6 +6,7 @@ import * as API from '../api/v0';
import reducer, {
initialState,
loadPreviousSearch,
searchAll,
searchAllFailure,
searchAllSuccess,
......@@ -14,10 +15,45 @@ import reducer, {
searchResource,
searchResourceFailure,
searchResourceSuccess,
updateSearchTab,
setPageIndex,
setResource,
submitSearch,
urlDidUpdate,
} from '../reducer';
import { searchAllWatcher, searchAllWorker, searchResourceWatcher, searchResourceWorker } from '../sagas';
import { SearchAll, SearchAllResponsePayload, SearchResource, SearchResponsePayload, UpdateSearchTab, } from '../types';
import {
loadPreviousSearchWatcher,
loadPreviousSearchWorker,
searchAllWatcher,
searchAllWorker,
searchResourceWatcher,
searchResourceWorker,
setPageIndexWatcher,
setPageIndexWorker,
setResourceWatcher,
setResourceWorker,
submitSearchWatcher,
submitSearchWorker,
urlDidUpdateWatcher,
urlDidUpdateWorker
} from '../sagas';
import {
LoadPreviousSearch,
SearchAll,
SearchAllResponsePayload,
SearchResource,
SearchResponsePayload,
SetPageIndex,
SetResource,
SubmitSearch,
UrlDidUpdate,
} from '../types';
import * as NavigationUtils from '../../../utils/navigation-utils';
import * as SearchUtils from 'ducks/search/utils';
import globalState from '../../../fixtures/globalState';
const updateSearchUrlSpy = jest.spyOn(NavigationUtils, 'updateSearchUrl');
const searchState = globalState.search;
describe('search ducks', () => {
const expectedSearchResults: SearchResponsePayload = {
......@@ -124,12 +160,43 @@ describe('search ducks', () => {
expect(action.type).toBe(SearchAll.RESET);
});
it('updateSearchTab - returns the action to update the search tab', () => {
const selectedTab = ResourceType.user;
const action = updateSearchTab(selectedTab);
const payload = action.payload;
expect(action.type).toBe(UpdateSearchTab.REQUEST);
expect(payload.selectedTab).toBe(selectedTab);
it('submitSearch - returns the action to submit a search', () => {
const term = 'test';
const action = submitSearch(term);
expect(action.type).toBe(SubmitSearch.REQUEST);
expect(action.payload.searchTerm).toBe(term);
});
it('setResource - returns the action to set the selected resource', () => {
const resource = ResourceType.table;
const updateUrl = true;
const action = setResource(resource, updateUrl);
const { payload } = action;
expect(action.type).toBe(SetResource.REQUEST);
expect(payload.resource).toBe(resource);
expect(payload.updateUrl).toBe(updateUrl);
});
it('setPageIndex - returns the action to set the page index', () => {
const index = 3;
const updateUrl = true;
const action = setPageIndex(index, updateUrl);
const { payload } = action;
expect(action.type).toBe(SetPageIndex.REQUEST);
expect(payload.pageIndex).toBe(index);
expect(payload.updateUrl).toBe(updateUrl);
});
it('loadPreviousSearch - returns the action to load the previous search', () => {
const action = loadPreviousSearch();
expect(action.type).toBe(LoadPreviousSearch.REQUEST);
});
it('urlDidUpdate - returns the action to run when the search page URL changes', () => {
const urlSearch = 'test url search';
const action = urlDidUpdate(urlSearch);
expect(action.type).toBe(UrlDidUpdate.REQUEST);
expect(action.payload.urlSearch).toBe(urlSearch);
});
});
......@@ -194,9 +261,9 @@ describe('search ducks', () => {
});
});
it('should handle UpdateSearchTab.REQUEST', () => {
it('should handle SetResource.REQUEST', () => {
const selectedTab = ResourceType.user;
expect(reducer(testState, updateSearchTab(selectedTab))).toEqual({
expect(reducer(testState, setResource(selectedTab))).toEqual({
...testState,
selectedTab,
});
......@@ -258,5 +325,216 @@ describe('search ducks', () => {
.next().isDone();
});
});
describe('submitSearchWorker', () => {
it('initiates a searchAll action', () => {
const term = 'test';
updateSearchUrlSpy.mockClear();
testSaga(submitSearchWorker, submitSearch(term))
.next().put(searchAll(term, ResourceType.table, 0))
.next().isDone();
expect(updateSearchUrlSpy).toHaveBeenCalledWith({ term });
});
});
describe('submitSearchWatcher', () => {
it('takes every SubmitSearch.REQUEST with submitSearchWorker', () => {
testSaga(submitSearchWatcher)
.next().takeEvery(SubmitSearch.REQUEST, submitSearchWorker)
.next().isDone();
});
});
describe('setResourceWorker', () => {
it('calls updateSearchUrl when updateUrl is true', () => {
const resource = ResourceType.table;
const updateUrl = true;
updateSearchUrlSpy.mockClear();
testSaga(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,
});
});
it('calls updateSearchUrl when updateUrl is true', () => {
const resource = ResourceType.table;
const updateUrl = false;
updateSearchUrlSpy.mockClear();
testSaga(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(setResourceWatcher)
.next().takeEvery(SetResource.REQUEST, 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(setPageIndexWorker, setPageIndex(index, updateUrl))
.next().select(SearchUtils.getSearchState)
.next(searchState).put(searchResource(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(setPageIndexWorker, setPageIndex(index, updateUrl))
.next().select(SearchUtils.getSearchState)
.next(searchState).put(searchResource(searchState.search_term, searchState.selectedTab, index))
.next().isDone();
expect(updateSearchUrlSpy).not.toHaveBeenCalled();
});
});
describe('setPageIndexWatcher', () => {
it('takes every SetPageIndex.REQUEST with setPageIndexWorker', () => {
testSaga(setPageIndexWatcher)
.next().takeEvery(SetPageIndex.REQUEST, setPageIndexWorker)
.next().isDone();
});
});
describe('urlDidUpdateWorker', () => {
let sagaTest;
let term;
let resource;
let index;
beforeEach(() => {
term = searchState.search_term;
resource = searchState.selectedTab;
index = SearchUtils.getPageIndex(searchState, resource);
sagaTest = (action) => {
return testSaga(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(term, resource, index))
.next().isDone();
});
it('Calls setResource when the resource has changed', () => {
resource = ResourceType.user;
sagaTest(urlDidUpdate(`term=${term}&resource=${resource}&index=${index}`))
.put(setResource(resource, false))
.next().isDone();
});
it('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(urlDidUpdateWatcher)
.next().takeEvery(UrlDidUpdate.REQUEST, urlDidUpdateWorker)
.next().isDone();
});
});
describe('loadPreviousSearchWorker', () => {
it('applies the existing search state into the URL', () => {
updateSearchUrlSpy.mockClear();
testSaga(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),
});
});
});
describe('loadPreviousSearchWatcher', () => {
it('takes every LoadPreviousSearch.REQUEST with loadPreviousSearchWorker', () => {
testSaga(loadPreviousSearchWatcher)
.next().takeEvery(LoadPreviousSearch.REQUEST, loadPreviousSearchWorker)
.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);
});
});
});
});
import { Search as UrlSearch } from 'history';
import {
DashboardResource,
Resource,
......@@ -28,6 +30,7 @@ export interface SearchAllResponsePayload extends SearchResponsePayload {
users: UserSearchResults;
};
export enum SearchAll {
REQUEST = 'amundsen/search/SEARCH_ALL_REQUEST',
SUCCESS = 'amundsen/search/SEARCH_ALL_SUCCESS',
......@@ -48,7 +51,8 @@ export interface SearchAllResponse {
};
export interface SearchAllReset {
type: SearchAll.RESET;
}
};
export enum SearchResource {
REQUEST = 'amundsen/search/SEARCH_RESOURCE_REQUEST',
......@@ -68,12 +72,56 @@ export interface SearchResourceResponse {
payload?: SearchResponsePayload;
};
export enum UpdateSearchTab {
REQUEST = 'amundsen/search/UPDATE_SEARCH_TAB_REQUEST',
}
export interface UpdateSearchTabRequest {
type: UpdateSearchTab.REQUEST;
export enum SubmitSearch {
REQUEST = 'amundsen/search/SUBMIT_SEARCH_REQUEST',
};
export interface SubmitSearchRequest {
payload: {
selectedTab: ResourceType;
}
}
searchTerm: string;
};
type: SubmitSearch.REQUEST;
};
export enum SetResource {
REQUEST = 'amundsen/search/SET_RESOURCE_REQUEST',
};
export interface SetResourceRequest {
payload: {
resource: ResourceType;
updateUrl: boolean;
};
type: SetResource.REQUEST;
};
export enum SetPageIndex {
REQUEST = 'amundsen/search/SET_PAGE_INDEX_REQUEST',
};
export interface SetPageIndexRequest {
payload: {
pageIndex: number;
updateUrl: boolean;
};
type: SetPageIndex.REQUEST;
};
export enum LoadPreviousSearch {
REQUEST = 'amundsen/search/LOAD_PREVIOUS_SEARCH_REQUEST',
};
export interface LoadPreviousSearchRequest {
type: LoadPreviousSearch.REQUEST;
};
export enum UrlDidUpdate {
REQUEST = 'amundsen/search/URL_DID_UPDATE_REQUEST',
};
export interface UrlDidUpdateRequest {
payload: {
urlSearch: UrlSearch;
};
type: UrlDidUpdate.REQUEST;
};
import { GlobalState } from 'ducks/rootReducer';
import { SearchReducerState } from 'ducks/search/reducer';
import { ResourceType } from 'interfaces/Resources';
export const getSearchState = (state: GlobalState): SearchReducerState => state.search;
export const getPageIndex = (state: SearchReducerState, resource?: ResourceType) => {
resource = resource || state.selectedTab;
switch(resource) {
case ResourceType.table:
return state.tables.page_index;
case ResourceType.user:
return state.users.page_index;
case ResourceType.dashboard:
return state.dashboards.page_index;
};
return 0;
};
......@@ -25,8 +25,8 @@ export function getMockRouterProps<P>(data: P, location: Partial<History.Locatio
length: 2,
action: 'POP',
location: mockLocation,
push: () => {},
replace: () => {},
push: null,
replace: null,
go: null,
goBack: null,
goForward: null,
......
......@@ -6,7 +6,7 @@ import createSagaMiddleware from 'redux-saga';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import { Router, Route, Switch } from 'react-router-dom';
import DocumentTitle from 'react-document-title';
import AnnouncementPage from './components/AnnouncementPage';
......@@ -23,6 +23,7 @@ import TableDetail from './components/TableDetail';
import rootReducer from './ducks/rootReducer';
import rootSaga from './ducks/rootSaga';
import { BrowserHistory } from 'utils/navigation-utils';
const sagaMiddleware = createSagaMiddleware();
const createStoreWithMiddleware = applyMiddleware(ReduxPromise, sagaMiddleware)(createStore);
......@@ -33,7 +34,7 @@ sagaMiddleware.run(rootSaga);
ReactDOM.render(
<DocumentTitle title="Amundsen - Data Discovery Portal">
<Provider store={store}>
<BrowserRouter>
<Router history={BrowserHistory}>
<div id="main">
<Preloader/>
<NavBar />
......@@ -49,7 +50,7 @@ ReactDOM.render(
<Feedback />
<Footer />
</div>
</BrowserRouter>
</Router>
</Provider>
</DocumentTitle>
, document.getElementById('content') || document.createElement('div'),
......
import * as qs from 'simple-query-string';
import { createBrowserHistory } from 'history';
// https://github.com/ReactTraining/react-router/issues/3972#issuecomment-264805667
export const BrowserHistory = createBrowserHistory();
interface SearchParams {
term?: string;
resource?: string;
index?: number;
}
export const updateSearchUrl = (searchParams: SearchParams, replace: boolean = false) => {
// Explicitly listing out parameters to ensure consistent URL format
const urlParams = qs.stringify({
term: searchParams.term,
resource: searchParams.resource,
index: searchParams.index,
});
if (replace) {
BrowserHistory.replace(`/search?${urlParams}`);
} else {
BrowserHistory.push(`/search?${urlParams}`);
}
};
import * as NavigationUtils from 'utils/navigation-utils';
import * as qs from 'simple-query-string';
import { ResourceType } from 'interfaces/Resources';
describe('Navigation utils', () => {
describe('updateSearchUrl', () => {
let historyReplaceSpy;
let historyPushSpy;
let searchParams;
let expectedQueryString;
beforeAll(() => {
historyReplaceSpy = jest.spyOn(NavigationUtils.BrowserHistory, 'replace');
historyPushSpy = jest.spyOn(NavigationUtils.BrowserHistory, 'push');
searchParams = {
term: 'test',
resource: ResourceType.table,
index: 0,
};
expectedQueryString = `/search?${qs.stringify(searchParams)}`;
});
it('calls history.replace when replace is true', () => {
historyReplaceSpy.mockClear();
historyPushSpy.mockClear();
const replace = true;
NavigationUtils.updateSearchUrl(searchParams, replace);
expect(historyReplaceSpy).toHaveBeenCalledWith(expectedQueryString);
expect(historyPushSpy).not.toHaveBeenCalled();
});
it('calls history.push when replace is false', () => {
historyReplaceSpy.mockClear();
historyPushSpy.mockClear();
const replace = false;
NavigationUtils.updateSearchUrl(searchParams, replace);
expect(historyReplaceSpy).not.toHaveBeenCalled();
expect(historyPushSpy).toHaveBeenCalledWith(expectedQueryString);
});
});
});
......@@ -39,6 +39,7 @@ const config: webpack.Configuration = {
config: path.join(__dirname, '/js/config'),
ducks: path.join(__dirname, '/js/ducks'),
interfaces: path.join(__dirname, '/js/interfaces'),
utils: path.join(__dirname, '/js/utils'),
},
extensions: ['.tsx', '.ts', '.js', '.jsx', '.css', '.scss'],
},
......
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