Unverified Commit e355bca3 authored by Tamika Tannis's avatar Tamika Tannis Committed by GitHub

Initial inline results feature (#321)

* Initial inline results feature

* Use getDerivedStateFromProps; Cleanup some styles

* Update UI; Rename method

* Cleanup behavior

* Unit tests

* Fix some tests

* Inline Search Improvements + Logging (#351)

* Combine debounce (to minimize requests) with takeLatest (for better UI)

* Log click interactions with inline search

* Mock logClick in tests

* Lint fix

* Address some comments

* Update a test
parent 824016b5
......@@ -12,7 +12,7 @@ import { Dropdown } from 'react-bootstrap';
import { LoggedInUser } from 'interfaces';
import { feedbackEnabled } from 'config/config-utils';
import { feedbackEnabled, indexUsersEnabled } from 'config/config-utils';
import Feedback from 'components/Feedback';
import SearchBar from 'components/common/SearchBar';
......@@ -34,10 +34,10 @@ export class NavBar extends React.Component<NavBarProps> {
generateNavLinks(navLinks: LinkConfig[]) {
return navLinks.map((link, index) => {
if (link.use_router) {
return <NavLink className="title-3" key={index} to={link.href} target={link.target}
return <NavLink className="title-3 border-bottom-white" key={index} to={link.href} target={link.target}
onClick={logClick}>{link.label}</NavLink>
}
return <a className="title-3" key={index} href={link.href} target={link.target}
return <a className="title-3 border-bottom-white" key={index} href={link.href} target={link.target}
onClick={logClick}>{link.label}</a>
});
}
......@@ -59,7 +59,7 @@ export class NavBar extends React.Component<NavBarProps> {
<div className="row">
<div className="nav-bar">
<div id="nav-bar-left" className="nav-bar-left">
<Link to={`/`} className="no-border-on-hover">
<Link to={`/`}>
{
AppConfig.logoPath &&
<img id="logo-icon" className="logo-icon" src={AppConfig.logoPath} />
......@@ -75,9 +75,9 @@ export class NavBar extends React.Component<NavBarProps> {
<Feedback />
}
{
this.props.loggedInUser && AppConfig.indexUsers.enabled &&
this.props.loggedInUser && indexUsersEnabled() &&
<Dropdown id='user-dropdown' pullRight={true}>
<Dropdown.Toggle noCaret={true} className="avatar-dropdown">
<Dropdown.Toggle noCaret={true} className="nav-bar-avatar avatar-dropdown">
<Avatar name={this.props.loggedInUser.display_name} size={32} round={true} />
</Dropdown.Toggle>
<Dropdown.Menu className='profile-menu'>
......@@ -86,7 +86,7 @@ export class NavBar extends React.Component<NavBarProps> {
<div>{this.props.loggedInUser.email}</div>
</div>
<li>
<Link id="nav-bar-avatar-link" className="no-border-on-hover" to={`/user/${this.props.loggedInUser.user_id}?source=navbar`}>
<Link id="nav-bar-avatar-link" to={`/user/${this.props.loggedInUser.user_id}?source=navbar`}>
My Profile
</Link>
</li>
......@@ -94,8 +94,8 @@ export class NavBar extends React.Component<NavBarProps> {
</Dropdown>
}
{
this.props.loggedInUser && !AppConfig.indexUsers.enabled &&
<div id="nav-bar-avatar" className="nav-bar-avatar">
this.props.loggedInUser && !indexUsersEnabled() &&
<div className="nav-bar-avatar">
<Avatar name={this.props.loggedInUser.display_name} size={32} round={true} />
</div>
}
......
......@@ -2,72 +2,62 @@
.nav-bar {
height: $nav-bar-height;
background: $nav-bar-color;
padding: 0px 32px 1px 32px;
display: flex;
flex-direction: row;
align-items: center;
.title-3 {
.nav-bar-left {
flex-basis: 234px;
span.title-3 {
color: $white;
}
}
.nav-bar-right {
margin-left: auto;
display: flex;
a {
color: $white;
display: inline-block;
height: 100%;
line-height: 32px;
text-decoration: none;
color: $white;
padding-left: 8px;
padding-right: 8px;
padding: 0 8px;
&:hover:not(.nav-bar-avatar-link),
&.active {
&.border-bottom-white:hover,
&.border-bottom-white.active {
border-bottom: 4px solid $white;
}
}
&.no-border-on-hover {
&:hover {
/* override border-bottom style */
border-bottom: none;
.nav-bar-avatar {
&:not(.avatar-dropdown) {
.sb-avatar {
margin: 4px 0 0 4px;
}
}
&.nav-bar-avatar-link {
background-color: $nav-bar-color;
border-style: none;
border-radius: 50%;
margin-top: -4px;
padding-left: 4px;
padding-right: 4px;
.nav-bar-avatar {
padding: 0;
height: 40px;
width: 40px;
padding: 4px;
&:hover {
/* override border-bottom style */
border-bottom: none;
/* circular background */
background-color: $white;
border-radius: 50%;
}
}
}
}
.nav-bar-left {
flex-basis: 234px;
}
.nav-bar-right {
margin-left: auto;
display: flex;
}
.nav-bar-left,
.nav-bar-right {
height: 100%;
padding-top: 12px;
a {
text-decoration: none;
}
}
.nav-bar-right > *:not(:last-child) {
......@@ -78,44 +68,14 @@
flex-grow: 1;
margin: auto 16px auto auto;
}
}
.logo-icon {
.logo-icon {
max-height: 32px;
max-width: 144px;
margin-right: 16px;
}
.avatar-dropdown {
border-style: none;
padding: 0 !important;
border-radius: 50%;
}
.profile-menu {
$profile-menu-width: 200px;
width: $profile-menu-width;
.profile-menu-header {
padding: 16px 16px 0 16px;
}
li {
padding: 16px;
a {
padding: 0;
}
}
}
.avatar-dropdown {
border-style: none;
padding: 0 !important;
border-radius: 50%;
}
.profile-menu {
.profile-menu {
$profile-menu-width: 200px;
width: $profile-menu-width;
......@@ -125,9 +85,11 @@
li {
padding: 16px;
a {
color: $text-primary;
padding: 0;
width: 100%;
}
}
}
}
......
......@@ -63,7 +63,7 @@ describe('NavBar', () => {
it('returns a NavLink w/ correct props if user_router is true', () => {
const expectedContent = JSON.stringify(
<NavLink className="title-3" key={0} to='/announcements' target='_blank'
<NavLink className="title-3 border-bottom-white" key={0} to='/announcements' target='_blank'
onClick={logClick}>Announcements</NavLink>
);
expect(JSON.stringify(content[0])).toEqual(expectedContent);
......
import * as React from 'react';
import { Link } from 'react-router-dom';
export interface ResultItemProps {
id: string;
href: string;
iconClass: string;
onItemSelect: (event: MouseEvent) => void;
subtitle: string;
title: string;
type: string;
}
const ResultItem: React.SFC<ResultItemProps> = ({ href, iconClass, id, onItemSelect, subtitle, title, type }) => {
return (
<li className="list-group-item">
<Link id={id} className="result-item-link" onClick={onItemSelect} to={ href }>
<img className={`result-icon ${iconClass}`} />
<div className="result-info">
<div className="truncated">
<div className="title-2 truncated">{ title }</div>
<div className="body-secondary-3 truncated">{ subtitle }</div>
</div>
</div>
<div className="resource-type">{ type }</div>
</Link>
</li>
);
};
export default ResultItem;
import * as React from 'react';
import { Link } from 'react-router-dom';
import { shallow } from 'enzyme';
import ResultItem, { ResultItemProps } from '../';
describe('ResultItem', () => {
let props: ResultItemProps;
let subject;
beforeAll(() => {
props = {
id: 'foo',
href: '/test',
iconClass: 'test-icon',
onItemSelect: jest.fn(),
subtitle: 'subtitle',
title: 'title',
type: 'User',
};
subject = shallow(<ResultItem {...props} />);
});
describe('render', () => {
let link;
beforeAll(() => {
link = subject.find(Link);
});
it('renders Link with correct props', () => {
expect(link.props().onClick).toEqual(props.onItemSelect);
expect(link.props().to).toEqual(props.href);
});
it('renders icon with correct props', () => {
expect(link.find('img').props().className).toEqual(`result-icon ${props.iconClass}`);
});
describe('renders result-info', () => {
let resultInfo;
let contentWrapper;
beforeAll(() => {
resultInfo = link.find('.result-info');
contentWrapper = resultInfo.children().at(0);
});
it('truncates the text accordingly', () => {
expect(contentWrapper.props().className).toMatch(/truncated/);
expect(contentWrapper.children().at(0).props().className).toMatch(/truncated/);
expect(contentWrapper.children().at(1).props().className).toMatch(/truncated/);
});
it('renders the title', () => {
expect(contentWrapper.find('.title-2').text()).toEqual(props.title);
});
it('renders the subtitle', () => {
expect(contentWrapper.find('.body-secondary-3').text()).toEqual(props.subtitle);
});
});
it('renders resource-type with correct text', () => {
expect(link.find('.resource-type').text()).toEqual(props.type);
});
});
});
import * as React from 'react';
import { logClick } from 'ducks/utilMethods';
import { ResourceType } from 'interfaces';
import { SuggestedResult } from '../../InlineSearchResults'
import ResultItem from './ResultItem';
import { RESULT_LIST_FOOTER_PREFIX, RESULT_LIST_FOOTER_SUFFIX } from '../constants';
export interface ResultItemListProps {
onItemSelect: (resourceType: ResourceType, updateUrl?: boolean) => void;
resourceType: ResourceType;
searchTerm: string;
suggestedResults: SuggestedResult[];
title: string;
totalResults: number;
}
class ResultItemList extends React.Component<ResultItemListProps, {}> {
constructor(props) {
super(props);
}
generateFooterLinkText = () => {
const { totalResults, title } = this.props;
return `${RESULT_LIST_FOOTER_PREFIX} ${totalResults} ${title} ${RESULT_LIST_FOOTER_SUFFIX}`;
}
onViewAllResults = (e) => {
logClick(e);
this.props.onItemSelect(this.props.resourceType, true);
};
renderResultItems = (results: SuggestedResult[]) => {
const onResultItemSelect = (e) => {
logClick(e);
this.props.onItemSelect(this.props.resourceType);
}
return results.map((item, index) => {
const { href, iconClass, subtitle, title, type } = item;
const id = `inline-resultitem-${this.props.resourceType}:${index}`;
return (
<ResultItem
key={id}
id={id}
href={href}
onItemSelect={onResultItemSelect}
iconClass={`icon icon-dark ${iconClass}`}
subtitle={subtitle}
title={title}
type={type}
/>
)
});
}
render = () => {
const { resourceType, suggestedResults, title } = this.props;
return (
<>
<div className="section-title title-3">{title}</div>
<ul className="list-group">
{ this.renderResultItems(suggestedResults) }
</ul>
<a
id={`inline-resultitem-viewall:${resourceType}`}
className="section-footer title-3"
onClick={this.onViewAllResults}
target='_blank'
>
{ this.generateFooterLinkText() }
</a>
</>
);
}
}
export default ResultItemList;
import * as React from 'react';
import { shallow } from 'enzyme';
import ResultItemList, { ResultItemListProps } from '../';
import { ResourceType } from 'interfaces';
import { RESULT_LIST_FOOTER_PREFIX, RESULT_LIST_FOOTER_SUFFIX } from '../../constants';
import { logClick } from 'ducks/utilMethods';
jest.mock('ducks/utilMethods', () => (
{
logClick: jest.fn(() => {}),
}
));
describe('ResultItemList', () => {
const setup = (propOverrides?: Partial<ResultItemListProps>) => {
const props: ResultItemListProps = {
onItemSelect: jest.fn(),
resourceType: ResourceType.table,
searchTerm: 'test',
suggestedResults: [
{
href: '/test',
iconClass: 'test-icon',
subtitle: 'subtitle',
title: 'title',
type: 'Hive',
},
{
href: '/test2',
iconClass: 'test-icon',
subtitle: 'subtitle2',
title: 'title2',
type: 'Hive',
}
],
title: 'Datasets',
totalResults: 10,
...propOverrides
};
const wrapper = shallow<ResultItemList>(<ResultItemList {...props} />);
return { props, wrapper };
};
describe('generateFooterLinkText', () => {
it('returns the expected text', () => {
const { props, wrapper } = setup();
const output = wrapper.instance().generateFooterLinkText();
expect(output).toEqual(`${RESULT_LIST_FOOTER_PREFIX} ${props.totalResults} ${props.title} ${RESULT_LIST_FOOTER_SUFFIX}`);
})
});
describe('onViewAllResults', () => {
it('calls props.onItemSelect with the correct parameters', () => {
const { props, wrapper } = setup();
const onItemSelectSpy = jest.spyOn(props, 'onItemSelect');
wrapper.instance().onViewAllResults({});
expect(onItemSelectSpy).toHaveBeenCalledWith(props.resourceType, true);
})
});
describe('renderResultItems', () => {
it('renders a ResultItem for item in the given results list', () => {
const { props, wrapper } = setup();
const listItems = wrapper.instance().renderResultItems(props.suggestedResults);
const expectedOnItemSelect = props.onItemSelect(props.resourceType);
listItems.forEach((item, index) => {
const { href, iconClass, subtitle, title, type } = props.suggestedResults[index];
expect(item.props.href).toBe(href);
expect(item.props.onItemSelect()).toBe(expectedOnItemSelect);
expect(item.props.iconClass).toBe(`icon icon-dark ${iconClass}`);
expect(item.props.subtitle).toBe(subtitle);
expect(item.props.title).toBe(title);
expect(item.props.type).toBe(type);
});
})
});
describe('render', () => {
let props;
let wrapper;
let generateFooterLinkTextSpy;
let renderResultItemsSpy;
beforeAll(() => {
const setUpResult = setup();
props = setUpResult.props;
wrapper = setUpResult.wrapper;
generateFooterLinkTextSpy = jest.spyOn(wrapper.instance(), 'generateFooterLinkText').mockImplementation(() => 'I am footer');
renderResultItemsSpy = jest.spyOn(wrapper.instance(), 'renderResultItems').mockImplementation(() => (<li key='test'>Hello</li>));
wrapper.instance().forceUpdate();
});
describe('renders the title', () => {
let title;
beforeAll(() => {
title = wrapper.find('.section-title')
});
it('with correct text', () => {
expect(title.text()).toEqual(props.title);
});
it('with correct class', () => {
expect(title.hasClass('title-3')).toBe(true);
});
});
it('renders result items', () => {
expect(renderResultItemsSpy).toHaveBeenCalledWith(props.suggestedResults);
});
describe('renders the footer', () => {
let footer;
beforeAll(() => {
footer = wrapper.find('.section-footer');
});
it('with correct text', () => {
expect(generateFooterLinkTextSpy).toHaveBeenCalled();
expect(footer.text()).toEqual('I am footer');
});
it('with correct class', () => {
expect(footer.hasClass('title-3')).toBe(true);
});
it('with correct onClick interaction', () => {
expect(footer.props().onClick).toEqual(wrapper.instance().onViewAllResults)
});
});
});
});
import * as React from 'react';
import { logClick } from 'ducks/utilMethods';
import { ResourceType } from 'interfaces';
export interface SearchItemProps {
listItemText: string;
onItemSelect: (resourceType: ResourceType, updateUrl: boolean) => void;
searchTerm: string;
resourceType: ResourceType;
}
class SearchItem extends React.Component<SearchItemProps, {}> {
constructor(props) {
super(props);
}
onViewAllResults = (e) => {
logClick(e);
this.props.onItemSelect(this.props.resourceType, true);
}
render = () => {
const { searchTerm, listItemText, resourceType } = this.props;
return (
<li className="list-group-item">
<a
id={`inline-searchitem-viewall:${resourceType}`}
className="search-item-link"
onClick={this.onViewAllResults}
target='_blank'
>
<img className="icon icon-search" />
<div className="title-2 search-item-info">
<div className="search-term">{`${searchTerm}\u00a0`}</div>
<div className="search-item-text">{listItemText}</div>
</div>
</a>
</li>
);
}
};
export default SearchItem;
import * as React from 'react';
import { shallow } from 'enzyme';
import SearchItem, { SearchItemProps } from '../';
import { ResourceType } from 'interfaces';
import { logClick } from 'ducks/utilMethods';
jest.mock('ducks/utilMethods', () => (
{
logClick: jest.fn(() => {}),
}
));
describe('SearchItem', () => {
const setup = (propOverrides?: Partial<SearchItemProps>) => {
const props: SearchItemProps = {
listItemText: 'test',
onItemSelect: jest.fn(),
searchTerm: 'test search',
resourceType: ResourceType.table,
...propOverrides
};
const wrapper = shallow<SearchItem>(<SearchItem {...props} />);
return { props, wrapper };
};
describe('onViewAllResults', () => {
it('calls props.onItemSelect with the correct parameters', () => {
const { props, wrapper } = setup();
const onItemSelectSpy = jest.spyOn(props, 'onItemSelect');
wrapper.instance().onViewAllResults({});
expect(onItemSelectSpy).toHaveBeenCalledWith(props.resourceType, true);
})
});
describe('render', () => {
let props;
let wrapper;
beforeAll(() => {
const setUpResult = setup();
props = setUpResult.props;
wrapper = setUpResult.wrapper;
});
describe('renders list item link', () => {
let listItemLink;
beforeAll(() => {
listItemLink = wrapper.find('li').find('a');
});
it('with correct onClick interaction', () => {
expect(listItemLink.props().onClick).toBe(wrapper.instance().onViewAllResults);
});
it('with correct class', () => {
expect(listItemLink.hasClass('search-item-link')).toBe(true);
});
describe('with correct content', () => {
it('renders an img with correct class', () => {
expect(listItemLink.find('img').props().className).toEqual('icon icon-search');
});
it('renders correct text', () => {
expect(listItemLink.text()).toEqual(`${props.searchTerm}\u00a0${props.listItemText}`);
});
});
});
});
});
import * as React from 'react';
import { indexUsersEnabled } from 'config/config-utils';
import { ResourceType } from 'interfaces';
import SearchItem from './SearchItem';
import * as CONSTANTS from '../constants';
export interface SearchItemListProps {
onItemSelect: (resourceType: ResourceType, updateUrl: boolean) => void;
searchTerm: string;
}
class SearchItemList extends React.Component<SearchItemListProps, {}> {
constructor(props) {
super(props);
}
getListItemText = (resourceType: ResourceType): string => {
switch (resourceType) {
case ResourceType.table:
return CONSTANTS.DATASETS_ITEM_TEXT;
case ResourceType.user:
return CONSTANTS.PEOPLE_ITEM_TEXT;
default:
return '';
}
}
render = () => {
const { onItemSelect, searchTerm } = this.props;
return (
<ul className="list-group">
<SearchItem
listItemText={this.getListItemText(ResourceType.table)}
onItemSelect={onItemSelect}
searchTerm={searchTerm}
resourceType={ResourceType.table}
/>
{
indexUsersEnabled() &&
<SearchItem
listItemText={this.getListItemText(ResourceType.user)}
onItemSelect={onItemSelect}
searchTerm={searchTerm}
resourceType={ResourceType.user}
/>
}
</ul>
);
}
}
export default SearchItemList;
import * as React from 'react';
import { shallow } from 'enzyme';
import SearchItemList, { SearchItemListProps } from '../';
import SearchItem from '../SearchItem';
import { ResourceType } from 'interfaces';
import * as CONSTANTS from '../../constants';
jest.mock('config/config-utils', () => ({ indexUsersEnabled: jest.fn() }));
import { indexUsersEnabled } from 'config/config-utils';
describe('SearchItemList', () => {
const setup = (propOverrides?: Partial<SearchItemListProps>) => {
const props: SearchItemListProps = {
onItemSelect: jest.fn(),
searchTerm: 'test',
...propOverrides
};
const wrapper = shallow<SearchItemList>(<SearchItemList {...props} />);
return { props, wrapper };
};
describe('getListItemText', () => {
let wrapper;
beforeAll(() => {
wrapper = setup().wrapper;
});
it('returns the correct value for ResourceType.table', () => {
const output = wrapper.instance().getListItemText(ResourceType.table);
expect(output).toEqual(CONSTANTS.DATASETS_ITEM_TEXT);
});
it('returns the correct value for ResourceType.user', () => {
const output = wrapper.instance().getListItemText(ResourceType.user);
expect(output).toEqual(CONSTANTS.PEOPLE_ITEM_TEXT);
});
it('returns empty string as the default', () => {
const output = wrapper.instance().getListItemText('unsupported');
expect(output).toEqual('');
});
});
describe('render', () => {
let props;
let wrapper;
let setUpResult;
let getListItemTextSpy;
let mockListItemText;
it('renders a SearchItem for ResourceType.table', () => {
setUpResult = setup();
props = setUpResult.props;
wrapper = setUpResult.wrapper;
mockListItemText = 'Hello'
getListItemTextSpy = jest.spyOn(wrapper.instance(), 'getListItemText').mockImplementation(() => mockListItemText);
wrapper.instance().forceUpdate();
const item = wrapper.find('SearchItem').findWhere(item => item.prop('resourceType') === ResourceType.table);
const itemProps = item.props();
expect(getListItemTextSpy).toHaveBeenCalledWith(ResourceType.table);
expect(itemProps.listItemText).toEqual(mockListItemText);
expect(itemProps.onItemSelect).toEqual(props.onItemSelect);
expect(itemProps.searchTerm).toEqual(props.searchTerm);
expect(itemProps.resourceType).toEqual(ResourceType.table);
});
describe('renders ResourceType.user SearchItem based on config', () =>{
it('when indexUsersEnabled = true, renders SearchItem', () => {
// @ts-ignore: Known issue but can't find solution: https://github.com/kulshekhar/ts-jest/issues/661
indexUsersEnabled.mockImplementation(() => true);
setUpResult = setup();
props = setUpResult.props;
wrapper = setUpResult.wrapper;
mockListItemText = 'Hello'
getListItemTextSpy = jest.spyOn(wrapper.instance(), 'getListItemText').mockImplementation(() => mockListItemText);
wrapper.instance().forceUpdate();
const item = wrapper.find('SearchItem').findWhere(item => item.prop('resourceType') === ResourceType.user);
const itemProps = item.props();
expect(getListItemTextSpy).toHaveBeenCalledWith(ResourceType.user);
expect(itemProps.listItemText).toEqual(mockListItemText);
expect(itemProps.onItemSelect).toEqual(props.onItemSelect);
expect(itemProps.searchTerm).toEqual(props.searchTerm);
expect(itemProps.resourceType).toEqual(ResourceType.user);
});
it('when indexUsersEnabled = false, does not render SearchItem', () => {
// @ts-ignore: Known issue but can't find solution: https://github.com/kulshekhar/ts-jest/issues/661
indexUsersEnabled.mockImplementation(() => false);
wrapper = setup().wrapper;
const item = wrapper.find('SearchItem').findWhere(item => item.prop('resourceType') === ResourceType.user);
expect(item.exists()).toBe(false)
})
});
});
});
// TODO: Consolidate fter implementing filtering. The final resourceConfig can include
// displayNames to avoid re-defining constants 'Datasets' & 'People' across components
export const DATASETS = "Datasets";
export const DATASETS_ITEM_TEXT = `in ${DATASETS}`;
export const PEOPLE = "People";
export const PEOPLE_ITEM_TEXT = `in ${PEOPLE}`;
export const PEOPLE_USER_TYPE = "User";
export const USER_ICON_CLASS = "icon-users";
export const RESULT_LIST_FOOTER_PREFIX = "See all";
export const RESULT_LIST_FOOTER_SUFFIX = "results";
import * as React from 'react';
import { connect } from 'react-redux'
import LoadingSpinner from 'components/common/LoadingSpinner';
import SearchItemList from './SearchItemList';
import ResultItemList from './ResultItemList';
import { getDatabaseDisplayName, getDatabaseIconClass, indexUsersEnabled } from 'config/config-utils';
import { GlobalState } from 'ducks/rootReducer'
import { SearchResults, TableSearchResults, UserSearchResults } from 'ducks/search/types';
import { Resource, ResourceType, TableResource, UserResource } from 'interfaces';
import './styles.scss';
import * as CONSTANTS from './constants';
export interface StateFromProps {
isLoading: boolean;
tables: TableSearchResults;
users: UserSearchResults;
}
export interface OwnProps {
className?: string;
onItemSelect: (resourceType: ResourceType, updateUrl?: boolean) => void;
searchTerm: string;
}
export type InlineSearchResultsProps = StateFromProps & OwnProps;
export interface SuggestedResult {
href: string;
iconClass: string;
subtitle: string;
title: string;
type: string;
}
export class InlineSearchResults extends React.Component<InlineSearchResultsProps, {}> {
constructor(props) {
super(props);
}
getTitleForResource = (resourceType: ResourceType): string => {
switch (resourceType) {
case ResourceType.table:
return CONSTANTS.DATASETS;
case ResourceType.user:
return CONSTANTS.PEOPLE;
default:
return '';
}
};
getTotalResultsForResource = (resourceType: ResourceType) : number => {
switch (resourceType) {
case ResourceType.table:
return this.props.tables.total_results
case ResourceType.user:
return this.props.users.total_results;
default:
return 0;
}
};
getResultsForResource = (resourceType: ResourceType): Resource[] => {
switch (resourceType) {
case ResourceType.table:
return this.props.tables.results.slice(0, 2);
case ResourceType.user:
return this.props.users.results.slice(0, 2);
default:
return [];
}
};
getSuggestedResultsForResource = (resourceType: ResourceType): SuggestedResult[] => {
const results = this.getResultsForResource(resourceType);
return results.map((result) => {
return {
href: this.getSuggestedResultHref(resourceType, result),
iconClass: this.getSuggestedResultIconClass(resourceType, result),
subtitle: this.getSuggestedResultSubTitle(resourceType, result),
title: this.getSuggestedResultTitle(resourceType, result),
type: this.getSuggestedResultType(resourceType, result)
}
});
};
getSuggestedResultHref = (resourceType: ResourceType, result: Resource): string => {
switch (resourceType) {
case ResourceType.table:
const table = result as TableResource;
return `/table_detail/${table.cluster}/${table.database}/${table.schema_name}/${table.name}`;
case ResourceType.user:
const user = result as UserResource;
return `/user/${user.user_id}`;
default:
return '';
}
};
getSuggestedResultIconClass = (resourceType: ResourceType, result: Resource): string => {
switch (resourceType) {
case ResourceType.table:
const table = result as TableResource;
return getDatabaseIconClass(table.database);
case ResourceType.user:
return CONSTANTS.USER_ICON_CLASS;
default:
return '';
}
};
getSuggestedResultSubTitle = (resourceType: ResourceType, result: Resource): string => {
switch (resourceType) {
case ResourceType.table:
const table = result as TableResource;
return table.description;
case ResourceType.user:
const user = result as UserResource;
return user.team_name;
default:
return '';
}
};
getSuggestedResultTitle = (resourceType: ResourceType, result: Resource): string => {
switch (resourceType) {
case ResourceType.table:
const table = result as TableResource;
return `${table.schema_name}.${table.name}`;
case ResourceType.user:
const user = result as UserResource;
return user.display_name;
default:
return '';
}
};
getSuggestedResultType = (resourceType: ResourceType, result: Resource): string => {
switch (resourceType) {
case ResourceType.table:
const table = result as TableResource;
return getDatabaseDisplayName(table.database);
case ResourceType.user:
return CONSTANTS.PEOPLE_USER_TYPE;
default:
return '';
}
};
renderResultsByResource = (resourceType: ResourceType) => {
return (
<div className="inline-results-section">
<ResultItemList
onItemSelect={this.props.onItemSelect}
resourceType={resourceType}
searchTerm={this.props.searchTerm}
suggestedResults={this.getSuggestedResultsForResource(resourceType)}
totalResults={this.getTotalResultsForResource(resourceType)}
title={this.getTitleForResource(resourceType)}
/>
</div>
)
};
renderResults = () => {
if (this.props.isLoading) {
return (
<div className="inline-results-section">
<LoadingSpinner/>
</div>
);
}
return (
<>
{ this.renderResultsByResource(ResourceType.table) }
{
indexUsersEnabled() &&
this.renderResultsByResource(ResourceType.user)
}
</>
);
}
render() {
const { className = '', onItemSelect, searchTerm } = this.props;
return (
<div id="inline-results" className={`inline-results ${className}`}>
<div className="inline-results-section">
<SearchItemList
onItemSelect={onItemSelect}
searchTerm={searchTerm}
/>
</div>
{ this.renderResults() }
</div>
);
}
}
export const mapStateToProps = (state: GlobalState) => {
const { isLoading, tables, users } = state.search.inlineResults;
return {
isLoading,
tables,
users,
};
};
export default connect<StateFromProps, OwnProps>(mapStateToProps)(InlineSearchResults);
@import 'variables';
.inline-results {
background: white;
width: 100%;
z-index: 10; // TODO: Establish levels for cases where we can't avoid z-index. This is a topmost element.
position: absolute;
border-radius: 4px;
border: 1px solid $stroke;
box-shadow: 0 3px 12px 0 rgba(17, 17, 31, 0.04);
&.small {
.search-item-link {
padding-left: 8px !important;
}
.result-item-link {
padding-left: 20px !important;
}
.section-footer.title-3,
.section-title.title-3 {
margin-left: 12px;
}
}
.inline-results-section {
padding-top: 8px;
padding-bottom: 8px;
&:not(:last-of-type) {
border-bottom: 1px solid $stroke;
}
.section-title,
.section-footer {
margin-left: 24px;
}
a.section-footer {
color: $brand-color-4 !important;
display: block;
&:hover {
cursor: pointer;
}
}
.list-group {
margin: 0;
.list-group-item {
/* Override some shared list-group-item styles */
border: none;
&:hover {
box-shadow: none !important;
background-color: $indigo5;
}
&:hover + .list-group-item {
box-shadow: none !important;
}
.result-item-link,
.search-item-link {
color: $text-primary;
display: flex;
flex-direction: row;
text-decoration: none;
img.icon.icon-search,
img.icon.result-icon,
.sb-avatar {
margin: auto 8px auto 0px;
}
}
/* SEARCH ITEM */
.search-item-link {
height: 32px;
padding: 4px 4px 4px 24px;
line-height: inherit;
&:hover {
.search-item-info .search-item-text {
text-shadow: 0 0 .6px $text-primary, 0 0 .6px $text-primary;
}
}
.search-item-info {
display: flex;
.search-item-text {
font-weight: $font-weight-body-regular;
}
}
}
/* RESULT ITEM */
.result-item-link {
height: 56px;
padding: 8px 8px 8px 32px;
.result-info {
display: flex;
flex: 1;
min-width: 0px;
margin-right: 8px;
}
.resource-type {
margin: auto;
}
}
}
}
}
.loading-spinner {
margin-top: 5%;
margin-bottom: 5%;
}
}
......@@ -2,7 +2,14 @@ import * as React from 'react';
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux';
// TODO: Use css-modules instead of 'import'
import { GlobalState } from 'ducks/rootReducer';
import { submitSearch, getInlineResultsDebounce, selectInlineResult } from 'ducks/search/reducer';
import { SubmitSearchRequest, InlineSearchRequest, InlineSearchSelect } from 'ducks/search/types';
import { ResourceType } from 'interfaces';
import InlineSearchResults from './InlineSearchResults';
import './styles.scss';
import {
......@@ -15,9 +22,6 @@ import {
SYNTAX_ERROR_PREFIX,
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;
......@@ -25,6 +29,8 @@ export interface StateFromProps {
export interface DispatchFromProps {
submitSearch: (searchTerm: string) => SubmitSearchRequest;
onInputChange: (term: string) => InlineSearchRequest;
onSelectInlineResult: (resourceType: ResourceType, searchTerm: string, updateUrl: boolean) => InlineSearchSelect;
}
export interface OwnProps {
......@@ -36,12 +42,15 @@ export interface OwnProps {
export type SearchBarProps = StateFromProps & DispatchFromProps & OwnProps;
interface SearchBarState {
showTypeAhead: boolean;
subTextClassName: string;
searchTerm: string;
subText: string;
}
export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
private refToSelf: React.RefObject<HTMLDivElement>;
public static defaultProps: Partial<SearchBarProps> = {
placeholder: PLACEHOLDER_DEFAULT,
subText: SUBTEXT_DEFAULT,
......@@ -50,8 +59,10 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
constructor(props) {
super(props);
this.refToSelf = React.createRef<HTMLDivElement>();
this.state = {
showTypeAhead: false,
subTextClassName: '',
searchTerm: this.props.searchTerm,
subText: this.props.subText,
......@@ -64,11 +75,25 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
}
clearSearchTerm = () : void => {
this.setState({ searchTerm: '' });
this.setState({ showTypeAhead: false, searchTerm: '' });
};
componentDidMount = () => {
document.addEventListener('mousedown', this.updateTypeAhead, false);
}
componentWillUnmount = () => {
document.removeEventListener('mousedown', this.updateTypeAhead, false);
}
handleValueChange = (event: React.SyntheticEvent<HTMLInputElement>) : void => {
this.setState({ searchTerm: (event.target as HTMLInputElement).value.toLowerCase() });
const searchTerm = (event.target as HTMLInputElement).value.toLowerCase();
const showTypeAhead = this.shouldShowTypeAhead(searchTerm);
this.setState({ searchTerm, showTypeAhead });
if (showTypeAhead) {
this.props.onInputChange(searchTerm);
}
};
handleValueSubmit = (event: React.FormEvent<HTMLFormElement>) : void => {
......@@ -76,9 +101,14 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
event.preventDefault();
if (this.isFormValid(searchTerm)) {
this.props.submitSearch(searchTerm);
this.hideTypeAhead();
}
};
hideTypeAhead = () : void => {
this.setState({ showTypeAhead: false });
}
isFormValid = (searchTerm: string) : boolean => {
if (searchTerm.length === 0) {
return false;
......@@ -108,13 +138,32 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
return true;
};
onSelectInlineResult = (resourceType: ResourceType, updateUrl: boolean = false) : void => {
this.hideTypeAhead();
this.props.onSelectInlineResult(resourceType, this.state.searchTerm, updateUrl);
}
shouldShowTypeAhead = (searchTerm: string) : boolean => {
return searchTerm.length > 0;
}
updateTypeAhead = (event: Event): void => {
/* This logic will hide/show the inline results component when the user clicks
outside/inside of the search bar */
if (this.refToSelf.current && this.refToSelf.current.contains(event.target as Node)) {
this.setState({ showTypeAhead: this.shouldShowTypeAhead(this.state.searchTerm) });
} else {
this.hideTypeAhead();
}
};
render() {
const inputClass = `${this.props.size === SIZE_SMALL ? 'title-2 small' : 'h2 large'} search-bar-input form-control`;
const searchButtonClass = `btn btn-flat-icon search-button ${this.props.size === SIZE_SMALL ? 'small' : 'large'}`;
const subTextClass = `subtext body-secondary-3 ${this.state.subTextClassName}`;
return (
<div id="search-bar">
<div id="search-bar" ref={this.refToSelf}>
<form className="search-bar-form" onSubmit={ this.handleValueSubmit }>
<input
id="search-input"
......@@ -124,6 +173,7 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
aria-label={ this.props.placeholder }
placeholder={ this.props.placeholder }
autoFocus={ true }
autoComplete="off"
/>
<button className={ searchButtonClass } type="submit">
<img className="icon icon-search" />
......@@ -133,6 +183,15 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
<button type="button" className="btn btn-close clear-button" aria-label={BUTTON_CLOSE_TEXT} onClick={this.clearSearchTerm} />
}
</form>
{
this.state.showTypeAhead &&
// @ts-ignore: Investigate proper configuration for 'className' to be valid by default on custom components
<InlineSearchResults
className={this.props.size === SIZE_SMALL ? 'small' : ''}
onItemSelect={this.onSelectInlineResult}
searchTerm={this.state.searchTerm}
/>
}
{
this.props.size !== SIZE_SMALL &&
<div className={ subTextClass }>
......@@ -151,7 +210,7 @@ export const mapStateToProps = (state: GlobalState) => {
};
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ submitSearch }, dispatch);
return bindActionCreators({ submitSearch, onInputChange: getInlineResultsDebounce, onSelectInlineResult: selectInlineResult }, dispatch);
};
export default connect<StateFromProps, DispatchFromProps, OwnProps>(mapStateToProps, mapDispatchToProps)(SearchBar);
@import 'variables';
#search-bar {
.search-bar-form {
position: relative;
.search-bar-form {
.search-button {
position: absolute;
height: 24px;
......
import * as React from 'react';
import { shallow } from 'enzyme';
import { mount, shallow } from 'enzyme';
import { mapStateToProps, mapDispatchToProps, SearchBar, SearchBarProps } from '../';
import {
......@@ -11,18 +11,26 @@ import {
SYNTAX_ERROR_SPACING_SUFFIX,
} from '../constants';
import globalState from 'fixtures/globalState';
import { getMockRouterProps } from 'fixtures/mockRouter';
import { ResourceType } from 'interfaces';
document.addEventListener = jest.fn(() => {});
document.removeEventListener = jest.fn(() => {});
describe('SearchBar', () => {
const valueChangeMockEvent = { target: { value: 'Data Resources' } };
const submitMockEvent = { preventDefault: jest.fn() };
const setStateSpy = jest.spyOn(SearchBar.prototype, 'setState');
const setup = (propOverrides?: Partial<SearchBarProps>) => {
const setup = (propOverrides?: Partial<SearchBarProps>, useMount?: boolean) => {
const props: SearchBarProps = {
onInputChange: jest.fn(),
onSelectInlineResult: jest.fn(),
searchTerm: '',
submitSearch: jest.fn(),
...propOverrides
};
const wrapper = shallow<SearchBar>(<SearchBar {...props} />)
const wrapper = useMount ? mount<SearchBar>(<SearchBar {...props} />) : shallow<SearchBar>(<SearchBar {...props} />);
return { props, wrapper };
};
......@@ -47,6 +55,7 @@ describe('SearchBar', () => {
const { props, wrapper } = setup();
const prevState = wrapper.state();
props.searchTerm = 'newTerm';
// @ts-ignore: Why does this work in other tests but complain here
wrapper.setProps(props);
expect(wrapper.state()).toMatchObject({
...prevState,
......@@ -62,22 +71,58 @@ describe('SearchBar', () => {
const { wrapper } = setup({ searchTerm: initialSearchTerm});
expect(wrapper.state().searchTerm).toBe(initialSearchTerm);
wrapper.instance().clearSearchTerm();
expect(setStateSpy).toHaveBeenCalledWith({ searchTerm: '' });
expect(setStateSpy).toHaveBeenCalledWith({ searchTerm: '', showTypeAhead: false });
});
});
describe('componentDidMount', () => {
it('adds an event listener for updateTypeAhead to be called on mousedown', () => {
const { wrapper } = setup({}, true);
expect(document.addEventListener).toHaveBeenCalledWith('mousedown', wrapper.instance().updateTypeAhead, false);
});
});
describe('componentWillUnmount', () => {
it('removes the event listener for updateTypeAhead', () => {
const { wrapper } = setup({}, true);
wrapper.instance().componentWillUnmount();
expect(document.removeEventListener).toHaveBeenCalledWith('mousedown', wrapper.instance().updateTypeAhead, false);
});
});
describe('handleValueChange', () => {
it('calls setState on searchTerm with event.target.value.toLowerCase()', () => {
let shouldShowTypeAheadSpy;
it('calls setState on searchTerm and shouldShowTypeAhead ', () => {
const { props, wrapper } = setup();
const mockReturnValue = true;
shouldShowTypeAheadSpy = jest.spyOn(wrapper.instance(), 'shouldShowTypeAhead').mockImplementation(() => mockReturnValue);
// @ts-ignore: mocked events throw type errors
wrapper.instance().handleValueChange(valueChangeMockEvent);
expect(setStateSpy).toHaveBeenCalledWith({ searchTerm: valueChangeMockEvent.target.value.toLowerCase(), showTypeAhead: mockReturnValue });
});
it('calls onInputChange if shouldShowTypeAhead = true', () => {
const { props, wrapper } = setup();
shouldShowTypeAheadSpy = jest.spyOn(wrapper.instance(), 'shouldShowTypeAhead').mockImplementation(() => true);
// @ts-ignore: mocked events throw type errors
wrapper.instance().handleValueChange(valueChangeMockEvent);
expect(setStateSpy).toHaveBeenCalledWith({ searchTerm: valueChangeMockEvent.target.value.toLowerCase() });
expect(props.onInputChange).toHaveBeenCalledWith(valueChangeMockEvent.target.value.toLowerCase());
});
it('does not call onInputChange if shouldShowTypeAhead = false', () => {
const { props, wrapper } = setup();
shouldShowTypeAheadSpy = jest.spyOn(wrapper.instance(), 'shouldShowTypeAhead').mockImplementation(() => false);
// @ts-ignore: mocked events throw type errors
wrapper.instance().handleValueChange(valueChangeMockEvent);
expect(props.onInputChange).not.toHaveBeenCalled();
});
});
describe('handleValueSubmit', () => {
let props;
let wrapper;
let hideTypeAheadSpy;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
......@@ -97,12 +142,77 @@ describe('SearchBar', () => {
expect(props.submitSearch).toHaveBeenCalledWith(props.searchTerm);
});
it('calls hideTypeAhead if isFormValid()', () => {
const { props, wrapper } = setup({ searchTerm: 'testTerm' });
hideTypeAheadSpy = jest.spyOn(wrapper.instance(), 'hideTypeAhead');
// @ts-ignore: mocked events throw type errors
wrapper.instance().handleValueSubmit(submitMockEvent);
expect(hideTypeAheadSpy).toHaveBeenCalled();
});
it('does not submit if !isFormValid()', () => {
const { props, wrapper } = setup({ searchTerm: 'tag:tag1 tag:tag2' });
// @ts-ignore: mocked events throw type errors
wrapper.instance().handleValueSubmit(submitMockEvent);
expect(props.submitSearch).not.toHaveBeenCalled();
});
it('does not call hideTypeAhead if !isFormValid()', () => {
const { props, wrapper } = setup({ searchTerm: 'tag:tag1 tag:tag2' });
hideTypeAheadSpy = jest.spyOn(wrapper.instance(), 'hideTypeAhead');
// @ts-ignore: mocked events throw type errors
wrapper.instance().handleValueSubmit(submitMockEvent);
expect(hideTypeAheadSpy).not.toHaveBeenCalled();
});
});
describe('hideTypeAhead', () => {
it('sets shouldShowTypeAhead to false', () => {
setStateSpy.mockClear();
const wrapper = setup().wrapper;
wrapper.instance().hideTypeAhead();
expect(setStateSpy).toHaveBeenCalledWith({ showTypeAhead: false });
});
});
describe('shouldShowTypeAhead', () => {
it('returns true for non-zero length string', () => {
const wrapper = setup().wrapper;
expect(wrapper.instance().shouldShowTypeAhead('test')).toEqual(true);
});
it('returns false for empty string', () => {
const wrapper = setup().wrapper;
expect(wrapper.instance().shouldShowTypeAhead('')).toEqual(false);
});
});
describe('onSelectInlineResult', () => {
let props;
let wrapper;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
});
it('calls hideTypeAhead', () => {
const hideTypeAheadSpy = jest.spyOn(wrapper.instance(), 'hideTypeAhead');
wrapper.update();
wrapper.instance().onSelectInlineResult(ResourceType.table, false);
expect(hideTypeAheadSpy).toHaveBeenCalled();
});
it('calls props.onSelectInlineResult with given parameters', () => {
const givenResource = ResourceType.user;
const givenBoolean = true;
wrapper.instance().onSelectInlineResult(givenResource, givenBoolean);
expect(props.onSelectInlineResult).toHaveBeenCalledWith(givenResource, wrapper.state().searchTerm, givenBoolean);
});
});
describe('updateTypeAhead', () => {
/* TODO: How to test? */
});
describe('isFormValid', () => {
......@@ -264,9 +374,15 @@ describe('mapDispatchToProps', () => {
result = mapDispatchToProps(dispatch);
});
it('sets searchAll on the props', () => {
it('sets submitSearch on the props', () => {
expect(result.submitSearch).toBeInstanceOf(Function);
});
it('sets onInputChange on the props', () => {
expect(result.onInputChange).toBeInstanceOf(Function);
});
it('sets onSelectInlineResult on the props', () => {
expect(result.onSelectInlineResult).toBeInstanceOf(Function);
});
});
describe('mapDispatchToProps', () => {
......
......@@ -38,6 +38,13 @@ export function feedbackEnabled(): boolean {
return AppConfig.mailClientFeatures.feedbackEnabled;
}
/**
* Returns whether or not user features should be shown
*/
export function indexUsersEnabled(): boolean {
return AppConfig.indexUsers.enabled;
}
/**
* Returns whether or not notification features should be enabled
*/
......
......@@ -33,6 +33,12 @@ describe('feedbackEnabled', () => {
});
});
describe('indexUsersEnabled', () => {
it('returns whether or not the notifications feature is enabled', () => {
expect(ConfigUtils.indexUsersEnabled()).toBe(AppConfig.indexUsers.enabled);
});
});
describe('notificationsEnabled', () => {
it('returns whether or not the notifications feature is enabled', () => {
expect(ConfigUtils.notificationsEnabled()).toBe(AppConfig.mailClientFeatures.notificationsEnabled);
......
......@@ -17,13 +17,16 @@ import { submitNotificationWatcher } from './notification/sagas';
import { submitFeedbackWatcher } from './feedback/sagas';
// PopularTables
import { getPopularTablesWatcher } from './popularTables/sagas';
// SearchPage
// Search
import {
inlineSearchWatcher,
inlineSearchWatcherDebounce,
loadPreviousSearchWatcher,
searchAllWatcher,
searchResourceWatcher,
setPageIndexWatcher,
setResourceWatcher,
selectInlineResultsWatcher,
submitSearchWatcher,
urlDidUpdateWatcher
} from './search/sagas';
......@@ -60,10 +63,13 @@ export default function* rootSaga() {
submitNotificationWatcher(),
// FeedbackForm
submitFeedbackWatcher(),
// SearchPage
// Search
inlineSearchWatcher(),
inlineSearchWatcherDebounce(),
loadPreviousSearchWatcher(),
searchAllWatcher(),
searchResourceWatcher(),
selectInlineResultsWatcher(),
setPageIndexWatcher(),
setResourceWatcher(),
submitSearchWatcher(),
......
......@@ -13,6 +13,13 @@ import {
SearchResponsePayload,
SearchResourceRequest,
SearchResourceResponse,
InlineSearch,
InlineSearchRequest,
InlineSearchResponse,
InlineSearchResponsePayload,
InlineSearchUpdatePayload,
InlineSearchSelect,
InlineSearchUpdate,
TableSearchResults,
UserSearchResults,
SubmitSearchRequest,
......@@ -29,6 +36,11 @@ export interface SearchReducerState {
dashboards: DashboardSearchResults;
tables: TableSearchResults;
users: UserSearchResults;
inlineResults: {
isLoading: boolean;
tables: TableSearchResults;
users: UserSearchResults;
}
};
/* ACTIONS */
......@@ -66,6 +78,45 @@ export function searchResourceFailure(): SearchResourceResponse {
return { type: SearchResource.FAILURE };
};
export function getInlineResultsDebounce(term: string): InlineSearchRequest {
return {
payload: {
term,
},
type: InlineSearch.REQUEST_DEBOUNCE,
};
};
export function getInlineResults(term: string): InlineSearchRequest {
return {
payload: {
term,
},
type: InlineSearch.REQUEST,
};
};
export function getInlineResultsSuccess(inlineResults: InlineSearchResponsePayload): InlineSearchResponse {
return { type: InlineSearch.SUCCESS, payload: inlineResults };
};
export function getInlineResultsFailure(): InlineSearchResponse {
return { type: InlineSearch.FAILURE };
};
export function selectInlineResult(resourceType: ResourceType, searchTerm: string, updateUrl: boolean = false): InlineSearchSelect {
return {
payload: {
resourceType,
searchTerm,
updateUrl,
},
type: InlineSearch.SELECT
};
};
export function updateFromInlineResult(data: InlineSearchUpdatePayload): InlineSearchUpdate {
return {
payload: data,
type: InlineSearch.UPDATE
};
};
export function searchReset(): SearchAllReset {
return {
type: SearchAll.RESET,
......@@ -108,6 +159,19 @@ export function urlDidUpdate(urlSearch: UrlSearch): UrlDidUpdateRequest{
/* REDUCER */
export const initialInlineResultsState = {
isLoading: false,
tables: {
page_index: 0,
results: [],
total_results: 0,
},
users: {
page_index: 0,
results: [],
total_results: 0,
},
}
export const initialState: SearchReducerState = {
search_term: '',
isLoading: false,
......@@ -127,6 +191,7 @@ export const initialState: SearchReducerState = {
results: [],
total_results: 0,
},
inlineResults: initialInlineResultsState,
};
export default function reducer(state: SearchReducerState = initialState, action): SearchReducerState {
......@@ -137,6 +202,9 @@ export default function reducer(state: SearchReducerState = initialState, action
// updates search term to reflect action
return {
...state,
inlineResults: {
...initialInlineResultsState,
},
search_term: (<SearchAllRequest>action).payload.term,
isLoading: true,
};
......@@ -151,7 +219,11 @@ export default function reducer(state: SearchReducerState = initialState, action
return {
...initialState,
...newState,
inlineResults: {
tables: newState.tables,
users: newState.users,
isLoading: false,
},
};
case SearchResource.SUCCESS:
// resets only a single resource and preserves search state for other resources
......@@ -172,6 +244,41 @@ export default function reducer(state: SearchReducerState = initialState, action
...state,
selectedTab: (<SetResourceRequest>action).payload.resource
};
case InlineSearch.UPDATE:
const { searchTerm, selectedTab, tables, users } = (<InlineSearchUpdate>action).payload;
return {
...state,
selectedTab,
tables,
users,
search_term: searchTerm,
};
case InlineSearch.SUCCESS:
const inlineResults = (<InlineSearchResponse>action).payload;
return {
...state,
inlineResults: {
tables: inlineResults.tables,
users: inlineResults.users,
isLoading: false,
},
};
case InlineSearch.FAILURE:
return {
...state,
inlineResults: {
...initialInlineResultsState,
},
};
case InlineSearch.REQUEST:
case InlineSearch.REQUEST_DEBOUNCE:
return {
...state,
inlineResults: {
...initialInlineResultsState,
isLoading: true,
}
};
default:
return state;
};
......
import { SagaIterator } from 'redux-saga';
import { all, call, put, select, takeEvery } from 'redux-saga/effects';
import { all, call, debounce, put, select, takeEvery, takeLatest } from 'redux-saga/effects';
import * as qs from 'simple-query-string';
import { ResourceType } from 'interfaces/Resources';
......@@ -13,6 +13,8 @@ import {
SearchAllRequest,
SearchResource,
SearchResourceRequest,
InlineSearch,
InlineSearchRequest,
SetPageIndex,
SetPageIndexRequest, SetResource, SetResourceRequest,
SubmitSearch,
......@@ -23,16 +25,74 @@ import {
import {
initialState,
initialInlineResultsState,
searchAll,
searchAllFailure,
searchAllSuccess,
searchResource,
searchResourceFailure,
searchResourceSuccess, setPageIndex, setResource,
searchResourceSuccess,
getInlineResults,
getInlineResultsDebounce,
getInlineResultsSuccess,
getInlineResultsFailure,
updateFromInlineResult,
setPageIndex, setResource,
} from './reducer';
import { autoSelectResource, getPageIndex, getSearchState } from './utils';
import { updateSearchUrl } from 'utils/navigation-utils';
export function* inlineSearchWorker(action: InlineSearchRequest): SagaIterator {
const { term } = action.payload;
try {
const [tableResponse, userResponse] = yield all([
call(API.searchResource, 0, ResourceType.table, term),
call(API.searchResource, 0, ResourceType.user, term),
]);
const inlineSearchResponse = {
tables: tableResponse.tables || initialInlineResultsState.tables,
users: userResponse.users || initialInlineResultsState.users,
};
yield put(getInlineResultsSuccess(inlineSearchResponse));
} catch (e) {
yield put(getInlineResultsFailure());
}
};
export function* inlineSearchWatcher(): SagaIterator {
yield takeLatest(InlineSearch.REQUEST, inlineSearchWorker);
}
export function* debounceWorker(action): SagaIterator {
yield put(getInlineResults(action.payload.term));
}
export function* inlineSearchWatcherDebounce(): SagaIterator {
yield debounce(350, InlineSearch.REQUEST_DEBOUNCE, debounceWorker);
}
export function* selectInlineResultWorker(action): SagaIterator {
const state = yield select();
const { searchTerm, resourceType, updateUrl } = action.payload;
if (state.search.inlineResults.isLoading) {
yield put(searchAll(searchTerm, resourceType, 0))
updateSearchUrl({ term: searchTerm });
}
else {
if (updateUrl) {
updateSearchUrl({ resource: resourceType, term: searchTerm, index: 0 });
}
const data = {
searchTerm,
selectedTab: resourceType,
tables: state.search.inlineResults.tables,
users: state.search.inlineResults.users,
};
yield put(updateFromInlineResult(data));
}
};
export function* selectInlineResultsWatcher(): SagaIterator {
yield takeEvery(InlineSearch.SELECT, selectInlineResultWorker);
};
export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
let { resource } = action.payload;
const { pageIndex, term } = action.payload;
......
......@@ -5,7 +5,11 @@ import { DEFAULT_RESOURCE_TYPE, ResourceType } from 'interfaces';
import * as API from '../api/v0';
import reducer, {
getInlineResults,
getInlineResultsSuccess,
getInlineResultsFailure,
initialState,
initialInlineResultsState,
loadPreviousSearch,
searchAll,
searchAllFailure,
......@@ -15,18 +19,24 @@ import reducer, {
searchResource,
searchResourceFailure,
searchResourceSuccess,
selectInlineResult,
setPageIndex,
setResource,
submitSearch,
updateFromInlineResult,
urlDidUpdate,
} from '../reducer';
import {
inlineSearchWatcher,
inlineSearchWorker,
loadPreviousSearchWatcher,
loadPreviousSearchWorker,
searchAllWatcher,
searchAllWorker,
searchResourceWatcher,
searchResourceWorker,
selectInlineResultsWatcher,
selectInlineResultWorker,
setPageIndexWatcher,
setPageIndexWorker,
setResourceWatcher,
......@@ -38,6 +48,9 @@ import {
} from '../sagas';
import {
LoadPreviousSearch,
InlineSearch,
InlineSearchResponsePayload,
InlineSearchUpdatePayload,
SearchAll,
SearchAllResponsePayload,
SearchResource,
......@@ -106,6 +119,34 @@ describe('search ducks', () => {
},
};
const expectedInlineResults: InlineSearchResponsePayload = {
tables: {
page_index: 0,
results: [],
total_results: 0,
},
users: {
page_index: 0,
results: [],
total_results: 0,
}
};
const inlineUpdatePayload: InlineSearchUpdatePayload = {
searchTerm: 'testName',
selectedTab: ResourceType.table,
tables: {
page_index: 0,
results: [],
total_results: 0,
},
users: {
page_index: 0,
results: [],
total_results: 0,
},
};
describe('actions', () => {
it('searchAll - returns the action to search all resources', () => {
const term = 'test';
......@@ -198,6 +239,43 @@ describe('search ducks', () => {
expect(action.type).toBe(UrlDidUpdate.REQUEST);
expect(action.payload.urlSearch).toBe(urlSearch);
});
it('getInlineResultsSuccess - returns the action to get inline results', () => {
const term = 'test'
const action = getInlineResults(term);
expect(action.type).toBe(InlineSearch.REQUEST);
expect(action.payload.term).toBe(term);
});
it('getInlineResultsSuccess - returns the action to process the success', () => {
const action = getInlineResultsSuccess(expectedInlineResults);
const { payload } = action;
expect(action.type).toBe(InlineSearch.SUCCESS);
expect(payload).toBe(expectedInlineResults);
});
it('getInlineResultsFailure - returns the action to process the failure', () => {
const action = getInlineResultsFailure();
expect(action.type).toBe(InlineSearch.FAILURE);
});
it('selectInlineResult - returns the action to process the selection of an inline result', () => {
const resource = ResourceType.table;
const searchTerm = 'test;'
const updateUrl = true;
const action = selectInlineResult(resource, searchTerm, updateUrl);
const { payload } = action;
expect(action.type).toBe(InlineSearch.SELECT);
expect(payload.resourceType).toBe(resource);
expect(payload.searchTerm).toBe(searchTerm);
expect(payload.updateUrl).toBe(updateUrl);
});
it('updateFromInlineResult - returns the action to populate the search results with existing inlineResults', () => {
const action = updateFromInlineResult(inlineUpdatePayload)
expect(action.type).toBe(InlineSearch.UPDATE);
expect(action.payload).toBe(inlineUpdatePayload);
});
});
describe('reducer', () => {
......@@ -215,6 +293,7 @@ describe('search ducks', () => {
const pageIndex = 0;
expect(reducer(testState, searchAll(term, resource, pageIndex))).toEqual({
...testState,
inlineResults: initialInlineResultsState,
search_term: term,
isLoading: true,
});
......@@ -223,8 +302,12 @@ describe('search ducks', () => {
it('should handle SearchAll.SUCCESS', () => {
expect(reducer(testState, searchAllSuccess(expectedSearchAllResults))).toEqual({
...initialState,
...expectedSearchResults,
...expectedSearchAllResults,
inlineResults: {
tables: expectedSearchAllResults.tables,
users: expectedSearchAllResults.users,
isLoading: false,
},
});
});
......@@ -268,6 +351,48 @@ describe('search ducks', () => {
selectedTab,
});
});
it('should handle InlineSearch.UPDATE', () => {
const { searchTerm, selectedTab, tables, users } = inlineUpdatePayload;
expect(reducer(testState, updateFromInlineResult(inlineUpdatePayload))).toEqual({
...testState,
selectedTab,
tables,
users,
search_term: searchTerm,
});
});
it('should handle InlineSearch.SUCCESS', () => {
const { tables, users } = expectedInlineResults;
expect(reducer(testState, getInlineResultsSuccess(expectedInlineResults))).toEqual({
...testState,
inlineResults: {
tables,
users,
isLoading: false,
}
});
});
it('should handle InlineSearch.FAILURE', () => {
expect(reducer(testState, getInlineResultsFailure())).toEqual({
...testState,
inlineResults: initialInlineResultsState,
});
});
it('should handle InlineSearch.REQUEST', () => {
const term = 'testSearch';
expect(reducer(testState, getInlineResults(term))).toEqual({
...testState,
inlineResults: {
tables: initialInlineResultsState.tables,
users: initialInlineResultsState.users,
isLoading: true,
},
});
});
});
describe('sagas', () => {
......@@ -486,6 +611,29 @@ describe('search ducks', () => {
.next().isDone();
});
});
describe('inlineSearchWorker', () => {
/* TODO - Considering some cleanup */
});
describe('inlineSearchWatcher', () => {
/* TODO - Need to investigate proper test approach
it('debounces InlineSearch.REQUEST and calls inlineSearchWorker', () => {
});
*/
});
describe('selectInlineResultWorker', () => {
/* TODO - Considering some cleanup */
});
describe('selectInlineResultsWatcher', () => {
it('takes every InlineSearch.REQUEST with selectInlineResultWorker', () => {
testSaga(selectInlineResultsWatcher)
.next().takeEvery(InlineSearch.SELECT, selectInlineResultWorker)
.next().isDone();
});
});
});
describe('utils', () => {
......
......@@ -29,6 +29,16 @@ export interface SearchAllResponsePayload extends SearchResponsePayload {
tables: TableSearchResults;
users: UserSearchResults;
};
export interface InlineSearchResponsePayload {
tables: TableSearchResults;
users: UserSearchResults;
};
export interface InlineSearchUpdatePayload {
searchTerm: string;
selectedTab: ResourceType;
tables: TableSearchResults;
users: UserSearchResults;
};
export enum SearchAll {
......@@ -73,6 +83,37 @@ export interface SearchResourceResponse {
};
export enum InlineSearch {
REQUEST = 'amundsen/search/INLINE_SEARCH_REQUEST',
REQUEST_DEBOUNCE = 'amundsen/search/INLINE_SEARCH_REQUEST_DEBOUNCE',
SELECT = 'amundsen/search/INLINE_SEARCH_SELECT',
SUCCESS = 'amundsen/search/INLINE_SEARCH_SUCCESS',
FAILURE = 'amundsen/search/INLINE_SEARCH_FAILURE',
UPDATE = 'amundsen/search/INLINE_SEARCH_UPDATE',
};
export interface InlineSearchRequest {
payload: {
term: string;
};
type: InlineSearch.REQUEST | InlineSearch.REQUEST_DEBOUNCE;
};
export interface InlineSearchResponse {
type: InlineSearch.SUCCESS | InlineSearch.FAILURE;
payload?: InlineSearchResponsePayload;
};
export interface InlineSearchSelect {
payload: {
resourceType: ResourceType;
searchTerm: string;
updateUrl: boolean;
};
type: InlineSearch.SELECT;
};
export interface InlineSearchUpdate {
payload: InlineSearchUpdatePayload,
type: InlineSearch.UPDATE;
};
export enum SubmitSearch {
REQUEST = 'amundsen/search/SUBMIT_SEARCH_REQUEST',
};
......
......@@ -4,7 +4,15 @@ import { DEFAULT_RESOURCE_TYPE, ResourceType } from 'interfaces/Resources';
export const getSearchState = (state: GlobalState): SearchReducerState => state.search;
export const getPageIndex = (state: SearchReducerState, resource?: ResourceType) => {
/*
TODO: Coupling the shape of the search state and search response requires the use of
Partial to resolve errors, removing type safty of these methods. We should
restructure any logic that takes of advantage of the case where the shape of the response
and the shape of the state happend to be the same because a piece of application state
can be the combination of multiple responses.
*/
export const getPageIndex = (state: Partial<SearchReducerState>, resource?: ResourceType) => {
resource = resource || state.selectedTab;
switch(resource) {
case ResourceType.table:
......@@ -17,7 +25,7 @@ export const getPageIndex = (state: SearchReducerState, resource?: ResourceType)
return 0;
};
export const autoSelectResource = (state: SearchReducerState) => {
export const autoSelectResource = (state: Partial<SearchReducerState>) => {
if (state.tables && state.tables.total_results > 0) {
return ResourceType.table;
}
......
......@@ -86,6 +86,19 @@ const globalState: GlobalState = {
results: [],
total_results: 0,
},
inlineResults: {
isLoading: false,
tables: {
page_index: 0,
results: [],
total_results: 0,
},
users: {
page_index: 0,
results: [],
total_results: 0,
},
}
},
tableMetadata: {
isLoading: true,
......
import { ResourceType } from 'interfaces';
export const isLoadingExample = {
isLoading: true,
tables: {
page_index: 0,
results: [],
total_results: 0,
},
users: {
page_index: 0,
results: [],
total_results: 0,
},
};
export const allResourcesExample = {
isLoading: false,
tables: {
page_index: 0,
results: [
{
cluster: 'testCluster',
database: 'testDatabase',
description: 'I have a lot of users',
key: 'testDatabase://testCluster.testSchema/testName1',
last_updated_epoch: 946684799,
name: 'testName1',
schema_name: 'testSchema',
type: ResourceType.table,
},
{
cluster: 'testCluster',
database: 'testDatabase',
description: 'I have a lot of users',
key: 'testDatabase://testCluster.testSchema/testName2',
last_updated_epoch: 946684799,
name: 'testName2',
schema_name: 'testSchema',
type: ResourceType.table,
},
{
cluster: 'testCluster',
database: 'testDatabase',
description: 'I have a lot of users',
key: 'testDatabase://testCluster.testSchema/testName3',
last_updated_epoch: 946684799,
name: 'testName3',
schema_name: 'testSchema',
type: ResourceType.table,
}
],
total_results: 3,
},
users: {
page_index: 0,
results: [
{
display_name: 'Test User',
email: 'tuser@test.com',
employee_type: null,
first_name: 'Test',
full_name: 'Test User',
github_username: '',
is_active: true,
last_name: 'User',
manager_email: 'tuser2@test.com',
manager_fullname: 'Test User2',
profile_url: '',
role_name: null,
slack_id: null,
team_name: 'Amundsen Team',
type: 'user',
user_id: 'tuser@test.com'
},
{
display_name: 'Test User2',
email: 'tuser2@test.com',
employee_type: null,
first_name: 'Test',
full_name: 'Test User2',
github_username: '',
is_active: true,
last_name: 'User2',
manager_email: 'tuser3@test.com',
manager_fullname: 'Test User3',
profile_url: '',
role_name: null,
slack_id: null,
team_name: 'Amundsen Team',
type: 'user',
user_id: 'tuser2@test.com'
}
],
total_results: 2,
},
};
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