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,127 +68,15 @@ 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;
if (this.props.location.search !== prevProps.location.search) {
this.props.urlDidUpdate(this.props.location.search);
}
};
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 = () => {
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);
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);
};
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: {
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: {
selectedTab: ResourceType;
}
}
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