Unverified Commit d5a98bba authored by Daniel's avatar Daniel Committed by GitHub

Enable Amundsen People/User feature (#206)

- Added option to disable user feature
- Added links to profile page
- Enable amundsen user search on FE
- Refactored searchAll to utilize searchResult API function
- Style touch-ups on Profile Page
parent 052dec38
...@@ -10,6 +10,7 @@ from flask.blueprints import Blueprint ...@@ -10,6 +10,7 @@ from flask.blueprints import Blueprint
from amundsen_application.log.action_log import action_logging from amundsen_application.log.action_log import action_logging
from amundsen_application.api.utils.request_utils import get_query_param, request_search from amundsen_application.api.utils.request_utils import get_query_param, request_search
from amundsen_application.models.user import load_user, dump_user
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
...@@ -25,6 +26,7 @@ valid_search_fields = { ...@@ -25,6 +26,7 @@ valid_search_fields = {
} }
SEARCH_ENDPOINT = '/search' SEARCH_ENDPOINT = '/search'
SEARCH_USER_ENDPOINT = '/search_user'
def _create_error_response(*, message: str, payload: Dict, status_code: int) -> Response: def _create_error_response(*, message: str, payload: Dict, status_code: int) -> Response:
...@@ -69,7 +71,11 @@ def search_table() -> Response: ...@@ -69,7 +71,11 @@ def search_table() -> Response:
@search_blueprint.route('/user', methods=['GET']) @search_blueprint.route('/user', methods=['GET'])
def search_user() -> Response: def search_user() -> Response:
return make_response(jsonify({'msg': 'Not implemented'}), HTTPStatus.NOT_IMPLEMENTED) search_term = get_query_param(request.args, 'query', 'Endpoint takes a "query" parameter')
page_index = get_query_param(request.args, 'page_index', 'Endpoint takes a "page_index" parameter')
results_dict = _search_user(search_term=search_term, page_index=page_index)
return make_response(jsonify(results_dict), results_dict.get('status_code', HTTPStatus.INTERNAL_SERVER_ERROR))
def _create_url_with_field(*, search_term: str, page_index: int) -> str: def _create_url_with_field(*, search_term: str, page_index: int) -> str:
...@@ -110,32 +116,15 @@ def _search_user(*, search_term: str, page_index: int) -> Dict[str, Any]: ...@@ -110,32 +116,15 @@ def _search_user(*, search_term: str, page_index: int) -> Dict[str, Any]:
call the search service endpoint and return matching results call the search service endpoint and return matching results
:return: a json output containing search results array as 'results' :return: a json output containing search results array as 'results'
Schema Defined Here: https://github.com/lyft/ Schema Defined Here:
amundsensearchlibrary/blob/master/search_service/api/search.py https://github.com/lyft/amundsensearchlibrary/blob/master/search_service/api/user.py
TODO: Define an interface for envoy_client TODO: Define an interface for envoy_client
""" """
def _map_user_result(result: Dict) -> Dict: def _map_user_result(result: Dict) -> Dict:
return { user_result = dump_user(load_user(result))
'type': 'user', user_result['type'] = 'user'
'active': result.get('active', None), return user_result
'birthday': result.get('birthday', None),
'department': result.get('department', None),
'email': result.get('email', None),
'first_name': result.get('first_name', None),
'github_username': result.get('github_username', None),
'id': result.get('id', None),
'last_name': result.get('last_name', None),
'manager_email': result.get('manager_email', None),
'name': result.get('name', None),
'offboarded': result.get('offboarded', None),
'office': result.get('office', None),
'role': result.get('role', None),
'start_date': result.get('start_date', None),
'team_name': result.get('team_name', None),
'title': result.get('title', None),
}
users = { users = {
'page_index': int(page_index), 'page_index': int(page_index),
...@@ -150,6 +139,30 @@ def _search_user(*, search_term: str, page_index: int) -> Dict[str, Any]: ...@@ -150,6 +139,30 @@ def _search_user(*, search_term: str, page_index: int) -> Dict[str, Any]:
'users': users, 'users': users,
} }
try:
url = '{0}?query_term={1}&page_index={2}'.format(app.config['SEARCHSERVICE_BASE'] + SEARCH_USER_ENDPOINT,
search_term,
page_index)
response = request_search(url=url)
status_code = response.status_code
if status_code == HTTPStatus.OK:
results_dict['msg'] = 'Success'
results = response.json().get('results')
users['results'] = [_map_user_result(result) for result in results]
users['total_results'] = response.json().get('total_results')
else:
message = 'Encountered error: Search request failed'
results_dict['msg'] = message
logging.error(message)
results_dict['status_code'] = status_code
return results_dict
except Exception as e:
message = 'Encountered exception: ' + str(e)
results_dict['msg'] = message
logging.exception(message)
return results_dict return results_dict
...@@ -159,8 +172,8 @@ def _search_table(*, search_term: str, page_index: int) -> Dict[str, Any]: ...@@ -159,8 +172,8 @@ def _search_table(*, search_term: str, page_index: int) -> Dict[str, Any]:
call the search service endpoint and return matching results call the search service endpoint and return matching results
:return: a json output containing search results array as 'results' :return: a json output containing search results array as 'results'
Schema Defined Here: https://github.com/lyft/ Schema Defined Here:
amundsensearchlibrary/blob/master/search_service/api/search.py https://github.com/lyft/amundsensearchlibrary/blob/master/search_service/api/search.py
TODO: Define an interface for envoy_client TODO: Define an interface for envoy_client
""" """
......
...@@ -21,6 +21,7 @@ class User: ...@@ -21,6 +21,7 @@ class User:
github_username: str = None, github_username: str = None,
is_active: bool = True, is_active: bool = True,
last_name: str = None, last_name: str = None,
manager_email: str = None,
manager_fullname: str = None, manager_fullname: str = None,
profile_url: str = None, profile_url: str = None,
role_name: str = None, role_name: str = None,
...@@ -35,6 +36,7 @@ class User: ...@@ -35,6 +36,7 @@ class User:
self.github_username = github_username self.github_username = github_username
self.is_active = is_active self.is_active = is_active
self.last_name = last_name self.last_name = last_name
self.manager_email = manager_email
self.manager_fullname = manager_fullname self.manager_fullname = manager_fullname
self.profile_url = profile_url self.profile_url = profile_url
self.role_name = role_name self.role_name = role_name
...@@ -53,6 +55,7 @@ class UserSchema(Schema): ...@@ -53,6 +55,7 @@ class UserSchema(Schema):
github_username = fields.Str(allow_none=True) github_username = fields.Str(allow_none=True)
is_active = fields.Bool(allow_none=True) is_active = fields.Bool(allow_none=True)
last_name = fields.Str(allow_none=True) last_name = fields.Str(allow_none=True)
manager_email = fields.Str(allow_none=True)
manager_fullname = fields.Str(allow_none=True) manager_fullname = fields.Str(allow_none=True)
profile_url = fields.Str(allow_none=True) profile_url = fields.Str(allow_none=True)
role_name = fields.Str(allow_none=True) role_name = fields.Str(allow_none=True)
...@@ -78,6 +81,9 @@ class UserSchema(Schema): ...@@ -78,6 +81,9 @@ class UserSchema(Schema):
if app.config['GET_PROFILE_URL']: if app.config['GET_PROFILE_URL']:
data['profile_url'] = app.config['GET_PROFILE_URL'](data['user_id']) data['profile_url'] = app.config['GET_PROFILE_URL'](data['user_id'])
# Fallback since search and metadata use a different key for 'full_name'
data['full_name'] = data.get('full_name', data.get('name'))
if self. _str_no_value(data.get('display_name')): if self. _str_no_value(data.get('display_name')):
if self._str_no_value(data.get('full_name')): if self._str_no_value(data.get('full_name')):
data['display_name'] = data.get('email') data['display_name'] = data.get('email')
......
...@@ -14,6 +14,10 @@ img.icon { ...@@ -14,6 +14,10 @@ img.icon {
background-color: $brand-color-3; background-color: $brand-color-3;
} }
&.icon-dark {
background-color: $text-medium;
}
// TODO - Add other icons here // TODO - Add other icons here
......
...@@ -15,18 +15,21 @@ h1, ...@@ -15,18 +15,21 @@ h1,
.h1 { .h1 {
font-size: 36px; font-size: 36px;
font-weight: $font-weight-header-regular; font-weight: $font-weight-header-regular;
line-height: 34px;
} }
h2, h2,
.h2 { .h2 {
font-size: 26px; font-size: 26px;
font-weight: $font-weight-header-bold; font-weight: $font-weight-header-bold;
line-height: 34px;
} }
h3, h3,
.h3 { .h3 {
font-size: 20px; font-size: 20px;
font-weight: $font-weight-header-bold; font-weight: $font-weight-header-bold;
line-height: 34px;
} }
body { body {
...@@ -46,7 +49,7 @@ body { ...@@ -46,7 +49,7 @@ body {
.title-1 { .title-1 {
font-size: 20px; font-size: 20px;
font-weight: $font-weight-body-bold; font-weight: $font-weight-body-bold;
line-height: 24px; line-height: 28px;
} }
.title-2 { .title-2 {
...@@ -63,7 +66,7 @@ body { ...@@ -63,7 +66,7 @@ body {
.subtitle-1 { .subtitle-1 {
font-size: 20px; font-size: 20px;
font-weight: $font-weight-body-semi-bold; font-weight: $font-weight-body-semi-bold;
line-height: 20px; line-height: 28px;
} }
.subtitle-2 { .subtitle-2 {
...@@ -79,12 +82,13 @@ body { ...@@ -79,12 +82,13 @@ body {
.body-1 { .body-1 {
font-size: 20px; font-size: 20px;
font-weight: $font-weight-body-regular; font-weight: $font-weight-body-regular;
line-height: 24px; line-height: 20px;
} }
.body-2 { .body-2 {
font-size: 16px; font-size: 16px;
font-weight: $font-weight-body-regular; font-weight: $font-weight-body-regular;
line-height: 20px;
} }
.body-3 { .body-3 {
......
...@@ -52,8 +52,15 @@ export class NavBar extends React.Component<NavBarProps> { ...@@ -52,8 +52,15 @@ export class NavBar extends React.Component<NavBarProps> {
<div id="nav-bar-right" className="nav-bar-right"> <div id="nav-bar-right" className="nav-bar-right">
{this.generateNavLinks(AppConfig.navLinks)} {this.generateNavLinks(AppConfig.navLinks)}
{ {
// TODO PEOPLE - Add link to user profile this.props.loggedInUser && AppConfig.indexUsers.enabled &&
this.props.loggedInUser && <Link id="nav-bar-avatar-link" to={`/user/${this.props.loggedInUser.user_id}`}>
<div id="nav-bar-avatar">
<Avatar name={this.props.loggedInUser.display_name} size={32} round={true} />
</div>
</Link>
}
{
this.props.loggedInUser && !AppConfig.indexUsers.enabled &&
<div id="nav-bar-avatar"> <div id="nav-bar-avatar">
<Avatar name={this.props.loggedInUser.display_name} size={32} round={true} /> <Avatar name={this.props.loggedInUser.display_name} size={32} round={true} />
</div> </div>
......
...@@ -31,6 +31,8 @@ AppConfig.navLinks = [ ...@@ -31,6 +31,8 @@ AppConfig.navLinks = [
use_router: false, use_router: false,
} }
]; ];
AppConfig.indexUsers.enabled = true;
import globalState from 'fixtures/globalState'; import globalState from 'fixtures/globalState';
...@@ -112,6 +114,16 @@ describe('NavBar', () => { ...@@ -112,6 +114,16 @@ describe('NavBar', () => {
round: true, round: true,
}) })
}); });
it('renders a Link to the user profile if `indexUsers` is enabled', () => {
expect(wrapper.find('#nav-bar-avatar-link').exists()).toBe(true)
});
it('does not render a Link to the user profile if `indexUsers` is disabled', () => {
AppConfig.indexUsers.enabled = false;
const { wrapper } = setup();
expect(wrapper.find('#nav-bar-avatar-link').exists()).toBe(false)
});
}); });
}); });
......
...@@ -2,6 +2,7 @@ import * as React from 'react'; ...@@ -2,6 +2,7 @@ import * as React from 'react';
import * as DocumentTitle from 'react-document-title'; import * as DocumentTitle from 'react-document-title';
import * as Avatar from 'react-avatar'; import * as Avatar from 'react-avatar';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import Breadcrumb from 'components/common/Breadcrumb'; import Breadcrumb from 'components/common/Breadcrumb';
...@@ -30,8 +31,6 @@ import { ...@@ -30,8 +31,6 @@ import {
READ_TAB_TITLE, READ_TAB_TITLE,
} from './constants'; } from './constants';
interface StateFromProps { interface StateFromProps {
bookmarks: Resource[]; bookmarks: Resource[];
user: PeopleUser; user: PeopleUser;
...@@ -46,30 +45,41 @@ interface DispatchFromProps { ...@@ -46,30 +45,41 @@ interface DispatchFromProps {
getBookmarksForUser: (userId: string) => GetBookmarksForUserRequest; getBookmarksForUser: (userId: string) => GetBookmarksForUserRequest;
} }
export type ProfilePageProps = StateFromProps & DispatchFromProps; interface RouteProps {
userId: string;
}
export class ProfilePage extends React.Component<ProfilePageProps> { interface ProfilePageState {
private userId: string; userId: string;
}
export type ProfilePageProps = StateFromProps & DispatchFromProps & RouteComponentProps<RouteProps>;
export class ProfilePage extends React.Component<ProfilePageProps, ProfilePageState> {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { userId: props.match.params.userId }
const { match } = props;
const params = match.params;
this.userId = params && params.userId ? params.userId : '';
} }
componentDidMount() { componentDidMount() {
this.props.getUserById(this.userId); this.loadUserInfo(this.state.userId);
this.props.getUserOwn(this.userId);
this.props.getUserRead(this.userId);
this.props.getBookmarksForUser(this.userId);
} }
getUserId = () => { componentDidUpdate() {
return this.userId; const userId = this.props.match.params.userId;
}; if (userId !== this.state.userId) {
this.setState({ userId });
this.loadUserInfo(userId);
}
}
loadUserInfo = (userId: string) => {
this.props.getUserById(userId);
this.props.getUserOwn(userId);
this.props.getUserRead(userId);
this.props.getBookmarksForUser(userId);
};
getTabContent = (resource: Resource[], source: string, label: string) => { getTabContent = (resource: Resource[], source: string, label: string) => {
// TODO: consider moving logic for empty content into Tab component // TODO: consider moving logic for empty content into Tab component
...@@ -93,16 +103,16 @@ export class ProfilePage extends React.Component<ProfilePageProps> { ...@@ -93,16 +103,16 @@ export class ProfilePage extends React.Component<ProfilePageProps> {
const tabInfo = []; const tabInfo = [];
const { bookmarks, read, own } = this.props; const { bookmarks, read, own } = this.props;
tabInfo.push({
content: this.getTabContent(read, READ_SOURCE, READ_LABEL),
key: READ_TAB_KEY,
title: `${READ_TAB_TITLE} (${read.length})`,
});
tabInfo.push({ tabInfo.push({
content: this.getTabContent(bookmarks, BOOKMARKED_SOURCE, BOOKMARKED_LABEL), content: this.getTabContent(bookmarks, BOOKMARKED_SOURCE, BOOKMARKED_LABEL),
key: BOOKMARKED_TAB_KEY, key: BOOKMARKED_TAB_KEY,
title: `${BOOKMARKED_TAB_TITLE} (${bookmarks.length})`, title: `${BOOKMARKED_TAB_TITLE} (${bookmarks.length})`,
}); });
tabInfo.push({
content: this.getTabContent(read, READ_SOURCE, READ_LABEL),
key: READ_TAB_KEY,
title: `${READ_TAB_TITLE} (${read.length})`,
});
tabInfo.push({ tabInfo.push({
content: this.getTabContent(own, OWNED_SOURCE, OWNED_LABEL), content: this.getTabContent(own, OWNED_SOURCE, OWNED_LABEL),
key: OWNED_TAB_KEY, key: OWNED_TAB_KEY,
...@@ -121,8 +131,7 @@ export class ProfilePage extends React.Component<ProfilePageProps> { ...@@ -121,8 +131,7 @@ export class ProfilePage extends React.Component<ProfilePageProps> {
<div className="container profile-page"> <div className="container profile-page">
<div className="row"> <div className="row">
<div className="col-xs-12 col-md-offset-1 col-md-10"> <div className="col-xs-12 col-md-offset-1 col-md-10">
{/* remove hardcode to home when this page is ready for production */} <Breadcrumb />
<Breadcrumb path="/" text="Home" />
{/* TODO - Consider making this part a separate component */} {/* TODO - Consider making this part a separate component */}
<div className="profile-header"> <div className="profile-header">
<div id="profile-avatar" className="profile-avatar"> <div id="profile-avatar" className="profile-avatar">
...@@ -137,51 +146,57 @@ export class ProfilePage extends React.Component<ProfilePageProps> { ...@@ -137,51 +146,57 @@ export class ProfilePage extends React.Component<ProfilePageProps> {
<h1>{ user.display_name }</h1> <h1>{ user.display_name }</h1>
{ {
(!user.is_active) && (!user.is_active) &&
<Flag caseType="sentenceCase" labelStyle="label-danger" text="Alumni"/> <Flag caseType="sentenceCase" labelStyle="danger" text="Alumni"/>
} }
</div> </div>
{ {
user.role_name && user.team_name && user.role_name && user.team_name &&
<label id="user-role">{ `${user.role_name} on ${user.team_name}` }</label> <div id="user-role" className="body-2">{ `${user.role_name} on ${user.team_name}` }</div>
} }
{/*TODO - delete when 'role_name'/'title' is added to user object in backend */}
{ {
user.manager_fullname && !user.role_name && user.team_name &&
<label id="user-manager">{ `Manager: ${user.manager_fullname}` }</label> <div id="user-role" className="body-2">{ `Team: ${user.team_name}` }</div>
} }
<div className="profile-icons">
{ {
user.is_active && user.manager_fullname &&
<a id="slack-link" href={user.slack_id} className='btn btn-flat-icon' target='_blank'> <div id="user-manager" className="body-2">{ `Manager: ${user.manager_fullname}` }</div>
<img className='icon icon-slack'/>
<span>Slack</span>
</a>
} }
<div className="profile-icons">
{/*TODO - Implement deep links to open Slack */}
{/*{*/}
{/*user.is_active && user.slack_id &&*/}
{/*<a id="slack-link" href={user.slack_id} className='btn btn-flat-icon' target='_blank'>*/}
{/*<img className='icon icon-dark icon-slack'/>*/}
{/*<span className="body-2">Slack</span>*/}
{/*</a>*/}
{/*}*/}
{ {
user.is_active && user.is_active &&
<a id="email-link" href={`mailto:${user.email}`} className='btn btn-flat-icon' target='_blank'> <a id="email-link" href={`mailto:${user.email}`} className='btn btn-flat-icon' target='_blank'>
<img className='icon icon-mail'/> <img className='icon icon-dark icon-mail'/>
<span>{ user.email }</span> <span className="body-2">{ user.email }</span>
</a> </a>
} }
{ {
user.is_active && user.profile_url && user.is_active && user.profile_url &&
<a id="profile-link" href={user.profile_url} className='btn btn-flat-icon' target='_blank'> <a id="profile-link" href={user.profile_url} className='btn btn-flat-icon' target='_blank'>
<img className='icon icon-users'/> <img className='icon icon-dark icon-users'/>
<span>Employee Profile</span> <span className="body-2">Employee Profile</span>
</a> </a>
} }
{ {
user.github_username && user.github_username &&
<a id="github-link" href={`https://github.com/${user.github_username}`} className='btn btn-flat-icon' target='_blank'> <a id="github-link" href={`https://github.com/${user.github_username}`} className='btn btn-flat-icon' target='_blank'>
<img className='icon icon-github'/> <img className='icon icon-dark icon-github'/>
<span>Github</span> <span className="body-2">Github</span>
</a> </a>
} }
</div> </div>
</div> </div>
</div> </div>
<div id="profile-tabs" className="profile-tabs"> <div id="profile-tabs" className="profile-tabs">
<Tabs tabs={ this.generateTabInfo() } defaultTab={ READ_TAB_KEY } /> <Tabs tabs={ this.generateTabInfo() } defaultTab={ BOOKMARKED_TAB_KEY } />
</div> </div>
</div> </div>
</div> </div>
...@@ -204,4 +219,4 @@ export const mapDispatchToProps = (dispatch) => { ...@@ -204,4 +219,4 @@ export const mapDispatchToProps = (dispatch) => {
return bindActionCreators({ getUserById, getUserOwn, getUserRead, getBookmarksForUser }, dispatch); return bindActionCreators({ getUserById, getUserOwn, getUserRead, getBookmarksForUser }, dispatch);
}; };
export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(ProfilePage); export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(withRouter(ProfilePage));
...@@ -41,8 +41,8 @@ ...@@ -41,8 +41,8 @@
.flag { .flag {
height: min-content; // TODO: consider moving height into Flag component height: min-content; // TODO: consider moving height into Flag component
font-size: 100%; font-size: 16px;
margin: auto auto auto 12px; margin: auto auto 4px 12px;
} }
} }
......
...@@ -23,7 +23,7 @@ import { ...@@ -23,7 +23,7 @@ import {
describe('ProfilePage', () => { describe('ProfilePage', () => {
const setup = (propOverrides?: Partial<ProfilePageProps>) => { const setup = (propOverrides?: Partial<ProfilePageProps>) => {
const props: ProfilePageProps = { const props: Partial<ProfilePageProps> = {
user: globalState.user.profile.user, user: globalState.user.profile.user,
bookmarks: [ bookmarks: [
{ type: ResourceType.table }, { type: ResourceType.table },
...@@ -52,19 +52,42 @@ describe('ProfilePage', () => { ...@@ -52,19 +52,42 @@ describe('ProfilePage', () => {
props = setupResult.props; props = setupResult.props;
wrapper = setupResult.wrapper; wrapper = setupResult.wrapper;
}); });
});
it('sets the userId if it exists on match.params', () => { describe('componentDidMount', () => {
expect(wrapper.instance().getUserId()).toEqual('test0'); it('calls loadUserInfo', () => {
const { props, wrapper } = setup();
const loadUserInfoSpy = jest.spyOn(wrapper.instance(), 'loadUserInfo');
wrapper.instance().componentDidMount();
expect(loadUserInfoSpy).toHaveBeenCalled();
});
}); });
it('sets the userId as empty string if no match.params.userId', () => { describe('componentDidUpdate', () => {
// @ts-ignore : complains about match let props;
const wrapper = shallow<ProfilePage>(<ProfilePage {...props} match={{params: {}}}/>); let wrapper;
expect(wrapper.instance().getUserId()).toEqual(''); let loadUserInfoSpy;
beforeEach(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
loadUserInfoSpy = jest.spyOn(wrapper.instance(), 'loadUserInfo');
}); });
it('calls loadUserInfo when userId has changes', () => {
wrapper.setProps({ match: { params: { userId: 'newUserId' }}});
expect(loadUserInfoSpy).toHaveBeenCalled();
}); });
describe('componentDidMount', () => { it('does not call loadUserInfo when userId has not changed', () => {
wrapper.instance().componentDidUpdate();
expect(loadUserInfoSpy).not.toHaveBeenCalled();
});
});
describe('loadUserInfo', () => {
it('calls props.getUserById', () => { it('calls props.getUserById', () => {
const { props, wrapper } = setup(); const { props, wrapper } = setup();
expect(props.getUserById).toHaveBeenCalled(); expect(props.getUserById).toHaveBeenCalled();
...@@ -161,11 +184,8 @@ describe('ProfilePage', () => { ...@@ -161,11 +184,8 @@ describe('ProfilePage', () => {
expect(wrapper.find(DocumentTitle).props().title).toEqual(`${props.user.display_name} - Amundsen Profile`); expect(wrapper.find(DocumentTitle).props().title).toEqual(`${props.user.display_name} - Amundsen Profile`);
}); });
it('renders Breadcrumb with correct props', () => { it('renders Breadcrumb', () => {
expect(wrapper.find(Breadcrumb).props()).toMatchObject({ expect(wrapper.find(Breadcrumb).exists()).toBe(true)
path: '/',
text: 'Home',
});
}); });
it('renders Avatar for user.display_name', () => { it('renders Avatar for user.display_name', () => {
...@@ -202,7 +222,7 @@ describe('ProfilePage', () => { ...@@ -202,7 +222,7 @@ describe('ProfilePage', () => {
}).wrapper; }).wrapper;
expect(wrapper.find('#profile-title').find(Flag).props()).toMatchObject({ expect(wrapper.find('#profile-title').find(Flag).props()).toMatchObject({
caseType: 'sentenceCase', caseType: 'sentenceCase',
labelStyle: 'label-danger', labelStyle: 'danger',
text: 'Alumni', text: 'Alumni',
}); });
}); });
...@@ -230,18 +250,19 @@ describe('ProfilePage', () => { ...@@ -230,18 +250,19 @@ describe('ProfilePage', () => {
it('renders Tabs w/ correct props', () => { it('renders Tabs w/ correct props', () => {
expect(wrapper.find('#profile-tabs').find(Tabs).props()).toMatchObject({ expect(wrapper.find('#profile-tabs').find(Tabs).props()).toMatchObject({
tabs: wrapper.instance().generateTabInfo(), tabs: wrapper.instance().generateTabInfo(),
defaultTab: READ_TAB_KEY, defaultTab: BOOKMARKED_TAB_KEY,
}); });
}); });
describe('if user.is_active', () => { describe('if user.is_active', () => {
it('renders slack link with correct href', () => { // TODO - Uncomment when slack integration is fixed
expect(wrapper.find('#slack-link').props().href).toEqual('www.slack.com'); // it('renders slack link with correct href', () => {
}); // expect(wrapper.find('#slack-link').props().href).toEqual('www.slack.com');
// });
it('renders slack link with correct text', () => { //
expect(wrapper.find('#slack-link').find('span').text()).toEqual('Slack'); // it('renders slack link with correct text', () => {
}); // expect(wrapper.find('#slack-link').find('span').text()).toEqual('Slack');
// });
it('renders email link with correct href', () => { it('renders email link with correct href', () => {
expect(wrapper.find('#email-link').props().href).toEqual('mailto:test@test.com'); expect(wrapper.find('#email-link').props().href).toEqual('mailto:test@test.com');
......
...@@ -13,3 +13,4 @@ export const SEARCH_ERROR_MESSAGE_PREFIX = 'Your search - '; ...@@ -13,3 +13,4 @@ export const SEARCH_ERROR_MESSAGE_PREFIX = 'Your search - ';
export const SEARCH_ERROR_MESSAGE_SUFFIX = ' result'; export const SEARCH_ERROR_MESSAGE_SUFFIX = ' result';
export const TABLE_RESOURCE_TITLE = 'Tables'; export const TABLE_RESOURCE_TITLE = 'Tables';
export const USER_RESOURCE_TITLE = 'Users';
...@@ -5,6 +5,7 @@ import * as DocumentTitle from 'react-document-title'; ...@@ -5,6 +5,7 @@ import * as DocumentTitle from 'react-document-title';
import * as qs from 'simple-query-string'; import * as qs from 'simple-query-string';
import { RouteComponentProps } from 'react-router'; import { RouteComponentProps } from 'react-router';
import AppConfig from 'config/config';
import LoadingSpinner from 'components/common/LoadingSpinner'; import LoadingSpinner from 'components/common/LoadingSpinner';
import InfoButton from 'components/common/InfoButton'; import InfoButton from 'components/common/InfoButton';
import ResourceList from 'components/common/ResourceList'; import ResourceList from 'components/common/ResourceList';
...@@ -36,6 +37,7 @@ import { ...@@ -36,6 +37,7 @@ import {
SEARCH_INFO_TEXT, SEARCH_INFO_TEXT,
SEARCH_SOURCE_NAME, SEARCH_SOURCE_NAME,
TABLE_RESOURCE_TITLE, TABLE_RESOURCE_TITLE,
USER_RESOURCE_TITLE,
} from './constants'; } from './constants';
export interface StateFromProps { export interface StateFromProps {
...@@ -155,8 +157,14 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState ...@@ -155,8 +157,14 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState
key: ResourceType.table, key: ResourceType.table,
content: this.getTabContent(this.props.tables, TABLE_RESOURCE_TITLE), content: this.getTabContent(this.props.tables, TABLE_RESOURCE_TITLE),
}, },
// TODO PEOPLE - Add users tab
]; ];
if (AppConfig.indexUsers.enabled) {
tabConfig.push({
title: `Users (${ this.props.users.total_results })`,
key: ResourceType.user,
content: this.getTabContent(this.props.users, USER_RESOURCE_TITLE),
})
}
return ( return (
<div> <div>
......
...@@ -3,6 +3,7 @@ import * as DocumentTitle from 'react-document-title'; ...@@ -3,6 +3,7 @@ import * as DocumentTitle from 'react-document-title';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import AppConfig from 'config/config';
import { ResourceType } from 'interfaces'; import { ResourceType } from 'interfaces';
import { SearchPage, SearchPageProps, mapDispatchToProps, mapStateToProps } from '../'; import { SearchPage, SearchPageProps, mapDispatchToProps, mapStateToProps } from '../';
import { import {
...@@ -596,22 +597,41 @@ describe('SearchPage', () => { ...@@ -596,22 +597,41 @@ describe('SearchPage', () => {
}); });
describe('renderSearchResults', () => { describe('renderSearchResults', () => {
let props;
let wrapper;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
});
it('renders TabsComponent with correct props', () => { it('renders TabsComponent with correct props', () => {
const { props, wrapper } = setup({ searchTerm: 'test search' }); AppConfig.indexUsers.enabled = false;
const content = shallow(wrapper.instance().renderSearchResults()); const content = shallow(wrapper.instance().renderSearchResults());
const expectedTabConfig = [ const tabProps = content.find(TabsComponent).props();
{ expect(tabProps.activeKey).toEqual(wrapper.state().selectedTab);
title: `${TABLE_RESOURCE_TITLE} (${props.tables.total_results})`, expect(tabProps.defaultTab).toEqual(ResourceType.table);
key: ResourceType.table, expect(tabProps.onSelect).toEqual(wrapper.instance().onTabChange);
content: wrapper.instance().getTabContent(props.tables, TABLE_RESOURCE_TITLE),
} const firstTab = tabProps.tabs[0];
]; expect(firstTab.key).toEqual(ResourceType.table);
expect(content.find(TabsComponent).props()).toMatchObject({ expect(firstTab.title).toEqual(`${TABLE_RESOURCE_TITLE} (${props.tables.total_results})`);
activeKey: wrapper.state().selectedTab, expect(firstTab.content).toEqual(wrapper.instance().getTabContent(props.tables, TABLE_RESOURCE_TITLE));
defaultTab: ResourceType.table, });
onSelect: wrapper.instance().onTabChange,
tabs: expectedTabConfig, it('renders only one tab if people is disabled', () => {
AppConfig.indexUsers.enabled = false;
const content = shallow(wrapper.instance().renderSearchResults());
const tabConfig = content.find(TabsComponent).props().tabs;
expect(tabConfig.length).toEqual(1)
}); });
it('renders two tabs if indexUsers is enabled', () => {
AppConfig.indexUsers.enabled = true;
const content = shallow(wrapper.instance().renderSearchResults());
const tabConfig = content.find(TabsComponent).props().tabs;
expect(tabConfig.length).toEqual(2)
}); });
}); });
......
import * as React from 'react'; import * as React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { Link } from 'react-router-dom';
import * as Avatar from 'react-avatar'; import * as Avatar from 'react-avatar';
import * as DocumentTitle from 'react-document-title'; import * as DocumentTitle from 'react-document-title';
...@@ -29,7 +30,7 @@ import { logClick } from 'ducks/utilMethods'; ...@@ -29,7 +30,7 @@ import { logClick } from 'ducks/utilMethods';
import { OverlayTrigger, Popover } from 'react-bootstrap'; import { OverlayTrigger, Popover } from 'react-bootstrap';
import { RouteComponentProps } from 'react-router'; import { RouteComponentProps } from 'react-router';
import { PreviewQueryParams, TableMetadata } from 'interfaces'; import { PreviewQueryParams, TableMetadata, User } from 'interfaces';
// TODO: Use css-modules instead of 'import' // TODO: Use css-modules instead of 'import'
import './styles.scss'; import './styles.scss';
...@@ -127,35 +128,31 @@ export class TableDetail extends React.Component<TableDetailProps & RouteCompone ...@@ -127,35 +128,31 @@ export class TableDetail extends React.Component<TableDetailProps & RouteCompone
this.props.getPreviewData({ database: this.database, schema: this.schema, tableName: this.tableName }); this.props.getPreviewData({ database: this.database, schema: this.schema, tableName: this.tableName });
} }
frequentUserOnClick = (e) => { getAvatarForUser(user: User, zIndex) {
logClick(e, {
target_id: 'frequent-users',
})
};
getAvatarForUser(fullName, profileUrl, zIndex) {
const popoverHoverFocus = ( const popoverHoverFocus = (
<Popover id="popover-trigger-hover-focus"> <Popover id="popover-trigger-hover-focus">
{fullName} { user.display_name }
</Popover>); </Popover>
if (profileUrl.length !== 0) {
return (
<OverlayTrigger key={fullName} trigger={['hover', 'focus']} placement="top" overlay={popoverHoverFocus}>
<a href={profileUrl} target='_blank'
style={{ display: 'inline-block', marginLeft: '-5px', backgroundColor: 'white', borderRadius: '90%'}}
onClick={this.frequentUserOnClick}
>
<Avatar name={fullName} size={25} round={true} style={{zIndex, position: 'relative'}} />
</a>
</OverlayTrigger>
); );
let link = user.profile_url;
let target = '_blank';
if (AppConfig.indexUsers.enabled) {
link = `/user/${user.user_id}`;
target = '';
} }
return ( return (
<OverlayTrigger key={fullName} trigger={['hover', 'focus']} placement="top" overlay={popoverHoverFocus}> <OverlayTrigger key={user.display_name} trigger={['hover', 'focus']} placement="top" overlay={popoverHoverFocus}>
<div style={{display: 'inline-block', marginLeft: '-5px', <Link
backgroundColor: 'white', borderRadius: '90%'}}> to={ link }
<Avatar name={fullName} size={25} round={true} style={{zIndex, position: 'relative'}}/> target={ target }
</div> className="avatar-overlap"
id="frequent-users"
onClick={logClick}
>
<Avatar name={user.display_name} size={25} round={true} style={{zIndex, position: 'relative'}} />
</Link>
</OverlayTrigger> </OverlayTrigger>
); );
} }
...@@ -239,12 +236,7 @@ export class TableDetail extends React.Component<TableDetailProps & RouteCompone ...@@ -239,12 +236,7 @@ export class TableDetail extends React.Component<TableDetailProps & RouteCompone
const readerSectionRenderer = () => { const readerSectionRenderer = () => {
return (data.table_readers && data.table_readers.length > 0) ? return (data.table_readers && data.table_readers.length > 0) ?
data.table_readers.map((entry, index) => { data.table_readers.map((entry, index) => {
const fullName = entry.reader.display_name; return this.getAvatarForUser(entry.reader, data.table_readers.length - index);
const profileUrl = entry.reader.profile_url;
return (
this.getAvatarForUser(fullName, profileUrl, data.table_readers.length - index)
);
}) : }) :
(<label className="m-auto">No frequent users exist</label>); (<label className="m-auto">No frequent users exist</label>);
}; };
......
...@@ -19,3 +19,11 @@ ...@@ -19,3 +19,11 @@
font-weight: normal; font-weight: normal;
min-width: 0; min-width: 0;
} }
.avatar-overlap {
margin-left: -5px;
&:first-child {
margin-left: 0;
}
}
...@@ -23,17 +23,15 @@ export const Breadcrumb: React.SFC<BreadcrumbProps> = (props) => { ...@@ -23,17 +23,15 @@ export const Breadcrumb: React.SFC<BreadcrumbProps> = (props) => {
path = '/'; path = '/';
text = 'Home'; text = 'Home';
if (props.searchTerm) { if (props.searchTerm) {
path = `/search?searchTerm=${props.searchTerm}&selectedTab=table&pageIndex=0` path = `/search?searchTerm=${props.searchTerm}&selectedTab=table&pageIndex=0`;
text = 'Search Results' text = 'Search Results';
} }
} }
return ( return (
<div className="amundsen-breadcrumb"> <div className="amundsen-breadcrumb">
<Link to={path}> <Link to={path} className='btn btn-flat-icon title-3'>
<button className='btn btn-flat-icon title-3'>
<img className='icon icon-left'/> <img className='icon icon-left'/>
<span>{text}</span> <span>{text}</span>
</button>
</Link> </Link>
</div> </div>
); );
......
...@@ -25,8 +25,8 @@ describe('Breadcrumb', () => { ...@@ -25,8 +25,8 @@ describe('Breadcrumb', () => {
}); });
}); });
it('renders button with correct text within the Link', () => { it('renders Link with correct text', () => {
expect(subject.find(Link).find('button').text()).toEqual(props.text); expect(subject.find(Link).find('span').text()).toEqual(props.text);
}); });
}); });
...@@ -44,8 +44,8 @@ describe('Breadcrumb', () => { ...@@ -44,8 +44,8 @@ describe('Breadcrumb', () => {
}); });
}); });
it('renders button with correct text within the Link', () => { it('renders Link with correct text', () => {
expect(subject.find(Link).find('button').text()).toEqual('Search Results'); expect(subject.find(Link).find('span').text()).toEqual('Search Results');
}); });
}); });
...@@ -65,8 +65,8 @@ describe('Breadcrumb', () => { ...@@ -65,8 +65,8 @@ describe('Breadcrumb', () => {
}); });
}); });
it('renders button with correct text within the Link', () => { it('renders Link with correct text', () => {
expect(subject.find(Link).find('button').text()).toEqual('testText'); expect(subject.find(Link).find('span').text()).toEqual('testText');
}); });
}); });
}); });
...@@ -2,6 +2,6 @@ ...@@ -2,6 +2,6 @@
.flag { .flag {
display: inline-block; display: inline-block;
margin: 4px 4px 4px 0; margin: 4px 4px 4px 8px;
font-size: 14px; font-size: 14px;
} }
...@@ -19,7 +19,7 @@ class UserListItem extends React.Component<UserListItemProps, {}> { ...@@ -19,7 +19,7 @@ class UserListItem extends React.Component<UserListItemProps, {}> {
getLink = () => { getLink = () => {
const { user, logging } = this.props; const { user, logging } = this.props;
return `/user/${user.id}/?index=${logging.index}&source=${logging.source}`; return `/user/${user.user_id}/?index=${logging.index}&source=${logging.source}`;
}; };
render() { render() {
...@@ -27,25 +27,25 @@ class UserListItem extends React.Component<UserListItemProps, {}> { ...@@ -27,25 +27,25 @@ class UserListItem extends React.Component<UserListItemProps, {}> {
return ( return (
<li className="list-group-item"> <li className="list-group-item">
<Link className="resource-list-item user-list-item" to={ this.getLink() }> <Link className="resource-list-item user-list-item" to={ this.getLink() }>
<Avatar name={ user.name } size={ 24 } round={ true } /> <Avatar name={ user.display_name } size={ 24 } round={ true } />
<div className="content"> <div className="content">
<div className="col-xs-12 col-sm-6"> <div className="col-xs-12">
<div className="title-2"> <div className="title-2">
{ user.name } { user.display_name }
{ {
!user.active && !user.is_active &&
<Flag text="Alumni" labelStyle='danger' /> <Flag text="Alumni" labelStyle='danger' />
} }
</div> </div>
<div className="body-secondary-3"> <div className="body-secondary-3">
{ `${user.role} on ${user.team_name}` } {
</div> !user.role_name && user.team_name &&
</div> `${user.team_name}`
<div className="hidden-xs col-sm-6"> }
<div className="title-3">Frequently Uses</div> {
<div className="body-secondary-3 truncated"> user.role_name && user.team_name &&
{ /*TODO Fill this with a real value*/ } `${user.role_name} on ${user.team_name}`
<label>{ user.title }</label> }
</div> </div>
</div> </div>
</div> </div>
......
...@@ -15,22 +15,20 @@ describe('UserListItem', () => { ...@@ -15,22 +15,20 @@ describe('UserListItem', () => {
logging: { source: 'src', index: 0 }, logging: { source: 'src', index: 0 },
user: { user: {
type: ResourceType.user, type: ResourceType.user,
active: true, display_name: 'firstname lastname',
birthday: null,
department: 'Department',
email: 'test@test.com', email: 'test@test.com',
first_name: '', employee_type: 'fulltime',
github_username: '', first_name: 'firstname',
id: 0, full_name: 'firstname lastname',
last_name: '', github_username: 'githubName',
manager_email: '', is_active: true,
name: 'Test Tester', last_name: 'lastname',
offboarded: true, manager_fullname: 'Test Manager',
office: '', profile_url: 'www.test.com',
role: '', role_name: 'Tester',
start_date: '', slack_id: 'www.slack.com',
team_name: '', team_name: 'QA',
title: '', user_id: 'test0',
}, },
...propOverrides ...propOverrides
}; };
...@@ -54,14 +52,14 @@ describe('UserListItem', () => { ...@@ -54,14 +52,14 @@ describe('UserListItem', () => {
it('renders Avatar', () => { it('renders Avatar', () => {
expect(wrapper.find(Link).find(Avatar).props()).toMatchObject({ expect(wrapper.find(Link).find(Avatar).props()).toMatchObject({
name: props.user.name, name: props.user.display_name,
size: 24, size: 24,
round: true, round: true,
}); });
}); });
it('renders user.name', () => { it('renders user.name', () => {
expect(wrapper.find('.content').children().at(0).children().at(0).children().at(0).text()).toEqual(props.user.name); expect(wrapper.find('.content').children().at(0).children().at(0).children().at(0).text()).toEqual(props.user.display_name);
}); });
it('does not render Alumni flag if user is active', () => { it('does not render Alumni flag if user is active', () => {
...@@ -69,29 +67,27 @@ describe('UserListItem', () => { ...@@ -69,29 +67,27 @@ describe('UserListItem', () => {
}); });
it('renders description', () => { it('renders description', () => {
expect(wrapper.find('.content').children().at(0).children().at(1).text()).toEqual(`${props.user.role} on ${props.user.team_name}`); expect(wrapper.find('.content').children().at(0).children().at(1).text()).toEqual(`${props.user.role_name} on ${props.user.team_name}`);
}); });
it('renders Alumni flag if user not active', () => { it('renders Alumni flag if user not active', () => {
const wrapper = setup({ const wrapper = setup({
user: { user: {
type: ResourceType.user, type: ResourceType.user,
active: false, display_name: 'firstname lastname',
birthday: null,
department: 'Department',
email: 'test@test.com', email: 'test@test.com',
first_name: '', employee_type: 'fulltime',
github_username: '', first_name: 'firstname',
id: 0, full_name: 'firstname lastname',
last_name: '', github_username: 'githubName',
manager_email: '', is_active: false,
name: 'Test Tester', last_name: 'lastname',
offboarded: true, manager_fullname: 'Test Manager',
office: '', profile_url: 'www.test.com',
role: '', role_name: 'Tester',
start_date: '', slack_id: 'www.slack.com',
team_name: '', team_name: 'QA',
title: '', user_id: 'test0',
} }
}).wrapper; }).wrapper;
expect(wrapper.find('.content').children().at(0).children().at(0).find(Flag).exists()).toBeTruthy(); expect(wrapper.find('.content').children().at(0).children().at(0).find(Flag).exists()).toBeTruthy();
...@@ -102,7 +98,7 @@ describe('UserListItem', () => { ...@@ -102,7 +98,7 @@ describe('UserListItem', () => {
it('getLink returns correct string', () => { it('getLink returns correct string', () => {
const { props, wrapper } = setup(); const { props, wrapper } = setup();
const { user, logging } = props; const { user, logging } = props;
expect(wrapper.instance().getLink()).toEqual(`/user/${user.id}/?index=${logging.index}&source=${logging.source}`); expect(wrapper.instance().getLink()).toEqual(`/user/${user.user_id}/?index=${logging.index}&source=${logging.source}`);
}); });
}); });
}); });
...@@ -4,6 +4,8 @@ ...@@ -4,6 +4,8 @@
border: none; border: none;
> li { > li {
margin-right: 24px;
&.active > a { &.active > a {
&, &,
&:hover { &:hover {
...@@ -21,6 +23,7 @@ ...@@ -21,6 +23,7 @@
color: $text-medium; color: $text-medium;
font-size: $font-size-large; font-size: $font-size-large;
line-height: $line-height-large; line-height: $line-height-large;
margin: -4px 0 0 0;
padding: 4px 8px 12px; padding: 4px 8px 12px;
&:hover { &:hover {
......
...@@ -12,6 +12,9 @@ const configCustom: AppConfigCustom = { ...@@ -12,6 +12,9 @@ const configCustom: AppConfigCustom = {
key: 'default-key', key: 'default-key',
sampleRate: 100, sampleRate: 100,
}, },
indexUsers: {
enabled: false,
}
}; };
export default configCustom; export default configCustom;
...@@ -40,6 +40,9 @@ const configDefault: AppConfig = { ...@@ -40,6 +40,9 @@ const configDefault: AppConfig = {
return `https://DEFAULT_EXPLORE_URL?schema=${schema}&cluster=${cluster}&db=${database}&table=${table}`; return `https://DEFAULT_EXPLORE_URL?schema=${schema}&cluster=${cluster}&db=${database}&table=${table}`;
} }
}, },
indexUsers: {
enabled: false,
}
}; };
export default configDefault; export default configDefault;
...@@ -11,6 +11,7 @@ export interface AppConfig { ...@@ -11,6 +11,7 @@ export interface AppConfig {
navLinks: Array<LinkConfig>; navLinks: Array<LinkConfig>;
tableLineage: TableLineageConfig; tableLineage: TableLineageConfig;
tableProfile: TableProfileConfig; tableProfile: TableProfileConfig;
indexUsers: indexUsersConfig;
} }
export interface AppConfigCustom { export interface AppConfigCustom {
...@@ -20,6 +21,7 @@ export interface AppConfigCustom { ...@@ -20,6 +21,7 @@ export interface AppConfigCustom {
navLinks?: Array<LinkConfig>; navLinks?: Array<LinkConfig>;
tableLineage?: TableLineageConfig; tableLineage?: TableLineageConfig;
tableProfile?: TableProfileConfig; tableProfile?: TableProfileConfig;
indexUsers?: indexUsersConfig;
} }
/** /**
...@@ -80,3 +82,7 @@ export interface LinkConfig { ...@@ -80,3 +82,7 @@ export interface LinkConfig {
target?: string; target?: string;
use_router: boolean; use_router: boolean;
} }
interface indexUsersConfig {
enabled: boolean;
}
...@@ -76,6 +76,11 @@ export default function reducer(state: BookmarkReducerState = initialState, acti ...@@ -76,6 +76,11 @@ export default function reducer(state: BookmarkReducerState = initialState, acti
myBookmarksIsLoaded: true, myBookmarksIsLoaded: true,
}; };
case GetBookmarksForUser.REQUEST:
return {
...state,
bookmarksForUser: [],
};
case GetBookmarksForUser.SUCCESS: case GetBookmarksForUser.SUCCESS:
case GetBookmarksForUser.FAILURE: case GetBookmarksForUser.FAILURE:
return { return {
......
...@@ -74,10 +74,19 @@ describe('bookmark ducks', () => { ...@@ -74,10 +74,19 @@ describe('bookmark ducks', () => {
describe('reducer', () => { describe('reducer', () => {
let testState: BookmarkReducerState; let testState: BookmarkReducerState;
let bookmarkList: Bookmark[]; let bookmarkList: Bookmark[];
beforeAll(() => { beforeEach(() => {
bookmarkList = [ bookmarkList = [
{ {
key: 'bookmarked_key', key: 'bookmarked_key_0',
type: ResourceType.table,
cluster: 'cluster',
database: 'database',
description: 'description',
name: 'name',
schema_name: 'schema_name',
},
{
key: 'bookmarked_key_1',
type: ResourceType.table, type: ResourceType.table,
cluster: 'cluster', cluster: 'cluster',
database: 'database', database: 'database',
...@@ -89,7 +98,7 @@ describe('bookmark ducks', () => { ...@@ -89,7 +98,7 @@ describe('bookmark ducks', () => {
testState = { testState = {
myBookmarks: bookmarkList, myBookmarks: bookmarkList,
myBookmarksIsLoaded: false, myBookmarksIsLoaded: false,
bookmarksForUser: [], bookmarksForUser: bookmarkList,
}; };
}); });
it('should return the existing state if action is not handled', () => { it('should return the existing state if action is not handled', () => {
...@@ -100,9 +109,21 @@ describe('bookmark ducks', () => { ...@@ -100,9 +109,21 @@ describe('bookmark ducks', () => {
}); });
it('should handle RemoveBookmark.SUCCESS', () => { it('should handle RemoveBookmark.SUCCESS', () => {
expect(reducer(testState, { type: RemoveBookmark.SUCCESS, payload: { resourceType: ResourceType.table, resourceKey: 'bookmarked_key' }})).toEqual({ const bookmarkKey = 'bookmarked_key_1';
const action = { type: RemoveBookmark.SUCCESS, payload: { resourceType: ResourceType.table, resourceKey: bookmarkKey }};
const newState = reducer(testState, action);
expect(newState.myBookmarks.find((bookmark) => bookmark.key === bookmarkKey)).toEqual(undefined);
expect(newState).toEqual({
...testState, ...testState,
myBookmarks: [], myBookmarks: [{
key: 'bookmarked_key_0',
type: ResourceType.table,
cluster: 'cluster',
database: 'database',
description: 'description',
name: 'name',
schema_name: 'schema_name',
}],
}); });
}); });
...@@ -121,6 +142,13 @@ describe('bookmark ducks', () => { ...@@ -121,6 +142,13 @@ describe('bookmark ducks', () => {
myBookmarksIsLoaded: true, myBookmarksIsLoaded: true,
}); });
}); });
it('should reset bookmarksForUser on GetBookmarksForUser.REQUEST', () => {
expect(reducer(testState, { type: GetBookmarksForUser.REQUEST, payload: { userId: 'testUser' }})).toEqual({
...testState,
bookmarksForUser: [],
});
});
}); });
describe('sagas', () => { describe('sagas', () => {
......
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { ResourceType, SearchAllOptions } from 'interfaces'; import AppConfig from 'config/config';
import { ResourceType } from 'interfaces';
import { DashboardSearchResults, TableSearchResults, UserSearchResults } from '../types'; import { DashboardSearchResults, TableSearchResults, UserSearchResults } from '../types';
...@@ -15,24 +16,16 @@ interface SearchAPI { ...@@ -15,24 +16,16 @@ interface SearchAPI {
users?: UserSearchResults; users?: UserSearchResults;
}; };
export function searchAll(options: SearchAllOptions, term: string) {
return axios.all([
axios.get(`${BASE_URL}/table?query=${term}&page_index=${options.tableIndex || 0}`),
// TODO PEOPLE - Add request for people here
]).then(axios.spread((tableResponse: AxiosResponse<SearchAPI>) => {
return {
search_term: tableResponse.data.search_term,
tables: tableResponse.data.tables,
}
}));
};
export function searchResource(pageIndex: number, resource: ResourceType, term: string) { export function searchResource(pageIndex: number, resource: ResourceType, term: string) {
if (resource === ResourceType.dashboard ||
(resource === ResourceType.user && !AppConfig.indexUsers.enabled)) {
return Promise.resolve({});
}
return axios.get(`${BASE_URL}/${resource}?query=${term}&page_index=${pageIndex}`) return axios.get(`${BASE_URL}/${resource}?query=${term}&page_index=${pageIndex}`)
.then((response: AxiosResponse<SearchAPI>) => { .then((response: AxiosResponse<SearchAPI>) => {
const { data } = response; const { data } = response;
const ret = { searchTerm: data.search_term }; const ret = { searchTerm: data.search_term };
['tables', 'users'].forEach((key) => { ['tables', 'users', 'dashboards'].forEach((key) => {
if (data[key]) { if (data[key]) {
ret[key] = data[key]; ret[key] = data[key];
} }
......
import { SagaIterator } from 'redux-saga'; import { SagaIterator } from 'redux-saga';
import { call, put, takeEvery } from 'redux-saga/effects'; import { all, call, put, takeEvery } from 'redux-saga/effects';
import { import {
SearchAll, SearchAll,
...@@ -9,14 +9,25 @@ import { ...@@ -9,14 +9,25 @@ import {
} from './types'; } from './types';
import { import {
searchAll, searchResource, searchResource,
} from './api/v0'; } from './api/v0';
import { ResourceType } from 'interfaces/Resources';
export function* searchAllWorker(action: SearchAllRequest): SagaIterator { export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
const { options, term } = action.payload; const { options, term } = action.payload;
try { try {
const searchResults = yield call(searchAll, options, term); const [tableResponse, userResponse, dashboardResponse] = yield all([
yield put({ type: SearchAll.SUCCESS, payload: searchResults }); call(searchResource, options.tableIndex, ResourceType.table, term),
call(searchResource, options.userIndex, ResourceType.user, term),
call(searchResource, options.dashboardIndex, ResourceType.dashboard, term),
]);
const searchAllResponse = {
search_term: term,
tables: tableResponse.tables,
users: userResponse.users,
dashboards: dashboardResponse.dashboards,
};
yield put({ type: SearchAll.SUCCESS, payload: searchAllResponse });
} catch (e) { } catch (e) {
yield put({ type: SearchAll.FAILURE }); yield put({ type: SearchAll.FAILURE });
} }
......
import { PeopleUser } from 'interfaces/User';
export enum ResourceType { export enum ResourceType {
table = "table", table = "table",
user = "user", user = "user",
...@@ -26,59 +28,8 @@ export interface TableResource extends Resource { ...@@ -26,59 +28,8 @@ export interface TableResource extends Resource {
schema_name: string; schema_name: string;
}; };
/** export interface UserResource extends Resource, PeopleUser {
* This is a sample of the user data type which includes all fields.
* We will only need a subset of this for UserResource.
interface User {
active : boolean;
backupCodes: any[]; // Not sure of type
birthday : string | null;
department: string;
department_id: string;
email: string;
employment_type: string;
first_name: string;
github_username: string;
hris_active: boolean;
hris_number: string;
hris_source : string;
id: number;
last_name: string;
manager_email : string;
manager_id: number;
manager_hris_number: string;
mobile_phone : string | null;
name : string;
offboarded : boolean;
office: string;
role: string;
start_date : string;
team_name: string;
title: string;
work_phone: string;
}
*/
// Placeholder until the schema is defined.
export interface UserResource extends Resource {
type: ResourceType.user; type: ResourceType.user;
active : boolean;
birthday : string | null;
department: string;
email: string;
first_name: string;
github_username: string;
id: number;
last_name: string;
manager_email : string;
name : string;
offboarded : boolean;
office: string;
role: string;
start_date : string;
team_name: string;
title: string;
} }
// TODO - Consider just using the 'Resource' type instead // TODO - Consider just using the 'Resource' type instead
......
export interface User { export interface User {
display_name: string; display_name: string;
email: string;
profile_url: string; profile_url: string;
user_id: string;
}; };
// Not a good name, not sure if we can consolidate yet // Not a good name, not sure if we can consolidate yet
...@@ -14,7 +16,9 @@ export interface PeopleUser { ...@@ -14,7 +16,9 @@ export interface PeopleUser {
github_username: string; github_username: string;
is_active: boolean; is_active: boolean;
last_name: string; last_name: string;
manager_fullname: string; // Inconsistent data format from search and metadata return either `manager_email` or `manager_fullname`
manager_email?: string;
manager_fullname?: string;
profile_url: string; profile_url: string;
role_name?: string; role_name?: string;
slack_id: string; slack_id: string;
......
...@@ -194,6 +194,7 @@ class MetadataTest(unittest.TestCase): ...@@ -194,6 +194,7 @@ class MetadataTest(unittest.TestCase):
'github_username': 'githubusername', 'github_username': 'githubusername',
'is_active': True, 'is_active': True,
'last_name': 'Lastname', 'last_name': 'Lastname',
'manager_email': 'manager@email.com',
'manager_fullname': 'Manager Fullname', 'manager_fullname': 'Manager Fullname',
'profile_url': 'https://test-profile-url.com', 'profile_url': 'https://test-profile-url.com',
'role_name': 'SWE', 'role_name': 'SWE',
......
...@@ -5,7 +5,7 @@ import unittest ...@@ -5,7 +5,7 @@ import unittest
from http import HTTPStatus from http import HTTPStatus
from amundsen_application import create_app from amundsen_application import create_app
from amundsen_application.api.search.v0 import _create_url_with_field, SEARCH_ENDPOINT from amundsen_application.api.search.v0 import _create_url_with_field, SEARCH_ENDPOINT, SEARCH_USER_ENDPOINT
local_app = create_app('amundsen_application.config.TestConfig', 'tests/templates') local_app = create_app('amundsen_application.config.TestConfig', 'tests/templates')
...@@ -46,46 +46,40 @@ class SearchTest(unittest.TestCase): ...@@ -46,46 +46,40 @@ class SearchTest(unittest.TestCase):
] ]
self.mock_search_user_results = { self.mock_search_user_results = {
'total_results': 1, 'total_results': 1,
# TODO update data schema
'results': [ 'results': [
{ {
'active': True, 'name': 'First Last',
'birthday': '10-10-2000', 'first_name': 'First',
'department': 'Department', 'last_name': 'Last',
'email': 'mail@address.com', 'team_name': 'Team Name',
'first_name': 'Ash', 'email': 'email@email.com',
'github_username': 'github_user', 'manager_email': 'manager@email.com',
'id': 12345, 'github_username': '',
'last_name': 'Ketchum', 'is_active': True,
'manager_email': 'manager_email', 'employee_type': 'teamMember',
'name': 'Ash Ketchum', 'role_name': 'SWE',
'offboarded': False,
'office': 'Kanto Region',
'role': 'Pokemon Trainer',
'start_date': '05-04-2016',
'team_name': 'Kanto Trainers',
'title': 'Pokemon Master',
} }
] ]
} }
self.expected_parsed_search_user_results = [ self.expected_parsed_search_user_results = [
{ {
'active': True, 'display_name': 'First Last',
'birthday': '10-10-2000', 'email': 'email@email.com',
'department': 'Department', 'employee_type': 'teamMember',
'email': 'mail@address.com', 'first_name': 'First',
'first_name': 'Ash', 'full_name': 'First Last',
'github_username': 'github_user', 'github_username': '',
'id': 12345, 'is_active': True,
'last_name': 'Ketchum', 'last_name': 'Last',
'manager_email': 'manager_email', 'manager_email': 'manager@email.com',
'name': 'Ash Ketchum', 'manager_fullname': None,
'offboarded': False, 'profile_url': '',
'office': 'Kanto Region', 'role_name': 'SWE',
'role': 'Pokemon Trainer', 'slack_id': None,
'start_date': '05-04-2016', 'team_name': 'Team Name',
'team_name': 'Kanto Trainers', 'type': 'user',
'title': 'Pokemon Master', 'user_id': 'email@email.com'
} }
] ]
self.bad_search_results = { self.bad_search_results = {
...@@ -200,3 +194,54 @@ class SearchTest(unittest.TestCase): ...@@ -200,3 +194,54 @@ class SearchTest(unittest.TestCase):
'/search/field/tag/field_val/hive?page_index=1' '/search/field/tag/field_val/hive?page_index=1'
self.assertEqual(_create_url_with_field(search_term=search_term, self.assertEqual(_create_url_with_field(search_term=search_term,
page_index=1), expected) page_index=1), expected)
def test_search_user_fail_if_no_query(self) -> None:
"""
Test request failure if 'query' is not provided in the query string
to the search endpoint
:return:
"""
with local_app.test_client() as test:
response = test.get('/api/search/v0/user', query_string=dict(page_index='0'))
self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
def test_search_user_fail_if_no_page_index(self) -> None:
"""
Test request failure if 'page_index' is not provided in the query string
to the search endpoint
:return:
"""
with local_app.test_client() as test:
response = test.get('/api/search/v0/user', query_string=dict(query='test'))
self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
@responses.activate
def test_search_user_success(self) -> None:
"""
Test request success
:return:
"""
responses.add(responses.GET, local_app.config['SEARCHSERVICE_BASE'] + SEARCH_USER_ENDPOINT,
json=self.mock_search_user_results, status=HTTPStatus.OK)
with local_app.test_client() as test:
response = test.get('/api/search/v0/user', query_string=dict(query='test', page_index='0'))
data = json.loads(response.data)
self.assertEqual(response.status_code, HTTPStatus.OK)
users = data.get('users')
self.assertEqual(users.get('total_results'), self.mock_search_table_results.get('total_results'))
self.assertCountEqual(users.get('results'), self.expected_parsed_search_user_results)
@responses.activate
def test_search_user_fail_on_non_200_response(self) -> None:
"""
Test request failure if search endpoint returns non-200 http code
:return:
"""
responses.add(responses.GET, local_app.config['SEARCHSERVICE_BASE'] + SEARCH_USER_ENDPOINT,
json=self.mock_search_table_results, status=HTTPStatus.INTERNAL_SERVER_ERROR)
with local_app.test_client() as test:
response = test.get('/api/search/v0/user', query_string=dict(query='test', page_index='0'))
self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
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