Unverified Commit 525d4323 authored by Daniel's avatar Daniel Committed by GitHub

Add Bookmarks Feature (#156)

- Added the Bookmarks feature where users can save or unsave table resources.
  - BookmarkIcon component renders a filled or unfilled star next to table resources. Clicking on the icon will save or unsave the bookmark. Icons can be rendered in regular or large versions.
  - "My Bookmarks" component is added to the homepage. This displays all of the bookmarked table resources in a paginated format.
- Created a Preloader component that fetches data needed for app-wide functionality.
  - Bookmark and User data is fetched in Preloader
  - Removed user data fetching in NavBar
Added new tests for
- Preloader, BookmarkIcon, BookmarkList, Metadata API
Modified existing tests for
- Table Details, TableListItem,
parent da568b76
......@@ -11,6 +11,7 @@ from amundsen_application.log.action_log import action_logging
from amundsen_application.models.user import load_user, dump_user
from amundsen_application.api.utils.metadata_utils import get_table_key, marshall_table_partial
from amundsen_application.api.utils.request_utils import get_query_param, request_metadata
......@@ -33,15 +34,6 @@ def _get_table_endpoint() -> str:
return table_endpoint
def _get_table_key(args: Dict) -> str:
db = get_query_param(args, 'db')
cluster = get_query_param(args, 'cluster')
schema = get_query_param(args, 'schema')
table = get_query_param(args, 'table')
table_key = '{db}://{cluster}.{schema}/{table}'.format(**locals())
return table_key
@metadata_blueprint.route('/popular_tables', methods=['GET'])
def popular_tables() -> Response:
"""
......@@ -51,21 +43,6 @@ def popular_tables() -> Response:
Schema Defined Here:
https://github.com/lyft/amundsenmetadatalibrary/blob/master/metadata_service/api/popular_tables.py
"""
def _map_results(result: Dict) -> Dict:
table_name = result.get('table_name')
schema_name = result.get('schema')
cluster = result.get('cluster')
db = result.get('database')
return {
'name': table_name,
'schema_name': schema_name,
'cluster': cluster,
'database': db,
'description': result.get('table_description'),
'key': '{0}://{1}.{2}/{3}'.format(db, cluster, schema_name, table_name),
'type': 'table',
}
try:
url = app.config['METADATASERVICE_BASE'] + POPULAR_TABLES_ENDPOINT
response = request_metadata(url=url)
......@@ -75,7 +52,7 @@ def popular_tables() -> Response:
message = 'Success'
response_list = response.json().get('popular_tables')
top4 = response_list[0:min(len(response_list), app.config['POPULAR_TABLE_COUNT'])]
popular_tables = [_map_results(result) for result in top4]
popular_tables = [marshall_table_partial(result) for result in top4]
else:
message = 'Encountered error: Request to metadata service failed with status code ' + str(status_code)
logging.error(message)
......@@ -102,7 +79,7 @@ def get_table_metadata() -> Response:
TODO: Define an interface for envoy_client
"""
try:
table_key = _get_table_key(request.args)
table_key = get_table_key(request.args)
list_item_index = get_query_param(request.args, 'index')
list_item_source = get_query_param(request.args, 'source')
......@@ -179,6 +156,7 @@ def _get_table_metadata(*, table_key: str, index: int, source: str) -> Dict[str,
]
results = {key: response.json().get(key, None) for key in params}
results['key'] = table_key
is_editable = results['schema'] not in app.config['UNEDITABLE_SCHEMAS']
results['is_editable'] = is_editable
......@@ -232,7 +210,7 @@ def _update_table_owner(*, table_key: str, method: str, owner: str) -> Dict[str,
def update_table_owner() -> Response:
try:
args = request.get_json()
table_key = _get_table_key(args)
table_key = get_table_key(args)
owner = get_query_param(args, 'owner')
payload = jsonify(_update_table_owner(table_key=table_key, method=request.method, owner=owner))
......@@ -274,7 +252,7 @@ def get_last_indexed() -> Response:
def get_table_description() -> Response:
try:
table_endpoint = _get_table_endpoint()
table_key = _get_table_key(request.args)
table_key = get_table_key(request.args)
url = '{0}/{1}/description'.format(table_endpoint, table_key)
......@@ -299,7 +277,7 @@ def get_table_description() -> Response:
def get_column_description() -> Response:
try:
table_endpoint = _get_table_endpoint()
table_key = _get_table_key(request.args)
table_key = get_table_key(request.args)
column_name = get_query_param(request.args, 'column_name')
......@@ -333,7 +311,7 @@ def put_table_description() -> Response:
args = request.get_json()
table_endpoint = _get_table_endpoint()
table_key = _get_table_key(args)
table_key = get_table_key(args)
description = get_query_param(args, 'description')
description = ' '.join(description.split())
......@@ -367,7 +345,7 @@ def put_column_description() -> Response:
try:
args = request.get_json()
table_key = _get_table_key(args)
table_key = get_table_key(args)
table_endpoint = _get_table_endpoint()
column_name = get_query_param(args, 'column_name')
......@@ -436,7 +414,7 @@ def update_table_tags() -> Response:
method = request.method
table_endpoint = _get_table_endpoint()
table_key = _get_table_key(args)
table_key = get_table_key(args)
tag = get_query_param(args, 'tag')
......@@ -494,3 +472,69 @@ def get_user() -> Response:
logging.exception(message)
payload = jsonify({'msg': message})
return make_response(payload, HTTPStatus.INTERNAL_SERVER_ERROR)
@metadata_blueprint.route('/user/bookmark', methods=['GET'])
def get_bookmark() -> Response:
"""
Call metadata service to fetch a specified user's bookmarks.
If no 'user_id' is specified, it will fetch the logged-in user's bookmarks
:param user_id: (optional) the user whose bookmarks are fetched.
:return: a JSON object with an array of bookmarks under 'bookmarks' key
"""
try:
user_id = request.args.get('user_id')
if user_id is None:
if app.config['AUTH_USER_METHOD']:
user_id = app.config['AUTH_USER_METHOD'](app).user_id
else:
raise Exception('AUTH_USER_METHOD is not configured')
url = '{0}{1}/{2}/follow/'.format(app.config['METADATASERVICE_BASE'], USER_ENDPOINT, user_id)
response = request_metadata(url=url, method=request.method)
status_code = response.status_code
tables = response.json().get('table')
table_bookmarks = [marshall_table_partial(table) for table in tables]
return make_response(jsonify({'msg': 'success', 'bookmarks': table_bookmarks}), status_code)
except Exception as e:
message = 'Encountered exception: ' + str(e)
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.INTERNAL_SERVER_ERROR)
@metadata_blueprint.route('/user/bookmark', methods=['PUT', 'DELETE'])
def update_bookmark() -> Response:
"""
Call metadata service to PUT or DELETE a bookmark
Params
:param type: Resource type for the bookmarked item. e.g. 'table'
:param key: Resource key for the bookmarked item.
:return:
"""
try:
if app.config['AUTH_USER_METHOD']:
user = app.config['AUTH_USER_METHOD'](app)
else:
raise Exception('AUTH_USER_METHOD is not configured')
args = request.get_json()
resource_type = get_query_param(args, 'type')
resource_key = get_query_param(args, 'key')
url = '{0}{1}/{2}/follow/{3}/{4}'.format(app.config['METADATASERVICE_BASE'],
USER_ENDPOINT,
user.user_id,
resource_type,
resource_key)
response = request_metadata(url=url, method=request.method)
status_code = response.status_code
return make_response(jsonify({'msg': 'success', 'response': response.json()}), status_code)
except Exception as e:
message = 'Encountered exception: ' + str(e)
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.INTERNAL_SERVER_ERROR)
from typing import Dict
from amundsen_application.api.utils.request_utils import get_query_param
def get_table_key(args: Dict) -> str:
"""
Extracts the 'key' for a table resource
:param args: Dict which includes 'db', 'cluster', 'schema', and 'table'
:return: the table key
"""
db = get_query_param(args, 'db')
cluster = get_query_param(args, 'cluster')
schema = get_query_param(args, 'schema')
table = get_query_param(args, 'table')
table_key = '{db}://{cluster}.{schema}/{table}'.format(**locals())
return table_key
def marshall_table_partial(table: Dict) -> Dict:
"""
Forms a short version of a table Dict, with selected fields and an added 'key'
:param table: Dict of partial table object
:return: partial table Dict
TODO - Unify data format returned by search and metadata.
"""
table_name = table.get('table_name', '')
schema_name = table.get('schema', '')
cluster = table.get('cluster', '')
db = table.get('database', '')
return {
'cluster': cluster,
'database': db,
'description': table.get('table_description', ''),
'key': '{0}://{1}.{2}/{3}'.format(db, cluster, schema_name, table_name),
'name': table_name,
'schema_name': schema_name,
'type': 'table',
'last_updated_epoch': table.get('last_updated_epoch', None),
}
import os
from typing import Dict, Set # noqa: F401
from typing import Dict, Optional, Set # noqa: F401
from amundsen_application.tests.test_utils import get_test_user
class Config:
......@@ -50,7 +51,11 @@ class LocalConfig(Config):
# 2. SEARCHSERVICE_REQUEST_HEADERS
REQUEST_HEADERS_METHOD = None
AUTH_USER_METHOD = None
AUTH_USER_METHOD = None # type: Optional[function]
GET_PROFILE_URL = None
MAIL_CLIENT = None
class TestConfig(LocalConfig):
AUTH_USER_METHOD = get_test_user
......@@ -22,6 +22,16 @@ img.icon {
mask-image: url('/static/images/icons/Alert-Triangle.svg');
}
&.icon-bookmark {
-webkit-mask-image: url('/static/images/icons/Favorite.svg');
mask-image: url('/static/images/icons/Favorite.svg');
}
&.icon-bookmark-filled {
-webkit-mask-image: url('/static/images/icons/Favorite-Filled.svg');
mask-image: url('/static/images/icons/Favorite-Filled.svg');
}
&.icon-delete {
-webkit-mask-image: url('/static/images/icons/Trash.svg');
mask-image: url('/static/images/icons/Trash.svg');
......
......@@ -39,7 +39,7 @@ body {
.title-1, .title-2, .title-3,
.subtitle-1, .subtitle-2, .subtitle-3,
.body-1, .body-2, .body-3,
.body-secondary-3, .body-link, .caption {
.body-secondary-3, .body-placeholder , .body-link, .caption {
font-family: $font-family-body;
}
......@@ -98,6 +98,12 @@ body {
font-weight: $font-weight-body-regular;
}
.body-placeholder {
color: $text-medium;
font-size: 14px;
font-weight: $font-weight-body-regular;
}
.body-link {
color: $brand-color-4;
font-size: 14px;
......@@ -116,7 +122,7 @@ body {
}
.helper-text {
color: $text-light;
color: $text-medium;
font-size: 12px;
font-family: $font-family-body;
}
......@@ -2,13 +2,11 @@ import * as React from 'react';
import * as Avatar from 'react-avatar';
import { Link, NavLink, withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import AppConfig from 'config/config';
import { LinkConfig } from 'config/config-types';
import { GlobalState } from 'ducks/rootReducer';
import { getLoggedInUser } from 'ducks/user/reducer';
import { LoggedInUser, GetLoggedInUserRequest } from 'ducks/user/types';
import { LoggedInUser } from 'ducks/user/types';
import { logClick } from "ducks/utilMethods";
import './styles.scss';
......@@ -18,21 +16,13 @@ interface StateFromProps {
loggedInUser: LoggedInUser;
}
interface DispatchFromProps {
getLoggedInUser: () => GetLoggedInUserRequest;
}
export type NavBarProps = StateFromProps & DispatchFromProps;
export type NavBarProps = StateFromProps;
export class NavBar extends React.Component<NavBarProps> {
constructor(props) {
super(props);
}
componentDidMount() {
this.props.getLoggedInUser();
}
generateNavLinks(navLinks: LinkConfig[]) {
return navLinks.map((link, index) => {
if (link.use_router) {
......@@ -81,8 +71,4 @@ export const mapStateToProps = (state: GlobalState) => {
}
};
export const mapDispatchToProps = (dispatch) => {
return bindActionCreators({ getLoggedInUser }, dispatch);
};
export default withRouter(connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(NavBar));
export default withRouter(connect<StateFromProps>(mapStateToProps)(NavBar));
......@@ -4,7 +4,7 @@ import * as Avatar from 'react-avatar';
import { shallow } from 'enzyme';
import { Link, NavLink } from 'react-router-dom';
import { NavBar, NavBarProps, mapDispatchToProps, mapStateToProps } from '../';
import { NavBar, NavBarProps, mapStateToProps } from '../';
import { logClick } from "ducks/utilMethods";
jest.mock('ducks/utilMethods', () => {
......@@ -38,21 +38,12 @@ describe('NavBar', () => {
const setup = (propOverrides?: Partial<NavBarProps>) => {
const props: NavBarProps = {
loggedInUser: globalState.user.loggedInUser,
getLoggedInUser: jest.fn(),
...propOverrides
};
const wrapper = shallow<NavBar>(<NavBar {...props} />);
return { props, wrapper };
};
describe('componentDidMount', () => {
it('calls props.getLoggedInUser', () => {
const { props, wrapper } = setup();
wrapper.instance().componentDidMount();
expect(props.getLoggedInUser).toHaveBeenCalled();
});
});
describe('generateNavLinks', () => {
let content;
beforeAll(() => {
......@@ -124,19 +115,6 @@ describe('NavBar', () => {
});
});
describe('mapDispatchToProps', () => {
let dispatch;
let result;
beforeEach(() => {
dispatch = jest.fn(() => Promise.resolve());
result = mapDispatchToProps(dispatch);
});
it('sets getLoggedInUser on the props', () => {
expect(result.getLoggedInUser).toBeInstanceOf(Function);
});
});
describe('mapStateToProps', () => {
let result;
......
......@@ -9,6 +9,7 @@ import { RouteComponentProps } from 'react-router';
import SearchBar from './SearchBar';
import SearchList from './SearchList';
import BookmarkList from 'components/common/Bookmark/BookmarkList'
import InfoButton from 'components/common/InfoButton';
import { ResourceType, TableResource } from 'components/common/ResourceListItem/types';
import TabsComponent from 'components/common/Tabs';
......@@ -166,6 +167,7 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState
};
return (
<div className="search-list-container">
<BookmarkList />
<div className="popular-tables-header">
<label className="title-1">{POPULAR_TABLES_LABEL}</label>
<InfoButton infoText={POPULAR_TABLES_INFO_TEXT}/>
......@@ -208,7 +210,7 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState
if (total_results === 0 && searchTerm.length > 0) {
return (
<div className="search-list-container">
<div className="search-error">
<div className="search-error body-placeholder">
{SEARCH_ERROR_MESSAGE_PREFIX}<i>{ searchTerm }</i>{SEARCH_ERROR_MESSAGE_INFIX}{tabLabel.toLowerCase()}{SEARCH_ERROR_MESSAGE_SUFFIX}
</div>
</div>
......@@ -219,7 +221,7 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState
if (page_index < 0 || startIndex > total_results) {
return (
<div className="search-list-container">
<div className="search-error">
<div className="search-error body-placeholder">
{PAGE_INDEX_ERROR_MESSAGE}
</div>
</div>
......
......@@ -38,7 +38,6 @@
}
.search-error {
color: $text-medium;
text-align: center;
}
......
......@@ -617,11 +617,11 @@ describe('SearchPage', () => {
content = shallow(wrapper.instance().renderPopularTables());
});
it('renders correct label for content', () => {
expect(content.children().at(0).find('label').text()).toEqual(POPULAR_TABLES_LABEL);
expect(content.children().at(1).find('label').text()).toEqual(POPULAR_TABLES_LABEL);
});
it('renders InfoButton with correct props', () => {
expect(content.children().at(0).find(InfoButton).props()).toMatchObject({
expect(content.children().at(1).find(InfoButton).props()).toMatchObject({
infoText: POPULAR_TABLES_INFO_TEXT,
});
});
......
......@@ -33,6 +33,7 @@ import { PreviewQueryParams, TableMetadata } from './types';
// TODO: Use css-modules instead of 'import'
import './styles.scss';
import BookmarkIcon from "components/common/Bookmark/BookmarkIcon";
export interface StateFromProps {
isLoading: boolean;
......@@ -70,6 +71,7 @@ export class TableDetail extends React.Component<TableDetailProps & RouteCompone
database: '',
is_editable: false,
is_view: false,
key: '',
schema: '',
table_name: '',
table_description: '',
......@@ -333,6 +335,7 @@ export class TableDetail extends React.Component<TableDetailProps & RouteCompone
<div className="detail-header col-xs-12 col-md-7 col-lg-8">
<h1 className="detail-header-text">
{ `${data.schema}.${data.table_name}` }
<BookmarkIcon bookmarkKey={ this.props.tableData.key } large={ true }/>
</h1>
{
data.is_view && <Flag text="Table View" labelStyle="primary" />
......
......@@ -77,6 +77,7 @@ export interface TableMetadata {
database: string;
is_editable: boolean;
is_view: boolean;
key: string;
schema: string;
table_name: string;
table_description: string;
......
import * as React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { addBookmark, removeBookmark } from "ducks/bookmark/reducer";
import { AddBookmarkRequest, RemoveBookmarkRequest } from "ducks/bookmark/types";
import { GlobalState } from "ducks/rootReducer";
import './styles.scss'
interface StateFromProps {
isBookmarked: boolean;
}
interface DispatchFromProps {
addBookmark: (key: string, type: string) => AddBookmarkRequest,
removeBookmark: (key: string, type: string) => RemoveBookmarkRequest,
}
interface OwnProps {
bookmarkKey: string;
large?: boolean;
}
export type BookmarkIconProps = StateFromProps & DispatchFromProps & OwnProps;
export class BookmarkIcon extends React.Component<BookmarkIconProps> {
public static defaultProps: Partial<OwnProps> = {
large: false,
};
constructor(props) {
super(props);
}
handleClick = (event: React.MouseEvent<HTMLElement>) => {
event.stopPropagation();
event.preventDefault();
if (this.props.isBookmarked) {
this.props.removeBookmark(this.props.bookmarkKey, 'table');
} else {
this.props.addBookmark(this.props.bookmarkKey, 'table');
}
};
render() {
return (
<div className={"bookmark-icon " + (this.props.large ? "bookmark-large" : "")} onClick={ this.handleClick }>
<img className={"icon " + (this.props.isBookmarked ? "icon-bookmark-filled" : "icon-bookmark")}/>
</div>
)
}
}
export const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => {
return {
bookmarkKey: ownProps.bookmarkKey,
isBookmarked: state.bookmarks.myBookmarks.some((bookmark) => bookmark.key === ownProps.bookmarkKey),
};
};
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ addBookmark, removeBookmark } , dispatch);
};
export default connect<StateFromProps, DispatchFromProps, OwnProps>(mapStateToProps, mapDispatchToProps)(BookmarkIcon);
@import 'variables';
.bookmark-icon {
border-radius: 50%;
cursor: pointer;
display: inline-block;
height: 32px;
margin: -6px 0 -8px 4px;
padding: 4px;
vertical-align: top;
width: 32px;
&.bookmark-large {
height: 40px;
margin: 8px 0 0 4px;
width: 40px;
.icon {
height: 32px;
-webkit-mask-size: 32px;
mask-size: 32px;
width: 32px;
}
}
&:hover,
&:focus {
background-color: $gray-lighter;
}
.icon {
margin: 0;
&.icon-bookmark {
&,
&:hover,
&:focus {
background-color: $gray-light !important;
}
}
&.icon-bookmark-filled {
&,
&:hover,
&:focus {
background-color: gold !important;
}
}
}
}
import * as React from 'react';
import { shallow } from 'enzyme';
import globalState from 'fixtures/globalState';
import { BookmarkIcon, BookmarkIconProps, mapDispatchToProps, mapStateToProps } from "../";
describe('BookmarkIcon', () => {
const setup = (propOverrides?: Partial<BookmarkIconProps>) => {
const props: BookmarkIconProps = {
bookmarkKey: 'someKey',
isBookmarked: true,
large: false,
addBookmark: jest.fn(),
removeBookmark: jest.fn(),
...propOverrides
};
const wrapper = shallow<BookmarkIcon>(<BookmarkIcon {...props} />);
return { props, wrapper };
};
describe('handleClick', () => {
const clickEvent = {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
};
it ('stops propagation and prevents default', () => {
const { props, wrapper } = setup();
wrapper.find('div').simulate('click', clickEvent);
expect(clickEvent.preventDefault).toHaveBeenCalled();
expect(clickEvent.stopPropagation).toHaveBeenCalled();
});
it('bookmarks an unbookmarked resource', () => {
const { props, wrapper } = setup({
isBookmarked: false,
});
wrapper.find('div').simulate('click', clickEvent);
expect(props.addBookmark).toHaveBeenCalled();
});
it('unbookmarks a bookmarked resource', () => {
const { props, wrapper } = setup({
isBookmarked: true
});
wrapper.find('div').simulate('click', clickEvent);
expect(props.removeBookmark).toHaveBeenCalled();
});
});
describe('render', () => {
it('renders an empty bookmark when not bookmarked', () => {
const { props, wrapper } = setup({ isBookmarked: false });
expect(wrapper.find('.icon-bookmark').exists()).toBe(true);
});
it('renders a filled star when bookmarked', () => {
const { props, wrapper } = setup({ isBookmarked: true});
expect(wrapper.find('.icon-bookmark-filled').exists()).toBe(true);
});
it('renders a large star when specified', () => {
const { props, wrapper } = setup({ large: true});
expect(wrapper.find('.bookmark-large').exists()).toBe(true);
});
});
});
describe('mapDispatchToProps', () => {
let dispatch;
let props;
beforeAll(() => {
dispatch = jest.fn(() => Promise.resolve());
props = mapDispatchToProps(dispatch);
});
it('sets addBookmark on the props', () => {
expect(props.addBookmark).toBeInstanceOf(Function);
});
it('sets removeBookmark on the props', () => {
expect(props.removeBookmark).toBeInstanceOf(Function);
});
});
describe('mapStateToProps', () => {
it('sets the bookmarkKey on the props', () => {
const ownProps = { bookmarkKey: "test_bookmark_key" };
const result = mapStateToProps(globalState, ownProps);
expect(result.bookmarkKey).toEqual(ownProps.bookmarkKey);
});
it('sets isBookmarked to false when the resource key is not bookmarked', () => {
const ownProps = { bookmarkKey: "not_bookmarked_key" };
const result = mapStateToProps(globalState, ownProps);
expect(result.isBookmarked).toBe(false);
});
it('sets isBookmarked to true when the resource key is bookmarked', () => {
const ownProps = { bookmarkKey: "bookmarked_key" };
const result = mapStateToProps(globalState, ownProps);
expect(result.isBookmarked).toBe(true);
});
});
export const ITEMS_PER_PAGE = 4;
export const PAGINATION_PAGE_RANGE = 10;
export const BOOKMARK_TITLE = "My Bookmarks";
export const EMPTY_BOOKMARK_MESSAGE = "You don't have any bookmarks. Use the star icon to save a bookmark.";
import * as React from 'react';
import { connect } from 'react-redux';
import Pagination from 'react-js-pagination';
import { GlobalState } from "ducks/rootReducer";
import './styles.scss'
import { Bookmark } from "ducks/bookmark/types";
import ResourceListItem from "components/common/ResourceListItem";
import {
ITEMS_PER_PAGE,
EMPTY_BOOKMARK_MESSAGE,
PAGINATION_PAGE_RANGE,
BOOKMARK_TITLE,
} from "./constants";
interface StateFromProps {
myBookmarks: Bookmark[];
isLoaded: boolean;
}
export type BookmarkListProps = StateFromProps;
interface BookmarkListState {
activePage: number;
}
export class BookmarkList extends React.Component<BookmarkListProps, BookmarkListState> {
constructor(props) {
super(props);
this.state = { activePage: 0 };
}
onPaginationChange = (pageNumber: number) => {
const index = pageNumber - 1;
this.setState({ activePage: index });
};
render() {
if (!this.props.isLoaded) {
return null;
}
const totalBookmarks = this.props.myBookmarks.length;
const startIndex = this.state.activePage * ITEMS_PER_PAGE;
const displayedBookmarks = this.props.myBookmarks.slice(startIndex, startIndex + ITEMS_PER_PAGE);
if (totalBookmarks === 0) {
return (
<div className="bookmark-list">
<div className="title-1">{ BOOKMARK_TITLE }</div>
<div className="empty-message body-placeholder">
{ EMPTY_BOOKMARK_MESSAGE }
</div>
</div>
)
}
return (
<div className="bookmark-list">
<div className="title-1">{ BOOKMARK_TITLE }</div>
<ul className="list-group">
{
displayedBookmarks.map((resource, index) => {
const logging = { index: index + this.state.activePage * ITEMS_PER_PAGE , source: 'Bookmarks' };
return <ResourceListItem item={ resource } logging={ logging } key={ index } />;
})
}
</ul>
{
totalBookmarks > ITEMS_PER_PAGE &&
<div className="pagination-container">
<Pagination
activePage={ this.state.activePage + 1 }
itemsCountPerPage={ ITEMS_PER_PAGE }
totalItemsCount={ totalBookmarks }
pageRangeDisplayed={ PAGINATION_PAGE_RANGE }
onChange={ this.onPaginationChange }
/>
</div>
}
</div>
)
}
}
export const mapStateToProps = (state: GlobalState) => {
return {
myBookmarks: state.bookmarks.myBookmarks,
isLoaded: state.bookmarks.myBookmarksIsLoaded,
};
};
export default connect<StateFromProps>(mapStateToProps)(BookmarkList);
@import 'variables';
.bookmark-list {
margin: 32px 0;
.title-1 {
margin-bottom: 32px;
}
.empty-message,
.pagination-container {
display: flex;
justify-content: center;
}
}
import * as React from 'react';
import { shallow } from 'enzyme';
import globalState from 'fixtures/globalState';
import { ResourceType } from 'components/common/ResourceListItem/types';
import Pagination from 'react-js-pagination';
import ResourceListItem from 'components/common/ResourceListItem'
import { BookmarkList, BookmarkListProps, mapStateToProps } from "../";
import {
ITEMS_PER_PAGE,
EMPTY_BOOKMARK_MESSAGE,
BOOKMARK_TITLE,
} from '../constants';
describe('BookmarkList', () => {
const setStateSpy = jest.spyOn(BookmarkList.prototype, 'setState');
const setup = (propOverrides?: Partial<BookmarkListProps>) => {
const props: BookmarkListProps = {
myBookmarks: [
{
key: 'bookmark-1',
type: ResourceType.table,
cluster: 'cluster',
database: 'database',
description: 'description',
name: 'name',
schema_name: 'schema_name',
},
{
key: 'bookmark-2',
type: ResourceType.table,
cluster: 'cluster',
database: 'database',
description: 'description',
name: 'name',
schema_name: 'schema_name',
},
{
key: 'bookmark-3',
type: ResourceType.table,
cluster: 'cluster',
database: 'database',
description: 'description',
name: 'name',
schema_name: 'schema_name',
},
{
key: 'bookmark-4',
type: ResourceType.table,
cluster: 'cluster',
database: 'database',
description: 'description',
name: 'name',
schema_name: 'schema_name',
},
{
key: 'bookmark-5',
type: ResourceType.table,
cluster: 'cluster',
database: 'database',
description: 'description',
name: 'name',
schema_name: 'schema_name',
},
{
key: 'bookmark-6',
type: ResourceType.table,
cluster: 'cluster',
database: 'database',
description: 'description',
name: 'name',
schema_name: 'schema_name',
},
],
isLoaded: true,
...propOverrides
};
const wrapper = shallow<BookmarkList>(<BookmarkList {...props} />);
return { props, wrapper };
};
describe('onPaginationChange', () => {
it('reduces the page index by 1 and updates state', () => {
const { props, wrapper } = setup();
const pageNumber = 3;
wrapper.instance().onPaginationChange(pageNumber);
expect(setStateSpy).toHaveBeenCalledWith({ activePage: pageNumber - 1 });
});
});
describe('Render', () => {
it('Renders nothing until ready', () => {
const { props, wrapper } = setup({ isLoaded: false });
expect(wrapper.html()).toBeFalsy();
});
it('Renders the correct title', () => {
const { props, wrapper } = setup();
expect(wrapper.find('.title-1').text()).toEqual(BOOKMARK_TITLE);
});
it('Shows the EMPTY_BOOKMARK_MESSAGE when there are no bookmarks', () => {
const { props, wrapper } = setup({ myBookmarks: [] });
expect(wrapper.find('.empty-message').text()).toEqual(EMPTY_BOOKMARK_MESSAGE);
});
it('Renders at most ITEMS_PER_PAGE bookmarks at once', () => {
const { props, wrapper } = setup();
expect(wrapper.find(ResourceListItem).length).toEqual(ITEMS_PER_PAGE)
});
it('Renders a pagination widget when there are more than ITEMS_PER_PAGE bookmarks', () => {
const { props, wrapper } = setup();
expect(wrapper.find(Pagination).exists()).toBe(true)
});
it('Hides a pagination widget when there are fewer than ITEMS_PER_PAGE bookmarks', () => {
const { props, wrapper } = setup({
myBookmarks: [{
key: 'bookmark-1',
type: ResourceType.table,
cluster: 'cluster',
database: 'database',
description: 'description',
name: 'name',
schema_name: 'schema_name',
}]
});
expect(wrapper.find(Pagination).exists()).toBe(false)
});
});
});
describe('mapStateToProps', () => {
let result;
beforeAll(() => {
result = mapStateToProps(globalState);
});
it('sets myBookmarks on the props', () => {
expect(result.myBookmarks).toEqual(globalState.bookmarks.myBookmarks);
});
it('sets myBookmarksIsLoaded on the props', () => {
expect(result.isLoaded).toEqual(globalState.bookmarks.myBookmarksIsLoaded);
});
});
import * as React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { getBookmarks } from "ducks/bookmark/reducer";
import { GetBookmarksRequest } from "ducks/bookmark/types";
import { getLoggedInUser } from "ducks/user/reducer";
import { GetLoggedInUserRequest } from "ducks/user/types";
interface DispatchFromProps {
getLoggedInUser: () => GetLoggedInUserRequest;
getBookmarks: () => GetBookmarksRequest;
}
export type PreloaderProps = DispatchFromProps;
export class Preloader extends React.Component<PreloaderProps>{
constructor(props) {
super(props)
}
componentDidMount() {
this.props.getLoggedInUser();
this.props.getBookmarks();
}
render() { return null; }
}
export const mapDispatchToProps = (dispatch) => {
return bindActionCreators({ getLoggedInUser, getBookmarks }, dispatch);
};
export default connect<{}, DispatchFromProps>(null, mapDispatchToProps)(Preloader);
import * as React from 'react';
import { shallow } from 'enzyme';
import { Preloader, PreloaderProps, mapDispatchToProps } from '../';
describe('Preloader', () => {
const setup = (propOverrides?: Partial<PreloaderProps>) => {
const props: PreloaderProps = {
getLoggedInUser: jest.fn(),
getBookmarks: jest.fn(),
...propOverrides
};
const wrapper = shallow<Preloader>(<Preloader { ...props } />);
return { props, wrapper };
};
describe('componentDidMount', () => {
it('calls props.getLoggedInUser', () => {
const { props, wrapper } = setup();
wrapper.instance().componentDidMount();
expect(props.getLoggedInUser).toHaveBeenCalled();
});
it('calls props.getLoggedInUser', () => {
const { props, wrapper } = setup();
wrapper.instance().componentDidMount();
expect(props.getBookmarks).toHaveBeenCalled();
});
});
describe('render', () => {
it('does not render any elements', () => {
const { wrapper } = setup();
expect(wrapper.html()).toBeFalsy();
});
});
});
describe('mapDispatchToProps', () => {
let dispatch;
let props;
beforeAll(() => {
dispatch = jest.fn(() => Promise.resolve());
props = mapDispatchToProps(dispatch);
});
it('sets getBookmarks on props', () => {
expect(props.getBookmarks).toBeInstanceOf(Function);
});
it('sets getLoggedInUser on props', () => {
expect(props.getLoggedInUser).toBeInstanceOf(Function);
});
});
......@@ -2,6 +2,7 @@ import * as React from 'react';
import { Link } from 'react-router-dom';
import { LoggingParams, TableResource} from '../types';
import BookmarkIcon from "components/common/Bookmark/BookmarkIcon";
export interface TableListItemProps {
table: TableResource;
......@@ -35,7 +36,12 @@ class TableListItem extends React.Component<TableListItemProps, {}> {
<img className="icon icon-database icon-color" />
<div className="content">
<div className={ hasLastUpdated? "col-sm-9 col-md-10" : "col-sm-12"}>
<div className="title-2 truncated">{ `${table.schema_name}.${table.name}`}</div>
<div className="resource-name title-2">
<div className="truncated">
{ `${table.schema_name}.${table.name}`}
</div>
<BookmarkIcon bookmarkKey={ this.props.table.key }/>
</div>
<div className="body-secondary-3 truncated">{ table.description }</div>
</div>
{
......
......@@ -3,7 +3,7 @@ import * as React from 'react';
import { shallow } from 'enzyme';
import { Link } from 'react-router-dom';
import BookmarkIcon from 'components/common/Bookmark/BookmarkIcon';
import TableListItem, { TableListItemProps } from '../';
import { ResourceType } from '../../types';
......@@ -41,7 +41,11 @@ describe('TableListItem', () => {
});
it('renders table name', () => {
expect(wrapper.find('.content').children().at(0).children().at(0).text()).toEqual('tableSchema.tableName');
expect(wrapper.find('.resource-name').children().at(0).text()).toEqual('tableSchema.tableName');
});
it('renders a bookmark icon', () => {
expect(wrapper.find(BookmarkIcon).exists()).toBe(true);
});
it('renders table description', () => {
......
......@@ -20,5 +20,10 @@
.content {
width: 100%;
min-width: 0; /* Needed to support `white-space: nowrap` */
.resource-name .truncated {
display: inline-block;
max-width: calc(100% - 32px);
}
}
}
import axios, { AxiosResponse, AxiosError } from 'axios';
const API_PATH = '/api/metadata/v0';
export function addBookmark(resourceKey: string, resourceType: string) {
return axios.put(`${API_PATH}/user/bookmark`, { type: resourceType, key: resourceKey })
.then((response: AxiosResponse) => {
return response.data;
});
}
export function removeBookmark(resourceKey: string, resourceType: string) {
return axios.delete(`${API_PATH}/user/bookmark`, { data: { type: resourceType, key: resourceKey }})
.then((response: AxiosResponse) => {
return response.data;
});
}
export function getBookmarks(userId?: string) {
return axios.get(`${API_PATH}/user/bookmark` + (userId ? `?user_id=${userId}` : ''))
.then((response: AxiosResponse) => {
return response.data;
});
}
import {
AddBookmark,
AddBookmarkRequest,
AddBookmarkResponse,
Bookmark,
GetBookmarks,
GetBookmarksForUser,
GetBookmarksForUserRequest,
GetBookmarksForUserResponse,
GetBookmarksRequest,
GetBookmarksResponse,
RemoveBookmark,
RemoveBookmarkRequest,
RemoveBookmarkResponse,
} from "./types";
export type BookmarkReducerAction =
AddBookmarkRequest | AddBookmarkResponse |
GetBookmarksRequest | GetBookmarksResponse |
GetBookmarksForUserRequest | GetBookmarksForUserResponse |
RemoveBookmarkRequest | RemoveBookmarkResponse;
export function addBookmark(resourceKey: string, resourceType: string): AddBookmarkRequest {
return {
resourceKey,
resourceType,
type: AddBookmark.ACTION,
}
}
export function removeBookmark(resourceKey: string, resourceType: string): RemoveBookmarkRequest {
return {
resourceKey,
resourceType,
type: RemoveBookmark.ACTION,
}
}
export function getBookmarks(): GetBookmarksRequest {
return {
type: GetBookmarks.ACTION
}
}
export function getBookmarksForUser(userId: string): GetBookmarksForUserRequest {
return {
userId,
type: GetBookmarksForUser.ACTION,
}
}
export interface BookmarkReducerState {
myBookmarks: Bookmark[];
myBookmarksIsLoaded: boolean;
bookmarksForUser: Bookmark[];
}
const initialState: BookmarkReducerState = {
myBookmarks: [],
myBookmarksIsLoaded: false,
bookmarksForUser: [],
};
export default function reducer(state: BookmarkReducerState = initialState, action: BookmarkReducerAction): BookmarkReducerState {
switch(action.type) {
case RemoveBookmark.SUCCESS:
const { resourceKey } = action.payload;
return {
...state,
myBookmarks: state.myBookmarks.filter((bookmark) => bookmark.key !== resourceKey)
};
case AddBookmark.SUCCESS:
case GetBookmarks.SUCCESS:
return {
...state,
myBookmarks: action.payload,
myBookmarksIsLoaded: true,
};
case AddBookmark.FAILURE:
case GetBookmarks.FAILURE:
case GetBookmarksForUser.SUCCESS:
case GetBookmarksForUser.FAILURE:
case RemoveBookmark.FAILURE:
default:
return state;
}
}
import { call, put, takeEvery } from 'redux-saga/effects';
import { SagaIterator } from 'redux-saga';
import {
AddBookmark,
AddBookmarkRequest,
GetBookmarks,
GetBookmarksForUser,
GetBookmarksForUserRequest,
GetBookmarksRequest,
RemoveBookmark,
RemoveBookmarkRequest
} from "./types";
import {
addBookmark,
removeBookmark,
getBookmarks,
} from "./api/v0";
// AddBookmarks
export function* addBookmarkWorker(action: AddBookmarkRequest): SagaIterator {
let response;
const { resourceKey, resourceType } = action;
try {
yield call(addBookmark, resourceKey, resourceType);
// TODO - Consider adding the newly bookmarked resource directly to local store. This would save a round trip.
response = yield call(getBookmarks);
yield put({ type: AddBookmark.SUCCESS, payload: response.bookmarks });
} catch(e) {
yield put({ type: AddBookmark.FAILURE, payload: response });
}
}
export function* addBookmarkWatcher(): SagaIterator {
yield takeEvery(AddBookmark.ACTION , addBookmarkWorker)
}
// RemoveBookmarks
export function* removeBookmarkWorker(action: RemoveBookmarkRequest): SagaIterator {
let response;
const { resourceKey, resourceType } = action;
try {
response = yield call(removeBookmark, resourceKey, resourceType);
yield put({ type: RemoveBookmark.SUCCESS, payload: { resourceKey, resourceType }});
} catch(e) {
yield put({ type: RemoveBookmark.FAILURE, payload: response });
}
}
export function* removeBookmarkWatcher(): SagaIterator {
yield takeEvery(RemoveBookmark.ACTION , removeBookmarkWorker)
}
// GetBookmarks
export function* getBookmarksWorker(action: GetBookmarksRequest): SagaIterator {
let response;
try {
response = yield call(getBookmarks);
yield put({ type: GetBookmarks.SUCCESS, payload: response.bookmarks });
} catch(e) {
yield put({ type: GetBookmarks.FAILURE, payload: response });
}
}
export function* getBookmarkskWatcher(): SagaIterator {
yield takeEvery(GetBookmarks.ACTION, getBookmarksWorker)
}
// GetBookmarksForUser
export function* getBookmarkForUserWorker(action: GetBookmarksForUserRequest): SagaIterator {
let response;
const { userId } = action;
try {
response = yield call(getBookmarks, userId);
yield put({ type: GetBookmarksForUser.SUCCESS, payload: { userId, bookmarks: response.bookmarks } });
} catch(e) {
yield put({ type: GetBookmarksForUser.FAILURE, payload: response });
}
}
export function* getBookmarksForUserWatcher(): SagaIterator {
yield takeEvery(GetBookmarksForUser.ACTION, getBookmarkForUserWorker)
}
import { TableResource } from "components/common/ResourceListItem/types";
export type Bookmark = TableResource;
// AddBookmark
export enum AddBookmark {
ACTION = 'amundsen/bookmark/ADD',
SUCCESS = 'amundsen/bookmark/ADD_SUCCESS',
FAILURE = 'amundsen/bookmark/ADD_FAILURE',
}
export interface AddBookmarkRequest {
type: AddBookmark.ACTION;
resourceKey: string;
resourceType: string;
}
export interface AddBookmarkResponse {
type: AddBookmark.SUCCESS | AddBookmark.FAILURE;
payload: Bookmark[];
}
// RemoveBookmark
export enum RemoveBookmark {
ACTION = 'amundsen/bookmark/REMOVE',
SUCCESS = 'amundsen/bookmark/REMOVE_SUCCESS',
FAILURE = 'amundsen/bookmark/REMOVE_FAILURE',
}
export interface RemoveBookmarkRequest {
type: RemoveBookmark.ACTION;
resourceKey: string;
resourceType: string;
}
export interface RemoveBookmarkResponse {
type: RemoveBookmark.SUCCESS | RemoveBookmark.FAILURE;
payload: {
resourceKey: string;
resourceType: string;
};
}
// GetBookmarks - Get all bookmarks for the logged in user. This result will be cached
export enum GetBookmarks {
ACTION = 'amundsen/bookmark/GET',
SUCCESS = 'amundsen/bookmark/GET_SUCCESS',
FAILURE = 'amundsen/bookmark/GET_FAILURE',
}
export interface GetBookmarksRequest {
type: GetBookmarks.ACTION;
}
export interface GetBookmarksResponse {
type: GetBookmarks.SUCCESS | GetBookmarks.FAILURE;
payload: Bookmark[];
}
// GetBookmarksForUser - Get all bookmarks for a specified user
export enum GetBookmarksForUser {
ACTION = 'amundsen/bookmark/GET_FOR_USER',
SUCCESS = 'amundsen/bookmark/GET_FOR_USER_SUCCESS',
FAILURE = 'amundsen/bookmark/GET_FOR_USER_FAILURE',
}
export interface GetBookmarksForUserRequest {
type: GetBookmarksForUser.ACTION;
userId: string;
}
export interface GetBookmarksForUserResponse {
type: GetBookmarksForUser.SUCCESS | GetBookmarksForUser.FAILURE;
payload: {
bookmarks: Bookmark[];
userId: string;
};
}
......@@ -7,9 +7,11 @@ import search, { SearchReducerState } from './search/reducer';
import tableMetadata, { TableMetadataReducerState } from './tableMetadata/reducer';
import allTags, { AllTagsReducerState } from './allTags/reducer';
import user, { UserReducerState } from './user/reducer';
import bookmarks, { BookmarkReducerState } from "./bookmark/reducer";
export interface GlobalState {
announcements: AnnouncementsReducerState;
bookmarks: BookmarkReducerState;
feedback: FeedbackReducerState;
popularTables: PopularTablesReducerState;
search: SearchReducerState;
......@@ -20,6 +22,7 @@ export interface GlobalState {
export default combineReducers<GlobalState>({
announcements,
bookmarks,
feedback,
popularTables,
search,
......
......@@ -3,6 +3,13 @@ import { all } from 'redux-saga/effects';
// AnnouncementPage
import { announcementsGetWatcher } from "./announcements/sagas";
import {
addBookmarkWatcher,
getBookmarksForUserWatcher,
getBookmarkskWatcher,
removeBookmarkWatcher
} from "ducks/bookmark/sagas";
// FeedbackForm
import { submitFeedbackWatcher } from './feedback/sagas';
......@@ -33,6 +40,11 @@ export default function* rootSaga() {
yield all([
// AnnouncementPage
announcementsGetWatcher(),
// Bookmarks
addBookmarkWatcher(),
getBookmarksForUserWatcher(),
getBookmarkskWatcher(),
removeBookmarkWatcher(),
// FeedbackForm
submitFeedbackWatcher(),
// SearchPage
......
......@@ -104,6 +104,7 @@ const initialTableDataState: TableMetadata = {
database: '',
is_editable: false,
is_view: false,
key: '',
schema: '',
table_name: '',
table_description: '',
......
......@@ -16,6 +16,21 @@ const globalState: GlobalState = {
html_content: '<div>Just kidding</div>',
}],
},
bookmarks: {
myBookmarks: [
{
key: 'bookmarked_key',
type: ResourceType.table,
cluster: 'cluster',
database: 'database',
description: 'description',
name: 'name',
schema_name: 'schema_name',
},
],
myBookmarksIsLoaded: false,
bookmarksForUser: [],
},
feedback: {
sendState: SendingState.IDLE,
},
......@@ -82,6 +97,7 @@ const globalState: GlobalState = {
database: '',
is_editable: false,
is_view: false,
key: '',
schema: '',
table_name: '',
table_description: '',
......
......@@ -15,6 +15,7 @@ import Feedback from './components/Feedback';
import Footer from './components/Footer';
import NavBar from './components/NavBar';
import NotFoundPage from './components/NotFoundPage';
import Preloader from "components/common/Preloader";
import ProfilePage from './components/ProfilePage';
import SearchPage from './components/SearchPage';
import TableDetail from './components/TableDetail';
......@@ -33,6 +34,7 @@ ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<div id="main">
<Preloader/>
<NavBar />
<Switch>
<Route path="/table_detail/:cluster/:db/:schema/:table" component={TableDetail} />
......
from flask import current_app as app
from amundsen_application.models.user import load_user, User
TEST_USER_ID = 'test_user_id'
def get_test_user(app: app) -> User:
user_info = {
'email': 'test@email.com',
'user_id': TEST_USER_ID,
'first_name': 'Firstname',
'last_name': 'Lastname',
'full_name': 'Firstname Lastname',
}
return load_user(user_info)
......@@ -8,7 +8,9 @@ from amundsen_application import create_app
from amundsen_application.api.metadata.v0 import \
TABLE_ENDPOINT, LAST_INDEXED_ENDPOINT, POPULAR_TABLES_ENDPOINT, TAGS_ENDPOINT, USER_ENDPOINT
local_app = create_app('amundsen_application.config.LocalConfig')
from amundsen_application.tests.test_utils import TEST_USER_ID
local_app = create_app('amundsen_application.config.TestConfig')
class MetadataTest(unittest.TestCase):
......@@ -33,6 +35,7 @@ class MetadataTest(unittest.TestCase):
'key': 'test_db://test_cluster.test_schema/test_table',
'schema_name': 'test_schema',
'type': 'table',
'last_updated_epoch': None,
}
]
self.mock_metadata = {
......@@ -71,6 +74,7 @@ class MetadataTest(unittest.TestCase):
},
}
self.expected_parsed_metadata = {
'key': 'table_key',
'cluster': 'test_cluster',
'database': 'test_db',
'schema': 'test_schema',
......@@ -144,56 +148,96 @@ class MetadataTest(unittest.TestCase):
}
self.expected_parsed_tags = [
{
"tag_count": 3,
"tag_name": "tag_0"
'tag_count': 3,
'tag_name': 'tag_0'
},
{
"tag_count": 4,
"tag_name": "tag_1"
'tag_count': 4,
'tag_name': 'tag_1'
},
{
"tag_count": 5,
"tag_name": "tag_2"
'tag_count': 5,
'tag_name': 'tag_2'
},
{
"tag_count": 10,
"tag_name": "tag_3"
'tag_count': 10,
'tag_name': 'tag_3'
},
{
"tag_count": 1,
"tag_name": "tag_4"
'tag_count': 1,
'tag_name': 'tag_4'
}
]
self.mock_user = {
"email": "test@test.com",
"employee_type": "FTE",
"first_name": "Firstname",
"full_name": "Firstname Lastname",
"github_username": "githubusername",
"is_active": True,
"last_name": "Lastname",
"manager_fullname": "Manager Fullname",
"role_name": "SWE",
"slack_id": "slackuserid",
"team_name": "Amundsen",
"user_id": "testuserid",
'email': 'test@test.com',
'employee_type': 'FTE',
'first_name': 'Firstname',
'full_name': 'Firstname Lastname',
'github_username': 'githubusername',
'is_active': True,
'last_name': 'Lastname',
'manager_fullname': 'Manager Fullname',
'role_name': 'SWE',
'slack_id': 'slackuserid',
'team_name': 'Amundsen',
'user_id': 'testuserid',
}
self.expected_parsed_user = {
"display_name": "Firstname Lastname",
"email": "test@test.com",
"employee_type": "FTE",
"first_name": "Firstname",
"full_name": "Firstname Lastname",
"github_username": "githubusername",
"is_active": True,
"last_name": "Lastname",
"manager_fullname": "Manager Fullname",
"profile_url": "https://test-profile-url.com",
"role_name": "SWE",
"slack_id": "slackuserid",
"team_name": "Amundsen",
"user_id": "testuserid",
'display_name': 'Firstname Lastname',
'email': 'test@test.com',
'employee_type': 'FTE',
'first_name': 'Firstname',
'full_name': 'Firstname Lastname',
'github_username': 'githubusername',
'is_active': True,
'last_name': 'Lastname',
'manager_fullname': 'Manager Fullname',
'profile_url': 'https://test-profile-url.com',
'role_name': 'SWE',
'slack_id': 'slackuserid',
'team_name': 'Amundsen',
'user_id': 'testuserid',
}
self.get_bookmark_response = {
'table': [
{
'cluster': 'cluster',
'database': 'database',
'schema': 'schema',
'table_name': 'table_name_0',
'table_description': 'description',
},
{
'cluster': 'cluster',
'database': 'database',
'schema': 'schema',
'table_name': 'table_name_1',
'table_description': 'description',
},
]
}
self.expected_parsed_bookmarks = [
{
'cluster': 'cluster',
'database': 'database',
'description': 'description',
'key': 'database://cluster.schema/table_name_0',
'last_updated_epoch': None,
'name': 'table_name_0',
'schema_name': 'schema',
'type': 'table',
},
{
'cluster': 'cluster',
'database': 'database',
'description': 'description',
'key': 'database://cluster.schema/table_name_1',
'last_updated_epoch': None,
'name': 'table_name_1',
'schema_name': 'schema',
'type': 'table',
},
]
@responses.activate
def test_popular_tables_success(self) -> None:
......@@ -531,3 +575,80 @@ class MetadataTest(unittest.TestCase):
data = json.loads(response.data)
self.assertEquals(response.status_code, HTTPStatus.OK)
self.assertCountEqual(data.get('user'), self.expected_parsed_user)
@responses.activate
def test_get_bookmark(self) -> None:
"""
Test get_bookmark with no user specified
"""
url = '{0}{1}/{2}/follow/'.format(local_app.config['METADATASERVICE_BASE'], USER_ENDPOINT, TEST_USER_ID)
responses.add(responses.GET, url, json=self.get_bookmark_response, status=HTTPStatus.OK)
with local_app.test_client() as test:
response = test.get('/api/metadata/v0/user/bookmark')
data = json.loads(response.data)
self.assertEquals(response.status_code, HTTPStatus.OK)
self.assertCountEqual(data.get('bookmarks'), self.expected_parsed_bookmarks)
@responses.activate
def test_get_bookmark_for_user(self) -> None:
"""
Test get_bookmark with a specified user
"""
specified_user = 'other_user'
url = '{0}{1}/{2}/follow/'.format(local_app.config['METADATASERVICE_BASE'], USER_ENDPOINT, specified_user)
responses.add(responses.GET, url, json=self.get_bookmark_response, status=HTTPStatus.OK)
with local_app.test_client() as test:
response = test.get('/api/metadata/v0/user/bookmark', query_string=dict(user_id=specified_user))
data = json.loads(response.data)
self.assertEquals(response.status_code, HTTPStatus.OK)
self.assertCountEqual(data.get('bookmarks'), self.expected_parsed_bookmarks)
@responses.activate
def test_put_bookmark(self) -> None:
"""
Test update_bookmark with a PUT request
"""
resource_type = 'table'
key = 'database://cluster.schema/table_name_1'
url = '{0}{1}/{2}/follow/{3}/{4}'.format(local_app.config['METADATASERVICE_BASE'],
USER_ENDPOINT,
TEST_USER_ID,
resource_type,
key)
responses.add(responses.PUT, url, json={}, status=HTTPStatus.OK)
with local_app.test_client() as test:
response = test.put(
'/api/metadata/v0/user/bookmark',
json={
'type': resource_type,
'key': key,
})
self.assertEquals(response.status_code, HTTPStatus.OK)
@responses.activate
def test_delete_bookmark(self) -> None:
"""
Test update_bookmark with a DELETE request
"""
resource_type = 'table'
key = 'database://cluster.schema/table_name_1'
url = '{0}{1}/{2}/follow/{3}/{4}'.format(local_app.config['METADATASERVICE_BASE'],
USER_ENDPOINT,
TEST_USER_ID,
resource_type,
key)
responses.add(responses.DELETE, url, json={}, status=HTTPStatus.OK)
with local_app.test_client() as test:
response = test.delete(
'/api/metadata/v0/user/bookmark',
json={
'type': resource_type,
'key': key,
})
self.assertEquals(response.status_code, HTTPStatus.OK)
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