Unverified Commit 57f05b10 authored by Tamika Tannis's avatar Tamika Tannis Committed by GitHub

Improve InlineSearch UI (#355)

* Update inline search UI according to design QA

* Update tests

* Address some comments
parent 5a643fcd
import * as React from 'react'; import * as React from 'react';
import { connect } from 'react-redux'
import { logClick } from 'ducks/utilMethods'; import { logClick } from 'ducks/utilMethods';
import { ResourceType } from 'interfaces'; import { ResourceType } from 'interfaces';
export interface SearchItemProps { import LoadingSpinner from 'components/common/LoadingSpinner';
import { GlobalState } from 'ducks/rootReducer'
import {
SEARCH_ITEM_NO_RESULTS
} from 'components/common/SearchBar/InlineSearchResults/constants';
export interface StateFromProps {
isLoading: boolean;
hasResults: boolean;
}
export interface OwnProps {
listItemText: string; listItemText: string;
onItemSelect: (resourceType: ResourceType, updateUrl: boolean) => void; onItemSelect: (resourceType: ResourceType, updateUrl: boolean) => void;
searchTerm: string; searchTerm: string;
resourceType: ResourceType; resourceType: ResourceType;
} }
class SearchItem extends React.Component<SearchItemProps, {}> { export type SearchItemProps = StateFromProps & OwnProps;
export class SearchItem extends React.Component<SearchItemProps, {}> {
constructor(props) { constructor(props) {
super(props); super(props);
} }
...@@ -20,6 +36,20 @@ class SearchItem extends React.Component<SearchItemProps, {}> { ...@@ -20,6 +36,20 @@ class SearchItem extends React.Component<SearchItemProps, {}> {
this.props.onItemSelect(this.props.resourceType, true); this.props.onItemSelect(this.props.resourceType, true);
} }
renderIndicator = () => {
if (this.props.isLoading) {
return (<LoadingSpinner/>)
}
if (!this.props.hasResults) {
return (
<div className="search-item-indicator body-placeholder">
{ SEARCH_ITEM_NO_RESULTS }
</div>
)
}
return null;
}
render = () => { render = () => {
const { searchTerm, listItemText, resourceType } = this.props; const { searchTerm, listItemText, resourceType } = this.props;
return ( return (
...@@ -35,10 +65,29 @@ class SearchItem extends React.Component<SearchItemProps, {}> { ...@@ -35,10 +65,29 @@ class SearchItem extends React.Component<SearchItemProps, {}> {
<div className="search-term">{`${searchTerm}\u00a0`}</div> <div className="search-term">{`${searchTerm}\u00a0`}</div>
<div className="search-item-text">{listItemText}</div> <div className="search-item-text">{listItemText}</div>
</div> </div>
{ this.renderIndicator() }
</a> </a>
</li> </li>
); );
} }
}; };
export default SearchItem; export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
const { isLoading, tables, users } = state.search.inlineResults;
let hasResults = false;
switch (ownProps.resourceType) {
case ResourceType.table:
hasResults = tables.results.length > 0;
break;
case ResourceType.user:
hasResults = users.results.length > 0;
default:
break;
}
return {
isLoading,
hasResults
};
};
export default connect<{}, {}, OwnProps>(mapStateToProps)(SearchItem);
...@@ -2,10 +2,19 @@ import * as React from 'react'; ...@@ -2,10 +2,19 @@ import * as React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import SearchItem, { SearchItemProps } from '../'; import LoadingSpinner from 'components/common/LoadingSpinner';
import { SearchItem, SearchItemProps, mapStateToProps } from '../';
import {
SEARCH_ITEM_NO_RESULTS
} from 'components/common/SearchBar/InlineSearchResults/constants';
import { ResourceType } from 'interfaces'; import { ResourceType } from 'interfaces';
import { GlobalState } from 'ducks/rootReducer'
import globalState from 'fixtures/globalState';
import { allResourcesExample, isLoadingExample, noResultsExample } from 'fixtures/search/inlineResults';
import { logClick } from 'ducks/utilMethods'; import { logClick } from 'ducks/utilMethods';
jest.mock('ducks/utilMethods', () => ( jest.mock('ducks/utilMethods', () => (
{ {
...@@ -20,6 +29,8 @@ describe('SearchItem', () => { ...@@ -20,6 +29,8 @@ describe('SearchItem', () => {
onItemSelect: jest.fn(), onItemSelect: jest.fn(),
searchTerm: 'test search', searchTerm: 'test search',
resourceType: ResourceType.table, resourceType: ResourceType.table,
isLoading: false,
hasResults: true,
...propOverrides ...propOverrides
}; };
const wrapper = shallow<SearchItem>(<SearchItem {...props} />); const wrapper = shallow<SearchItem>(<SearchItem {...props} />);
...@@ -35,13 +46,37 @@ describe('SearchItem', () => { ...@@ -35,13 +46,37 @@ describe('SearchItem', () => {
}) })
}); });
describe('renderIndicator', () => {
it('renders LoadingSpinner if props.isLoading', () => {
const { props, wrapper } = setup({ isLoading: true });
const content = shallow(<div>{wrapper.instance().renderIndicator()}</div>);
expect(content.find(LoadingSpinner).exists()).toBe(true);
});
it('renders correct text if !props.hasResults', () => {
const { props, wrapper } = setup({ hasResults: false });
const content = shallow(wrapper.instance().renderIndicator());
expect(content.text()).toBe(SEARCH_ITEM_NO_RESULTS);
});
it('renders nothing if !props.Loading and props.hasResults', () => {
const { props, wrapper } = setup({ isLoading: false, hasResults: true});
expect(wrapper.instance().renderIndicator()).toBe(null);
});
});
describe('render', () => { describe('render', () => {
let props; let props;
let wrapper; let wrapper;
let renderIndicatorSpy;
let mockContent;
beforeAll(() => { beforeAll(() => {
const setUpResult = setup(); const setUpResult = setup();
props = setUpResult.props; props = setUpResult.props;
wrapper = setUpResult.wrapper; wrapper = setUpResult.wrapper;
mockContent = (<div>Hello</div>);
renderIndicatorSpy = jest.spyOn(wrapper.instance(), 'renderIndicator').mockImplementation(() => mockContent);
wrapper.instance().forceUpdate();
}); });
describe('renders list item link', () => { describe('renders list item link', () => {
...@@ -64,8 +99,81 @@ describe('SearchItem', () => { ...@@ -64,8 +99,81 @@ describe('SearchItem', () => {
}); });
it('renders correct text', () => { it('renders correct text', () => {
expect(listItemLink.text()).toEqual(`${props.searchTerm}\u00a0${props.listItemText}`); expect(listItemLink.find('.search-item-info').text()).toEqual(`${props.searchTerm}\u00a0${props.listItemText}`);
}); });
it('renders results of renderIndicator', () => {
expect(listItemLink.children().at(2).html()).toEqual("<div>Hello</div>");
});
});
});
it('calls renderIndicator', () => {
renderIndicatorSpy.mockClear();
wrapper.instance().render();
expect(renderIndicatorSpy).toHaveBeenCalledTimes(1);
});
});
describe('mapStateToProps', () => {
let result;
let ownProps;
const mockLoadingState: GlobalState = {
...globalState,
search: {
...globalState.search,
inlineResults: isLoadingExample,
}
};
const mockAllResultsState: GlobalState = {
...globalState,
search: {
...globalState.search,
// @ts-ignore: https://github.com/microsoft/TypeScript/issues/10570
inlineResults: allResourcesExample,
}
};
const mockNoResultsState: GlobalState = {
...globalState,
search: {
...globalState.search,
inlineResults: noResultsExample,
}
};
it('sets isLoading on the props', () => {
ownProps = setup().props;
result = mapStateToProps(mockLoadingState, ownProps);
expect(result.isLoading).toEqual(true);
});
describe('ownProps.resourceType is ResourceType.table', () => {
beforeAll(() => {
ownProps = setup({resourceType: ResourceType.table}).props;
});
it('sets hasResults to true if there are table results', () => {
result = mapStateToProps(mockAllResultsState, ownProps);
expect(result.hasResults).toEqual(true);
});
it('sets hasResults to false if there are no table results', () => {
result = mapStateToProps(mockNoResultsState, ownProps);
expect(result.hasResults).toEqual(false);
});
});
describe('ownProps.resourceType is ResourceType.user', () => {
beforeAll(() => {
ownProps = setup({resourceType: ResourceType.user}).props;
})
it('sets hasResults to true if there are user results', () => {
result = mapStateToProps(mockAllResultsState, ownProps);
expect(result.hasResults).toEqual(true);
});
it('sets hasResults to false if there are no user results', () => {
result = mapStateToProps(mockNoResultsState, ownProps);
expect(result.hasResults).toEqual(false);
}); });
}); });
}); });
......
...@@ -12,6 +12,14 @@ import * as CONSTANTS from '../../constants'; ...@@ -12,6 +12,14 @@ import * as CONSTANTS from '../../constants';
jest.mock('config/config-utils', () => ({ indexUsersEnabled: jest.fn() })); jest.mock('config/config-utils', () => ({ indexUsersEnabled: jest.fn() }));
import { indexUsersEnabled } from 'config/config-utils'; import { indexUsersEnabled } from 'config/config-utils';
jest.mock("react-redux", () => {
return {
connect: (mapStateToProps, mapDispatchToProps) => (
SearchItem
) => SearchItem
};
});
describe('SearchItemList', () => { describe('SearchItemList', () => {
const setup = (propOverrides?: Partial<SearchItemListProps>) => { const setup = (propOverrides?: Partial<SearchItemListProps>) => {
const props: SearchItemListProps = { const props: SearchItemListProps = {
......
...@@ -11,3 +11,5 @@ export const USER_ICON_CLASS = "icon-users"; ...@@ -11,3 +11,5 @@ export const USER_ICON_CLASS = "icon-users";
export const RESULT_LIST_FOOTER_PREFIX = "See all"; export const RESULT_LIST_FOOTER_PREFIX = "See all";
export const RESULT_LIST_FOOTER_SUFFIX = "results"; export const RESULT_LIST_FOOTER_SUFFIX = "results";
export const SEARCH_ITEM_NO_RESULTS = "No results found";
import * as React from 'react'; import * as React from 'react';
import { connect } from 'react-redux' import { connect } from 'react-redux'
import LoadingSpinner from 'components/common/LoadingSpinner';
import SearchItemList from './SearchItemList'; import SearchItemList from './SearchItemList';
import ResultItemList from './ResultItemList'; import ResultItemList from './ResultItemList';
...@@ -153,13 +152,17 @@ export class InlineSearchResults extends React.Component<InlineSearchResultsProp ...@@ -153,13 +152,17 @@ export class InlineSearchResults extends React.Component<InlineSearchResultsProp
}; };
renderResultsByResource = (resourceType: ResourceType) => { renderResultsByResource = (resourceType: ResourceType) => {
const suggestedResults = this.getSuggestedResultsForResource(resourceType);
if (suggestedResults.length === 0) {
return null;
}
return ( return (
<div className="inline-results-section"> <div className="inline-results-section">
<ResultItemList <ResultItemList
onItemSelect={this.props.onItemSelect} onItemSelect={this.props.onItemSelect}
resourceType={resourceType} resourceType={resourceType}
searchTerm={this.props.searchTerm} searchTerm={this.props.searchTerm}
suggestedResults={this.getSuggestedResultsForResource(resourceType)} suggestedResults={suggestedResults}
totalResults={this.getTotalResultsForResource(resourceType)} totalResults={this.getTotalResultsForResource(resourceType)}
title={this.getTitleForResource(resourceType)} title={this.getTitleForResource(resourceType)}
/> />
...@@ -169,11 +172,7 @@ export class InlineSearchResults extends React.Component<InlineSearchResultsProp ...@@ -169,11 +172,7 @@ export class InlineSearchResults extends React.Component<InlineSearchResultsProp
renderResults = () => { renderResults = () => {
if (this.props.isLoading) { if (this.props.isLoading) {
return ( return null;
<div className="inline-results-section">
<LoadingSpinner/>
</div>
);
} }
return ( return (
<> <>
...@@ -190,7 +189,7 @@ export class InlineSearchResults extends React.Component<InlineSearchResultsProp ...@@ -190,7 +189,7 @@ export class InlineSearchResults extends React.Component<InlineSearchResultsProp
const { className = '', onItemSelect, searchTerm } = this.props; const { className = '', onItemSelect, searchTerm } = this.props;
return ( return (
<div id="inline-results" className={`inline-results ${className}`}> <div id="inline-results" className={`inline-results ${className}`}>
<div className="inline-results-section"> <div className="inline-results-section search-item-section">
<SearchItemList <SearchItemList
onItemSelect={onItemSelect} onItemSelect={onItemSelect}
searchTerm={searchTerm} searchTerm={searchTerm}
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
.result-item-link { .result-item-link {
padding-left: 20px !important; padding-left: 20px !important;
padding-right: 20px !important;
} }
.section-footer.title-3, .section-footer.title-3,
...@@ -25,17 +26,19 @@ ...@@ -25,17 +26,19 @@
} }
} }
.inline-results-section { .search-item-section {
padding-top: 8px; padding-top: $spacer-1;
padding-bottom: 8px; padding-bottom: $spacer-1;
}
.inline-results-section {
&:not(:last-of-type) { &:not(:last-of-type) {
border-bottom: 1px solid $stroke; border-bottom: 1px solid $stroke;
} }
.section-title, .section-title,
.section-footer { .section-footer {
margin-left: 24px; margin: $spacer-1 $spacer-3;
} }
a.section-footer { a.section-footer {
...@@ -90,12 +93,26 @@ ...@@ -90,12 +93,26 @@
font-weight: $font-weight-body-regular; font-weight: $font-weight-body-regular;
} }
} }
.loading-spinner {
width: 24px;
height: 24px;
}
.loading-spinner,
.search-item-indicator {
margin: auto 0 auto $spacer-1;
}
.search-item-indicator {
font-style: italic;
}
} }
/* RESULT ITEM */ /* RESULT ITEM */
.result-item-link { .result-item-link {
height: 56px; height: 56px;
padding: 8px 8px 8px 32px; padding: 8px 32px;
.result-info { .result-info {
display: flex; display: flex;
flex: 1; flex: 1;
...@@ -109,9 +126,4 @@ ...@@ -109,9 +126,4 @@
} }
} }
} }
.loading-spinner {
margin-top: 5%;
margin-bottom: 5%;
}
} }
...@@ -309,44 +309,70 @@ describe('InlineSearchResults', () => { ...@@ -309,44 +309,70 @@ describe('InlineSearchResults', () => {
let getTotalResultsForResourceSpy; let getTotalResultsForResourceSpy;
let mockTitle; let mockTitle;
let getTitleForResourceSpy; let getTitleForResourceSpy;
beforeAll(() => {
const setupResult = setup(); describe('if results do not exist', () => {
props = setupResult.props; beforeAll(() => {
wrapper = setupResult.wrapper; const setupResult = setup();
mockResults = [ props = setupResult.props;
{ href: '/test', iconClass: 'test-class', subtitle: 'subtitle', title: 'title', type: 'User' }, wrapper = setupResult.wrapper;
{ href: '/test2', iconClass: 'test-class2', subtitle: 'subtitle2', title: 'title2', type: 'User' }, mockResults = []
] getSuggestedResultsForResourceSpy = jest.spyOn(wrapper.instance(), 'getSuggestedResultsForResource').mockImplementation(() => mockResults);
getSuggestedResultsForResourceSpy = jest.spyOn(wrapper.instance(), 'getSuggestedResultsForResource').mockImplementation(() => mockResults); wrapper.instance().forceUpdate();
mockTotal = 65; });
getTotalResultsForResourceSpy = jest.spyOn(wrapper.instance(), 'getTotalResultsForResource').mockImplementation(() => mockTotal);
mockTitle = 'Datasets'; it('calls helper methods with given resourceType', () => {
getTitleForResourceSpy = jest.spyOn(wrapper.instance(), 'getTitleForResource').mockImplementation(() => mockTitle); getSuggestedResultsForResourceSpy.mockClear();
wrapper.instance().forceUpdate(); const givenResourceType = ResourceType.dashboard;
wrapper.instance().renderResultsByResource(givenResourceType);
expect(getSuggestedResultsForResourceSpy).toHaveBeenCalledWith(givenResourceType);
});
it('renders nothing', () => {
const givenResourceType = ResourceType.dashboard;
expect(wrapper.instance().renderResultsByResource(givenResourceType)).toBe(null);
});
}); });
it('calls helper methods with given resourceType', () => { describe('if results exist', () => {
getSuggestedResultsForResourceSpy.mockClear(); beforeAll(() => {
getTotalResultsForResourceSpy.mockClear(); const setupResult = setup();
getTitleForResourceSpy.mockClear(); props = setupResult.props;
const givenResourceType = ResourceType.dashboard; wrapper = setupResult.wrapper;
wrapper.instance().renderResultsByResource(givenResourceType); mockResults = [
expect(getSuggestedResultsForResourceSpy).toHaveBeenCalledWith(givenResourceType); { href: '/test', iconClass: 'test-class', subtitle: 'subtitle', title: 'title', type: 'User' },
expect(getTotalResultsForResourceSpy).toHaveBeenCalledWith(givenResourceType); { href: '/test2', iconClass: 'test-class2', subtitle: 'subtitle2', title: 'title2', type: 'User' },
expect(getTitleForResourceSpy).toHaveBeenCalledWith(givenResourceType); ]
}); getSuggestedResultsForResourceSpy = jest.spyOn(wrapper.instance(), 'getSuggestedResultsForResource').mockImplementation(() => mockResults);
mockTotal = 65;
it('renders ResultItemList with expected props', () => { getTotalResultsForResourceSpy = jest.spyOn(wrapper.instance(), 'getTotalResultsForResource').mockImplementation(() => mockTotal);
const givenResourceType = ResourceType.dashboard; mockTitle = 'Datasets';
const content = shallow(wrapper.instance().renderResultsByResource(givenResourceType)); getTitleForResourceSpy = jest.spyOn(wrapper.instance(), 'getTitleForResource').mockImplementation(() => mockTitle);
const item = content.find('.inline-results-section').find(ResultItemList); wrapper.instance().forceUpdate();
const itemProps = item.props(); });
expect(itemProps.onItemSelect).toEqual(props.onItemSelect);
expect(itemProps.resourceType).toEqual(givenResourceType); it('calls helper methods with given resourceType', () => {
expect(itemProps.suggestedResults).toEqual(mockResults); getSuggestedResultsForResourceSpy.mockClear();
expect(itemProps.totalResults).toEqual(mockTotal); getTotalResultsForResourceSpy.mockClear();
expect(itemProps.title).toEqual(mockTitle); getTitleForResourceSpy.mockClear();
}) const givenResourceType = ResourceType.dashboard;
wrapper.instance().renderResultsByResource(givenResourceType);
expect(getSuggestedResultsForResourceSpy).toHaveBeenCalledWith(givenResourceType);
expect(getTotalResultsForResourceSpy).toHaveBeenCalledWith(givenResourceType);
expect(getTitleForResourceSpy).toHaveBeenCalledWith(givenResourceType);
});
it('renders ResultItemList with expected props', () => {
const givenResourceType = ResourceType.dashboard;
const content = shallow(wrapper.instance().renderResultsByResource(givenResourceType));
const item = content.find('.inline-results-section').find(ResultItemList);
const itemProps = item.props();
expect(itemProps.onItemSelect).toEqual(props.onItemSelect);
expect(itemProps.resourceType).toEqual(givenResourceType);
expect(itemProps.suggestedResults).toEqual(mockResults);
expect(itemProps.totalResults).toEqual(mockTotal);
expect(itemProps.title).toEqual(mockTitle);
});
});
}); });
describe('renderResults', () => { describe('renderResults', () => {
...@@ -358,10 +384,9 @@ describe('InlineSearchResults', () => { ...@@ -358,10 +384,9 @@ describe('InlineSearchResults', () => {
wrapper.update(); wrapper.update();
}); });
it('renders a LoadingSpinner when props.isLoading', () => { it('does not render anything when props.isLoading', () => {
const wrapper = setup({isLoading: true}).wrapper; const wrapper = setup({isLoading: true}).wrapper;
const content = shallow(wrapper.instance().renderResults()); expect(wrapper.instance().renderResults()).toBe(null);
expect(content.find(LoadingSpinner).exists()).toBe(true);
}); });
describe('when !props.isLoading', () => { describe('when !props.isLoading', () => {
......
...@@ -14,6 +14,20 @@ export const isLoadingExample = { ...@@ -14,6 +14,20 @@ export const isLoadingExample = {
}, },
}; };
export const noResultsExample = {
isLoading: false,
tables: {
page_index: 0,
results: [],
total_results: 0,
},
users: {
page_index: 0,
results: [],
total_results: 0,
},
};
export const allResourcesExample = { export const allResourcesExample = {
isLoading: false, isLoading: false,
tables: { tables: {
......
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