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 * as React from 'react';
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router';
// TODO: Use css-modules instead of 'import' // TODO: Use css-modules instead of 'import'
import './styles.scss'; import './styles.scss';
...@@ -14,17 +14,23 @@ import { ...@@ -14,17 +14,23 @@ import {
SYNTAX_ERROR_SPACING_SUFFIX, SYNTAX_ERROR_SPACING_SUFFIX,
} from './constants'; } from './constants';
import { GlobalState } from 'ducks/rootReducer'; import { GlobalState } from 'ducks/rootReducer';
import { submitSearch } from 'ducks/search/reducer';
import { SubmitSearchRequest } from 'ducks/search/types';
export interface StateFromProps { export interface StateFromProps {
searchTerm: string; searchTerm: string;
} }
export interface DispatchFromProps {
submitSearch: (searchTerm: string) => SubmitSearchRequest;
}
export interface OwnProps { export interface OwnProps {
placeholder?: string; placeholder?: string;
subText?: string; subText?: string;
} }
export type SearchBarProps = StateFromProps & OwnProps & RouteComponentProps<any>; export type SearchBarProps = StateFromProps & DispatchFromProps & OwnProps;
interface SearchBarState { interface SearchBarState {
subTextClassName: string; subTextClassName: string;
...@@ -61,15 +67,15 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> { ...@@ -61,15 +67,15 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
const searchTerm = this.state.searchTerm.trim(); const searchTerm = this.state.searchTerm.trim();
event.preventDefault(); event.preventDefault();
if (this.isFormValid(searchTerm)) { if (this.isFormValid(searchTerm)) {
let pathName = '/'; this.props.submitSearch(searchTerm);
if (searchTerm !== '') {
pathName = `/search?searchTerm=${searchTerm}`;
}
this.props.history.push(pathName);
} }
}; };
isFormValid = (searchTerm: string) : boolean => { isFormValid = (searchTerm: string) : boolean => {
if (searchTerm.length === 0) {
return false;
}
const hasAtMostOneCategory = searchTerm.split(':').length <= 2; const hasAtMostOneCategory = searchTerm.split(':').length <= 2;
if (!hasAtMostOneCategory) { if (!hasAtMostOneCategory) {
this.setState({ this.setState({
...@@ -126,4 +132,8 @@ export const mapStateToProps = (state: GlobalState) => { ...@@ -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'; ...@@ -2,7 +2,7 @@ import * as React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { mapStateToProps, SearchBar, SearchBarProps } from '../'; import { mapStateToProps, mapDispatchToProps, SearchBar, SearchBarProps } from '../';
import { import {
ERROR_CLASSNAME, ERROR_CLASSNAME,
SUBTEXT_DEFAULT, SUBTEXT_DEFAULT,
...@@ -11,18 +11,15 @@ import { ...@@ -11,18 +11,15 @@ import {
SYNTAX_ERROR_SPACING_SUFFIX, SYNTAX_ERROR_SPACING_SUFFIX,
} from '../constants'; } from '../constants';
import globalState from 'fixtures/globalState'; import globalState from 'fixtures/globalState';
import { getMockRouterProps } from 'fixtures/mockRouter';
describe('SearchBar', () => { describe('SearchBar', () => {
const valueChangeMockEvent = { target: { value: 'Data Resources' } }; const valueChangeMockEvent = { target: { value: 'Data Resources' } };
const submitMockEvent = { preventDefault: jest.fn() }; const submitMockEvent = { preventDefault: jest.fn() };
const setStateSpy = jest.spyOn(SearchBar.prototype, 'setState'); 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 setup = (propOverrides?: Partial<SearchBarProps>) => {
const props: SearchBarProps = { const props: SearchBarProps = {
searchTerm: '', searchTerm: '',
...routerProps, submitSearch: jest.fn(),
...propOverrides ...propOverrides
}; };
const wrapper = shallow<SearchBar>(<SearchBar {...props} />) const wrapper = shallow<SearchBar>(<SearchBar {...props} />)
...@@ -82,27 +79,18 @@ describe('SearchBar', () => { ...@@ -82,27 +79,18 @@ describe('SearchBar', () => {
expect(submitMockEvent.preventDefault).toHaveBeenCalled(); 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()', () => { it('submits with correct props if isFormValid()', () => {
historyPushSpy.mockClear();
const { props, wrapper } = setup({ searchTerm: 'testTerm' }); const { props, wrapper } = setup({ searchTerm: 'testTerm' });
// @ts-ignore: mocked events throw type errors // @ts-ignore: mocked events throw type errors
wrapper.instance().handleValueSubmit(submitMockEvent); wrapper.instance().handleValueSubmit(submitMockEvent);
expect(historyPushSpy).toHaveBeenCalledWith(`/search?searchTerm=${wrapper.state().searchTerm}`); expect(props.submitSearch).toHaveBeenCalledWith(props.searchTerm);
}); });
it('does not submit if !isFormValid()', () => { it('does not submit if !isFormValid()', () => {
historyPushSpy.mockClear();
const { props, wrapper } = setup({ searchTerm: 'tag:tag1 tag:tag2' }); const { props, wrapper } = setup({ searchTerm: 'tag:tag1 tag:tag2' });
// @ts-ignore: mocked events throw type errors // @ts-ignore: mocked events throw type errors
wrapper.instance().handleValueSubmit(submitMockEvent); wrapper.instance().handleValueSubmit(submitMockEvent);
expect(historyPushSpy).not.toHaveBeenCalled(); expect(props.submitSearch).not.toHaveBeenCalled();
}); });
}); });
...@@ -111,12 +99,16 @@ describe('SearchBar', () => { ...@@ -111,12 +99,16 @@ describe('SearchBar', () => {
let wrapper; let wrapper;
beforeAll(() => { beforeAll(() => {
wrapper = setup().wrapper; wrapper = setup().wrapper;
}) });
it('returns false', () => { it('does not accept multiple search categories', () => {
expect(wrapper.instance().isFormValid('tag:tag1 tag:tag2')).toEqual(false); 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', () => { it('sets state.subText correctly', () => {
expect(wrapper.state().subText).toEqual(SYNTAX_ERROR_CATEGORY); expect(wrapper.state().subText).toEqual(SYNTAX_ERROR_CATEGORY);
}); });
...@@ -130,7 +122,7 @@ describe('SearchBar', () => { ...@@ -130,7 +122,7 @@ describe('SearchBar', () => {
let wrapper; let wrapper;
beforeAll(() => { beforeAll(() => {
wrapper = setup({ searchTerm: 'tag : tag1' }).wrapper; wrapper = setup({ searchTerm: 'tag : tag1' }).wrapper;
}) });
it('returns false', () => { it('returns false', () => {
expect(wrapper.instance().isFormValid('tag : tag1')).toEqual(false); expect(wrapper.instance().isFormValid('tag : tag1')).toEqual(false);
...@@ -149,7 +141,7 @@ describe('SearchBar', () => { ...@@ -149,7 +141,7 @@ describe('SearchBar', () => {
let wrapper; let wrapper;
beforeAll(() => { beforeAll(() => {
wrapper = setup().wrapper; wrapper = setup().wrapper;
}) });
it('returns true', () => { it('returns true', () => {
expect(wrapper.instance().isFormValid('tag:tag1')).toEqual(true); expect(wrapper.instance().isFormValid('tag:tag1')).toEqual(true);
...@@ -237,6 +229,19 @@ describe('SearchBar', () => { ...@@ -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', () => { describe('mapStateToProps', () => {
let result; let result;
beforeAll(() => { beforeAll(() => {
......
...@@ -2,9 +2,8 @@ import * as React from 'react'; ...@@ -2,9 +2,8 @@ import * as React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import * as DocumentTitle from 'react-document-title'; import * as DocumentTitle from 'react-document-title';
import * as qs from 'simple-query-string';
import { RouteComponentProps } from 'react-router'; import { RouteComponentProps } from 'react-router';
import { Search } from 'history'; import { Search as UrlSearch } from 'history';
import AppConfig from 'config/config'; import AppConfig from 'config/config';
import LoadingSpinner from 'components/common/LoadingSpinner'; import LoadingSpinner from 'components/common/LoadingSpinner';
...@@ -14,14 +13,14 @@ import TabsComponent from 'components/common/Tabs'; ...@@ -14,14 +13,14 @@ import TabsComponent from 'components/common/Tabs';
import SearchBar from './SearchBar'; import SearchBar from './SearchBar';
import { GlobalState } from 'ducks/rootReducer'; import { GlobalState } from 'ducks/rootReducer';
import { searchAll, searchResource, updateSearchTab } from 'ducks/search/reducer'; import { setPageIndex, setResource, urlDidUpdate } from 'ducks/search/reducer';
import { import {
DashboardSearchResults, DashboardSearchResults,
SearchAllRequest,
SearchResourceRequest,
SearchResults, SearchResults,
SetPageIndexRequest,
SetResourceRequest,
TableSearchResults, TableSearchResults,
UpdateSearchTabRequest, UrlDidUpdateRequest,
UserSearchResults, UserSearchResults,
} from 'ducks/search/types'; } from 'ducks/search/types';
...@@ -54,9 +53,9 @@ export interface StateFromProps { ...@@ -54,9 +53,9 @@ export interface StateFromProps {
} }
export interface DispatchFromProps { export interface DispatchFromProps {
searchAll: (term: string, selectedTab: ResourceType, pageIndex: number) => SearchAllRequest; setResource: (resource: ResourceType) => SetResourceRequest;
searchResource: (term: string, resource: ResourceType, pageIndex: number) => SearchResourceRequest; setPageIndex: (pageIndex: number) => SetPageIndexRequest;
updateSearchTab: (selectedTab: ResourceType) => UpdateSearchTabRequest; urlDidUpdate: (urlSearch: UrlSearch) => UrlDidUpdateRequest;
} }
export type SearchPageProps = StateFromProps & DispatchFromProps & RouteComponentProps<any>; export type SearchPageProps = StateFromProps & DispatchFromProps & RouteComponentProps<any>;
...@@ -69,126 +68,14 @@ export class SearchPage extends React.Component<SearchPageProps> { ...@@ -69,126 +68,14 @@ export class SearchPage extends React.Component<SearchPageProps> {
} }
componentDidMount() { componentDidMount() {
const urlSearchParams = this.getUrlParams(this.props.location.search); this.props.urlDidUpdate(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;
} }
componentDidUpdate(prevProps: SearchPageProps) { componentDidUpdate(prevProps: SearchPageProps) {
const nextUrlParams = this.getUrlParams(this.props.location.search); if (this.props.location.search !== prevProps.location.search) {
const prevUrlParams = this.getUrlParams(prevProps.location.search); this.props.urlDidUpdate(this.props.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;
} }
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 = () => { renderSearchResults = () => {
const tabConfig = [ const tabConfig = [
...@@ -212,7 +99,7 @@ export class SearchPage extends React.Component<SearchPageProps> { ...@@ -212,7 +99,7 @@ export class SearchPage extends React.Component<SearchPageProps> {
tabs={ tabConfig } tabs={ tabConfig }
defaultTab={ ResourceType.table } defaultTab={ ResourceType.table }
activeKey={ this.props.selectedTab } activeKey={ this.props.selectedTab }
onSelect={ this.onTabChange } onSelect={ this.props.setResource }
/> />
</div> </div>
); );
...@@ -282,7 +169,7 @@ export class SearchPage extends React.Component<SearchPageProps> { ...@@ -282,7 +169,7 @@ export class SearchPage extends React.Component<SearchPageProps> {
source={ SEARCH_SOURCE_NAME } source={ SEARCH_SOURCE_NAME }
itemsPerPage={ RESULTS_PER_PAGE } itemsPerPage={ RESULTS_PER_PAGE }
activePage={ page_index } activePage={ page_index }
onPagination={ this.onPaginationChange } onPagination={ this.props.setPageIndex }
/> />
</div> </div>
); );
...@@ -330,7 +217,7 @@ export const mapStateToProps = (state: GlobalState) => { ...@@ -330,7 +217,7 @@ export const mapStateToProps = (state: GlobalState) => {
}; };
export const mapDispatchToProps = (dispatch: any) => { 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); export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(SearchPage);
import * as React from 'react'; import * as React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import './styles.scss'; import './styles.scss';
import { GlobalState } from 'ducks/rootReducer'; import { GlobalState } from 'ducks/rootReducer';
import { loadPreviousSearch } from 'ducks/search/reducer';
import { LoadPreviousSearchRequest } from 'ducks/search/types';
export interface OwnProps { export interface OwnProps {
path?: string; path?: string;
...@@ -14,7 +17,11 @@ export interface StateFromProps { ...@@ -14,7 +17,11 @@ export interface StateFromProps {
searchTerm: string; 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) => { export const Breadcrumb: React.SFC<BreadcrumbProps> = (props) => {
let path = props.path; let path = props.path;
...@@ -23,8 +30,14 @@ export const Breadcrumb: React.SFC<BreadcrumbProps> = (props) => { ...@@ -23,8 +30,14 @@ export const Breadcrumb: React.SFC<BreadcrumbProps> = (props) => {
path = '/'; path = '/';
text = 'Home'; text = 'Home';
if (props.searchTerm) { if (props.searchTerm) {
path = `/search`; return (
text = 'Search Results'; <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 ( return (
...@@ -43,4 +56,8 @@ export const mapStateToProps = (state: GlobalState) => { ...@@ -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'; ...@@ -3,7 +3,8 @@ import * as React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Breadcrumb, BreadcrumbProps } from '../'; import { Breadcrumb, BreadcrumbProps, mapStateToProps, mapDispatchToProps } from '../';
import globalState from '../../../../fixtures/globalState';
describe('Breadcrumb', () => { describe('Breadcrumb', () => {
let props: BreadcrumbProps; let props: BreadcrumbProps;
...@@ -15,6 +16,7 @@ describe('Breadcrumb', () => { ...@@ -15,6 +16,7 @@ describe('Breadcrumb', () => {
path: 'testPath', path: 'testPath',
text: 'testText', text: 'testText',
searchTerm: '', searchTerm: '',
loadPreviousSearch: jest.fn(),
}; };
subject = shallow(<Breadcrumb {...props} />); subject = shallow(<Breadcrumb {...props} />);
}); });
...@@ -34,18 +36,19 @@ describe('Breadcrumb', () => { ...@@ -34,18 +36,19 @@ describe('Breadcrumb', () => {
beforeEach(() => { beforeEach(() => {
props = { props = {
searchTerm: 'testTerm', searchTerm: 'testTerm',
loadPreviousSearch: jest.fn(),
}; };
subject = shallow(<Breadcrumb {...props} />); subject = shallow(<Breadcrumb {...props} />);
}); });
it('renders Link with correct path', () => { it('renders Link with correct path', () => {
expect(subject.find(Link).props()).toMatchObject({ expect(subject.find('a').props()).toMatchObject({
to: '/search', onClick: props.loadPreviousSearch,
}); });
}); });
it('renders Link with correct text', () => { 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', () => { ...@@ -55,6 +58,7 @@ describe('Breadcrumb', () => {
path: 'testPath', path: 'testPath',
text: 'testText', text: 'testText',
searchTerm: 'testTerm', searchTerm: 'testTerm',
loadPreviousSearch: jest.fn(),
}; };
subject = shallow(<Breadcrumb {...props} />); subject = shallow(<Breadcrumb {...props} />);
}); });
...@@ -69,4 +73,28 @@ describe('Breadcrumb', () => { ...@@ -69,4 +73,28 @@ describe('Breadcrumb', () => {
expect(subject.find(Link).find('span').text()).toEqual('testText'); 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 { ...@@ -12,10 +12,18 @@ import {
// FeedbackForm // FeedbackForm
import { submitFeedbackWatcher } from './feedback/sagas'; import { submitFeedbackWatcher } from './feedback/sagas';
// PopularTables
// SearchPage
import { getPopularTablesWatcher } from './popularTables/sagas'; import { getPopularTablesWatcher } from './popularTables/sagas';
import { searchAllWatcher, searchResourceWatcher } from './search/sagas'; // SearchPage
import {
loadPreviousSearchWatcher,
searchAllWatcher,
searchResourceWatcher,
setPageIndexWatcher,
setResourceWatcher,
submitSearchWatcher,
urlDidUpdateWatcher
} from './search/sagas';
// TableDetail // TableDetail
import { updateTableOwnerWatcher } from './tableMetadata/owners/sagas'; import { updateTableOwnerWatcher } from './tableMetadata/owners/sagas';
...@@ -48,8 +56,14 @@ export default function* rootSaga() { ...@@ -48,8 +56,14 @@ export default function* rootSaga() {
// FeedbackForm // FeedbackForm
submitFeedbackWatcher(), submitFeedbackWatcher(),
// SearchPage // SearchPage
loadPreviousSearchWatcher(),
searchAllWatcher(), searchAllWatcher(),
searchResourceWatcher(), searchResourceWatcher(),
setPageIndexWatcher(),
setResourceWatcher(),
submitSearchWatcher(),
urlDidUpdateWatcher(),
// PopularTables
getPopularTablesWatcher(), getPopularTablesWatcher(),
// Tags // Tags
getAllTagsWatcher(), getAllTagsWatcher(),
......
import { ResourceType } from 'interfaces'; import { ResourceType } from 'interfaces';
import { Search as UrlSearch } from 'history';
import { import {
DashboardSearchResults, DashboardSearchResults,
SearchAll, SearchAll,
...@@ -12,9 +14,12 @@ import { ...@@ -12,9 +14,12 @@ import {
SearchResourceRequest, SearchResourceRequest,
SearchResourceResponse, SearchResourceResponse,
TableSearchResults, TableSearchResults,
UpdateSearchTab,
UpdateSearchTabRequest,
UserSearchResults, UserSearchResults,
SubmitSearchRequest,
SubmitSearch,
SetResourceRequest,
SetResource,
SetPageIndexRequest, SetPageIndex, LoadPreviousSearchRequest, LoadPreviousSearch, UrlDidUpdateRequest, UrlDidUpdate,
} from './types'; } from './types';
export interface SearchReducerState { export interface SearchReducerState {
...@@ -67,12 +72,39 @@ export function searchReset(): SearchAllReset { ...@@ -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 { return {
payload: { selectedTab }, payload: { pageIndex, updateUrl },
type: UpdateSearchTab.REQUEST, type: SetPageIndex.REQUEST,
}; };
} };
export function loadPreviousSearch(): LoadPreviousSearchRequest {
return {
type: LoadPreviousSearch.REQUEST,
};
};
export function urlDidUpdate(urlSearch: UrlSearch): UrlDidUpdateRequest{
return {
payload: { urlSearch },
type: UrlDidUpdate.REQUEST,
};
};
/* REDUCER */ /* REDUCER */
...@@ -135,10 +167,10 @@ export default function reducer(state: SearchReducerState = initialState, action ...@@ -135,10 +167,10 @@ export default function reducer(state: SearchReducerState = initialState, action
...initialState, ...initialState,
search_term: state.search_term, search_term: state.search_term,
}; };
case UpdateSearchTab.REQUEST: case SetResource.REQUEST:
return { return {
...state, ...state,
selectedTab: (<UpdateSearchTabRequest>action).payload.selectedTab selectedTab: (<SetResourceRequest>action).payload.resource
}; };
default: default:
return state; return state;
......
import { SagaIterator } from 'redux-saga'; 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 { ResourceType } from 'interfaces/Resources';
import * as API from './api/v0'; import * as API from './api/v0';
import { import {
LoadPreviousSearch,
LoadPreviousSearchRequest,
SearchAll, SearchAll,
SearchAllRequest, SearchAllRequest,
SearchResource, SearchResource,
SearchResourceRequest, SearchResourceRequest,
SetPageIndex,
SetPageIndexRequest, SetResource, SetResourceRequest,
SubmitSearch,
SubmitSearchRequest,
UrlDidUpdate,
UrlDidUpdateRequest,
} from './types'; } from './types';
import { import {
initialState, searchAllSuccess, searchAllFailure, initialState,
searchResourceSuccess, searchResourceFailure, searchAll,
searchAllFailure,
searchAllSuccess,
searchResource,
searchResourceFailure,
searchResourceSuccess, setPageIndex, setResource,
} from './reducer'; } from './reducer';
import { getPageIndex, getSearchState } from './utils';
import { updateSearchUrl } from 'utils/navigation-utils';
export function* searchAllWorker(action: SearchAllRequest): SagaIterator { export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
const { resource, pageIndex, term } = action.payload; const { resource, pageIndex, term } = action.payload;
...@@ -35,8 +51,12 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator { ...@@ -35,8 +51,12 @@ export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
tables: tableResponse.tables || initialState.tables, tables: tableResponse.tables || initialState.tables,
users: userResponse.users || initialState.users, users: userResponse.users || initialState.users,
dashboards: dashboardResponse.dashboards || initialState.dashboards, dashboards: dashboardResponse.dashboards || initialState.dashboards,
isLoading: false,
}; };
const index = getPageIndex(searchAllResponse, resource);
yield put(searchAllSuccess(searchAllResponse)); yield put(searchAllSuccess(searchAllResponse));
updateSearchUrl({ term, resource, index, }, true);
} catch (e) { } catch (e) {
yield put(searchAllFailure()); yield put(searchAllFailure());
} }
...@@ -53,7 +73,78 @@ export function* searchResourceWorker(action: SearchResourceRequest): SagaIterat ...@@ -53,7 +73,78 @@ export function* searchResourceWorker(action: SearchResourceRequest): SagaIterat
} catch (e) { } catch (e) {
yield put(searchResourceFailure()); yield put(searchResourceFailure());
} }
} };
export function* searchResourceWatcher(): SagaIterator { export function* searchResourceWatcher(): SagaIterator {
yield takeEvery(SearchResource.REQUEST, searchResourceWorker); 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);
};
import { Search as UrlSearch } from 'history';
import { import {
DashboardResource, DashboardResource,
Resource, Resource,
...@@ -28,6 +30,7 @@ export interface SearchAllResponsePayload extends SearchResponsePayload { ...@@ -28,6 +30,7 @@ export interface SearchAllResponsePayload extends SearchResponsePayload {
users: UserSearchResults; users: UserSearchResults;
}; };
export enum SearchAll { export enum SearchAll {
REQUEST = 'amundsen/search/SEARCH_ALL_REQUEST', REQUEST = 'amundsen/search/SEARCH_ALL_REQUEST',
SUCCESS = 'amundsen/search/SEARCH_ALL_SUCCESS', SUCCESS = 'amundsen/search/SEARCH_ALL_SUCCESS',
...@@ -48,7 +51,8 @@ export interface SearchAllResponse { ...@@ -48,7 +51,8 @@ export interface SearchAllResponse {
}; };
export interface SearchAllReset { export interface SearchAllReset {
type: SearchAll.RESET; type: SearchAll.RESET;
} };
export enum SearchResource { export enum SearchResource {
REQUEST = 'amundsen/search/SEARCH_RESOURCE_REQUEST', REQUEST = 'amundsen/search/SEARCH_RESOURCE_REQUEST',
...@@ -68,12 +72,56 @@ export interface SearchResourceResponse { ...@@ -68,12 +72,56 @@ export interface SearchResourceResponse {
payload?: SearchResponsePayload; payload?: SearchResponsePayload;
}; };
export enum UpdateSearchTab {
REQUEST = 'amundsen/search/UPDATE_SEARCH_TAB_REQUEST', export enum SubmitSearch {
} REQUEST = 'amundsen/search/SUBMIT_SEARCH_REQUEST',
export interface UpdateSearchTabRequest { };
type: UpdateSearchTab.REQUEST; export interface SubmitSearchRequest {
payload: { 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 ...@@ -25,8 +25,8 @@ export function getMockRouterProps<P>(data: P, location: Partial<History.Locatio
length: 2, length: 2,
action: 'POP', action: 'POP',
location: mockLocation, location: mockLocation,
push: () => {}, push: null,
replace: () => {}, replace: null,
go: null, go: null,
goBack: null, goBack: null,
goForward: null, goForward: null,
......
...@@ -6,7 +6,7 @@ import createSagaMiddleware from 'redux-saga'; ...@@ -6,7 +6,7 @@ import createSagaMiddleware from 'redux-saga';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from '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 DocumentTitle from 'react-document-title';
import AnnouncementPage from './components/AnnouncementPage'; import AnnouncementPage from './components/AnnouncementPage';
...@@ -23,6 +23,7 @@ import TableDetail from './components/TableDetail'; ...@@ -23,6 +23,7 @@ import TableDetail from './components/TableDetail';
import rootReducer from './ducks/rootReducer'; import rootReducer from './ducks/rootReducer';
import rootSaga from './ducks/rootSaga'; import rootSaga from './ducks/rootSaga';
import { BrowserHistory } from 'utils/navigation-utils';
const sagaMiddleware = createSagaMiddleware(); const sagaMiddleware = createSagaMiddleware();
const createStoreWithMiddleware = applyMiddleware(ReduxPromise, sagaMiddleware)(createStore); const createStoreWithMiddleware = applyMiddleware(ReduxPromise, sagaMiddleware)(createStore);
...@@ -33,7 +34,7 @@ sagaMiddleware.run(rootSaga); ...@@ -33,7 +34,7 @@ sagaMiddleware.run(rootSaga);
ReactDOM.render( ReactDOM.render(
<DocumentTitle title="Amundsen - Data Discovery Portal"> <DocumentTitle title="Amundsen - Data Discovery Portal">
<Provider store={store}> <Provider store={store}>
<BrowserRouter> <Router history={BrowserHistory}>
<div id="main"> <div id="main">
<Preloader/> <Preloader/>
<NavBar /> <NavBar />
...@@ -49,7 +50,7 @@ ReactDOM.render( ...@@ -49,7 +50,7 @@ ReactDOM.render(
<Feedback /> <Feedback />
<Footer /> <Footer />
</div> </div>
</BrowserRouter> </Router>
</Provider> </Provider>
</DocumentTitle> </DocumentTitle>
, document.getElementById('content') || document.createElement('div'), , 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 = { ...@@ -39,6 +39,7 @@ const config: webpack.Configuration = {
config: path.join(__dirname, '/js/config'), config: path.join(__dirname, '/js/config'),
ducks: path.join(__dirname, '/js/ducks'), ducks: path.join(__dirname, '/js/ducks'),
interfaces: path.join(__dirname, '/js/interfaces'), interfaces: path.join(__dirname, '/js/interfaces'),
utils: path.join(__dirname, '/js/utils'),
}, },
extensions: ['.tsx', '.ts', '.js', '.jsx', '.css', '.scss'], 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