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