Unverified Commit 7b09a2fa authored by Daniel's avatar Daniel Committed by GitHub

Merge feature people (#71)

* Added support for UserListItems (#43)

* Added support for UserListItem
- Restyled ResourceListItem to support multiple columns
- Added timestamp for TableListItem
- Added UserListItem
- Added $text-medium and $text-light

* Cleanup and PR feedback
- Added Alumni Flag to UserListItem
- Changed usage of Flag LabelStyle to exclude the 'label-' prefix
- Added more space for 'Frequent Users' in TableListItem

* Skeleton implementation of ProfilePage w/ mock data (#44)

* Skeleton implementation w/ mock data

* Some tweaks

* Fix lint errors

* Switch profile icons to btn-flat-icon

* currentUser -> loggedInUser & profilePageUser -> profileUser

* Make a Breadcrumb component

* tweak

* Added support for UserListItems (#43)

* Added support for UserListItem
- Restyled ResourceListItem to support multiple columns
- Added timestamp for TableListItem
- Added UserListItem
- Added $text-medium and $text-light

* Cleanup and PR feedback
- Added Alumni Flag to UserListItem
- Changed usage of Flag LabelStyle to exclude the 'label-' prefix
- Added more space for 'Frequent Users' in TableListItem

* Skeleton implementation w/ mock data

* Some tweaks

* Fix lint errors

* Switch profile icons to btn-flat-icon

* currentUser -> loggedInUser & profilePageUser -> profileUser

* Make a Breadcrumb component

* tweak

* Updated search APIs to support multiple types (#48)

* Updated search APIs to support multiple types
- User search just returns mocked data

* Updated search API tests

* Add breadcrumbs + fix to tableData state management (#49)

* Update search to support multiple resource types (#51)

* Added Tabs to search results
*Split executeSearch into two actions:
- `searchAll` will search all resource types. The options allow you to search different page indexes for different resources. This is useful for loading up &selectedTab=users&tabIndex=5, since you don't necessarily want to fetch page 5 for all tabs.
- `searchResource` will run a search on a single resource type. This is primarily used for search pagination.
The URL should always reflect the current state of the search and can be refreshed or shared to maintain state.

* Improved state management with window URL and user actions
(search submit, pagination, tab change)

* changed 'last_updated' to 'last_updated_epoch' (#53)

- Conditionally hide the timestamp when not present

* Feature people design overhaul (#55)

Overhaul of UI
- NavBar - Resized to 48px, rework bottom border with box-shadow
- Search Bar - Simplify DOM elements, height 60px, font 24px bold, narrower in md, lg screens.
- Container top margin at 96, 64, 32px for lg, md, sm screens.
- Search results has 16px top and bottom
- Consolidated buttons classes to: btn-primary btn-default
- Reworked Tag buttons and labels
- Fixed panel border radiuses
- Fixed NavBar active state highlighting

* Rebase Design Overhaul from Master (#58)

* Overhaul of UI Part 1 (#54)

Overhaul of UI
- NavBar - Resized to 48px, rework bottom border with box-shadow
- Search Bar - Simplify DOM elements, height 60px, font 24px bold, narrower in md, lg screens.
- Container top margin at 96, 64, 32px for lg, md, sm screens.
- Search results has 16px top and bottom
- Consolidated buttons classes to: btn-primary btn-default
- Reworked Tag buttons and labels
- Fixed panel border radiuses
- Fixed NavBar active state highlighting

* Design Overhaul pt2 (#57)

* Design Overhaul pt2
- Standardized button colors, sizes, icons
- Text colors split into $text-dark, $text-medium, and $text-light
- Fixed some styles on search bar
- Updated TagInfo and TagInput
- Made popover colors darker

* Added support for UserListItems (#43)

* Added support for UserListItem
- Restyled ResourceListItem to support multiple columns
- Added timestamp for TableListItem
- Added UserListItem
- Added $text-medium and $text-light

* Cleanup and PR feedback
- Added Alumni Flag to UserListItem
- Changed usage of Flag LabelStyle to exclude the 'label-' prefix
- Added more space for 'Frequent Users' in TableListItem

* Skeleton implementation of ProfilePage w/ mock data (#44)

* Skeleton implementation w/ mock data

* Some tweaks

* Fix lint errors

* Switch profile icons to btn-flat-icon

* currentUser -> loggedInUser & profilePageUser -> profileUser

* Make a Breadcrumb component

* tweak

* Added support for UserListItems (#43)

* Added support for UserListItem
- Restyled ResourceListItem to support multiple columns
- Added timestamp for TableListItem
- Added UserListItem
- Added $text-medium and $text-light

* Cleanup and PR feedback
- Added Alumni Flag to UserListItem
- Changed usage of Flag LabelStyle to exclude the 'label-' prefix
- Added more space for 'Frequent Users' in TableListItem

* Skeleton implementation w/ mock data

* Some tweaks

* Fix lint errors

* Switch profile icons to btn-flat-icon

* currentUser -> loggedInUser & profilePageUser -> profileUser

* Make a Breadcrumb component

* tweak

* Updated search APIs to support multiple types (#48)

* Updated search APIs to support multiple types
- User search just returns mocked data

* Updated search API tests

* Add breadcrumbs + fix to tableData state management (#49)

* Update search to support multiple resource types (#51)

* Added Tabs to search results
*Split executeSearch into two actions:
- `searchAll` will search all resource types. The options allow you to search different page indexes for different resources. This is useful for loading up &selectedTab=users&tabIndex=5, since you don't necessarily want to fetch page 5 for all tabs.
- `searchResource` will run a search on a single resource type. This is primarily used for search pagination.
The URL should always reflect the current state of the search and can be refreshed or shared to maintain state.

* Improved state management with window URL and user actions
(search submit, pagination, tab change)

* changed 'last_updated' to 'last_updated_epoch' (#53)

- Conditionally hide the timestamp when not present

* Feature people design overhaul (#55)

Overhaul of UI
- NavBar - Resized to 48px, rework bottom border with box-shadow
- Search Bar - Simplify DOM elements, height 60px, font 24px bold, narrower in md, lg screens.
- Container top margin at 96, 64, 32px for lg, md, sm screens.
- Search results has 16px top and bottom
- Consolidated buttons classes to: btn-primary btn-default
- Reworked Tag buttons and labels
- Fixed panel border radiuses
- Fixed NavBar active state highlighting

* Fix some issues with rebasing ui overhaul from master

* Pull changes from #56 into feature/people (#60)

* Overhaul of UI Part 1 (#54)

Overhaul of UI
- NavBar - Resized to 48px, rework bottom border with box-shadow
- Search Bar - Simplify DOM elements, height 60px, font 24px bold, narrower in md, lg screens.
- Container top margin at 96, 64, 32px for lg, md, sm screens.
- Search results has 16px top and bottom
- Consolidated buttons classes to: btn-primary btn-default
- Reworked Tag buttons and labels
- Fixed panel border radiuses
- Fixed NavBar active state highlighting

* Design Overhaul pt2 (#57)

* Design Overhaul pt2
- Standardized button colors, sizes, icons
- Text colors split into $text-dark, $text-medium, and $text-light
- Fixed some styles on search bar
- Updated TagInfo and TagInput
- Made popover colors darker

* Consolidate Containers (#56)

* Consolidate un-nested containers

* Consolidate TableDetail related containers

* Consolidate FeedbackForms

* Fix merge mistakes

* Update styles

* - Disabled/Hid most Amundsen people related features
- Fixed list-group-item padding issues and border
- Adjusted column stats hover colors
- Adjusted Breadcrum styles
parent a7518e83
...@@ -553,3 +553,34 @@ def update_table_tags() -> Response: ...@@ -553,3 +553,34 @@ def update_table_tags() -> Response:
logging.exception(message) logging.exception(message)
payload = jsonify({'msg': message}) payload = jsonify({'msg': message})
return make_response(payload, HTTPStatus.INTERNAL_SERVER_ERROR) return make_response(payload, HTTPStatus.INTERNAL_SERVER_ERROR)
# TODO: Implement real support
@metadata_blueprint.route('/user', methods=['GET'])
def get_user() -> Response:
try:
user_id = get_query_param(request.args, 'user_id')
user_info = {
'first_name': 'Firstname',
'last_name': 'Lastname',
'email': 'test@test.com',
'display_name': 'Firstname Lastname',
'profile_url': 'https://github.com/lyft/amundsenfrontendlibrary',
'user_id': user_id,
'github_name': 'lyft',
'is_active': True,
'manager_name': 'Roald Amundsen',
'role_name': 'Software Engineer',
'slack_url': 'https://slack.com',
'team_name': 'Amundsen Team',
}
if user_id == 'alumni':
user_info['is_active'] = False
status_code = HTTPStatus.OK
payload = jsonify({'user': user_info})
return make_response(payload, status_code)
except Exception as e:
message = 'Encountered exception: ' + str(e)
logging.exception(message)
return make_response(jsonify({'user': {}, 'msg': message}), HTTPStatus.INTERNAL_SERVER_ERROR)
...@@ -53,8 +53,8 @@ def _validate_search_term(*, search_term: str, page_index: int) -> Optional[Resp ...@@ -53,8 +53,8 @@ def _validate_search_term(*, search_term: str, page_index: int) -> Optional[Resp
return None return None
@search_blueprint.route('/', methods=['GET']) @search_blueprint.route('/table', methods=['GET'])
def search() -> Response: def search_table() -> Response:
search_term = get_query_param(request.args, 'query', 'Endpoint takes a "query" parameter') 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') page_index = get_query_param(request.args, 'page_index', 'Endpoint takes a "page_index" parameter')
...@@ -62,7 +62,20 @@ def search() -> Response: ...@@ -62,7 +62,20 @@ def search() -> Response:
if error_response is not None: if error_response is not None:
return error_response return error_response
results_dict = _search(search_term=search_term, page_index=page_index) results_dict = _search_table(search_term=search_term, page_index=page_index)
return make_response(jsonify(results_dict), results_dict.get('status_code', HTTPStatus.INTERNAL_SERVER_ERROR))
@search_blueprint.route('/user', methods=['GET'])
def search_user() -> Response:
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')
error_response = _validate_search_term(search_term=search_term, page_index=int(page_index))
if error_response is not None:
return error_response
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)) return make_response(jsonify(results_dict), results_dict.get('status_code', HTTPStatus.INTERNAL_SERVER_ERROR))
...@@ -98,21 +111,119 @@ def _create_url_with_field(*, search_term: str, page_index: int) -> str: ...@@ -98,21 +111,119 @@ def _create_url_with_field(*, search_term: str, page_index: int) -> str:
return url return url
# TODO - Implement these functions @action_logging
def _search_tables(*, search_term: str, page_index: int) -> Dict[str, Any]: def _search_user(*, search_term: str, page_index: int) -> Dict[str, Any]:
return {} """
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
def _search_dashboards(*, search_term: str, page_index: int) -> Dict[str, Any]: TODO: Define an interface for envoy_client
return {} """
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),
}
users = {
'page_index': int(page_index),
'results': [],
'total_results': 0,
}
def _search_people(*, search_term: str, page_index: int) -> Dict[str, Any]: results_dict = {
return {} 'search_term': search_term,
'msg': 'Success',
'status_code': HTTPStatus.OK,
'users': users,
}
# TEST CODE
users['total_results'] = 3
users['results'] = [
{
'type': 'user',
'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',
},
{
'type': 'user',
'active': True,
'birthday': '06-01-2000',
'department': 'Department',
'email': 'mail@address.com',
'first_name': 'Gary',
'github_username': 'github_user',
'id': 12345,
'last_name': 'Oak',
'manager_email': 'manager_email',
'name': 'Gary Oak',
'offboarded': False,
'office': 'Kanto Region',
'role': 'Pokemon Trainer',
'start_date': '05-04-2016',
'team_name': 'Kanto Trainers',
'title': 'Pokemon Master',
},
{
'type': 'user',
'active': False,
'birthday': '06-01-60',
'department': 'Department',
'email': 'mail@address.com',
'first_name': 'Professor',
'github_username': 'github_user',
'id': 12345,
'last_name': 'Oak',
'manager_email': 'manager_email',
'name': 'Professor Oak',
'offboarded': False,
'office': 'Kanto Region',
'role': 'Scientist',
'start_date': '05-04-2016',
'team_name': 'Team Oak',
'title': 'Pokemon Researcher',
},
]
return results_dict
@action_logging @action_logging
def _search(*, search_term: str, page_index: int) -> Dict[str, Any]: def _search_table(*, search_term: str, page_index: int) -> Dict[str, Any]:
""" """
call the search service endpoint and return matching results call the search service endpoint and return matching results
:return: a json output containing search results array as 'results' :return: a json output containing search results array as 'results'
...@@ -131,7 +242,7 @@ def _search(*, search_term: str, page_index: int) -> Dict[str, Any]: ...@@ -131,7 +242,7 @@ def _search(*, search_term: str, page_index: int) -> Dict[str, Any]:
'description': result.get('description', None), 'description': result.get('description', None),
'database': result.get('database', None), 'database': result.get('database', None),
'schema_name': result.get('schema_name', None), 'schema_name': result.get('schema_name', None),
'last_updated': result.get('last_updated', None), 'last_updated_epoch': result.get('last_updated_epoch', None),
} }
tables = { tables = {
...@@ -183,3 +294,8 @@ def _search(*, search_term: str, page_index: int) -> Dict[str, Any]: ...@@ -183,3 +294,8 @@ def _search(*, search_term: str, page_index: int) -> Dict[str, Any]:
results_dict['msg'] = message results_dict['msg'] = message
logging.exception(message) logging.exception(message)
return results_dict return results_dict
# TODO - Implement
def _search_dashboard(*, search_term: str, page_index: int) -> Dict[str, Any]:
return {}
...@@ -11,11 +11,11 @@ LOGGER = logging.getLogger(__name__) ...@@ -11,11 +11,11 @@ LOGGER = logging.getLogger(__name__)
blueprint = Blueprint('api', __name__, url_prefix='/api') blueprint = Blueprint('api', __name__, url_prefix='/api')
@blueprint.route('/current_user', methods=['GET']) @blueprint.route('/auth_user', methods=['GET'])
def current_user() -> Response: def current_user() -> Response:
if (app.config['CURRENT_USER_METHOD']): if (app.config['AUTH_USER_METHOD']):
user = app.config['CURRENT_USER_METHOD'](app) user = app.config['AUTH_USER_METHOD'](app)
else: else:
user = load_user({'display_name': '*'}) user = load_user({'user_id': 'undefined', 'display_name': '*'})
return user.to_json() return user.to_json()
...@@ -39,7 +39,7 @@ class LocalConfig(Config): ...@@ -39,7 +39,7 @@ class LocalConfig(Config):
'http://{LOCAL_HOST}:{PORT}/tags'.format(LOCAL_HOST=LOCAL_HOST, PORT=METADATA_PORT) 'http://{LOCAL_HOST}:{PORT}/tags'.format(LOCAL_HOST=LOCAL_HOST, PORT=METADATA_PORT)
METADATASERVICE_REQUEST_HEADERS = None METADATASERVICE_REQUEST_HEADERS = None
CURRENT_USER_METHOD = None AUTH_USER_METHOD = None
GET_PROFILE_URL = None GET_PROFILE_URL = None
MAIL_CLIENT = None MAIL_CLIENT = None
...@@ -76,8 +76,8 @@ def _build_metrics(func_name: str, ...@@ -76,8 +76,8 @@ def _build_metrics(func_name: str,
metrics['pos_args_json'] = json.dumps(args) metrics['pos_args_json'] = json.dumps(args)
metrics['keyword_args_json'] = json.dumps(kwargs) metrics['keyword_args_json'] = json.dumps(kwargs)
if flask_app.config['CURRENT_USER_METHOD']: if flask_app.config['AUTH_USER_METHOD']:
metrics['user'] = flask_app.config['CURRENT_USER_METHOD'](flask_app).email metrics['user'] = flask_app.config['AUTH_USER_METHOD'](flask_app).email
else: else:
metrics['user'] = getpass.getuser() metrics['user'] = getpass.getuser()
......
...@@ -13,18 +13,36 @@ redesign how User handles names ...@@ -13,18 +13,36 @@ redesign how User handles names
class User: class User:
# TODO: alphabetize after we have the real params
def __init__(self, def __init__(self,
first_name: str = None, first_name: str = None,
last_name: str = None, last_name: str = None,
email: str = None, email: str = None,
display_name: str = None, display_name: str = None,
profile_url: str = None) -> None: profile_url: str = None,
user_id: str = None,
github_name: str = None,
is_active: bool = True,
manager_name: str = None,
role_name: str = None,
slack_url: str = None,
team_name: str = None) -> None:
self.first_name = first_name self.first_name = first_name
self.last_name = last_name self.last_name = last_name
self.email = email self.email = email
self.display_name = display_name self.display_name = display_name
self.profile_url = profile_url self.profile_url = profile_url
# TODO: modify the following names as needed after backend support is implemented
self.user_id = user_id
self.github_name = github_name
self.is_active = is_active
self.manager_name = manager_name
self.role_name = role_name
self.slack_url = slack_url
self.team_name = team_name
# TODO: frequent_used, bookmarked, & owned resources
def to_json(self) -> Response: def to_json(self) -> Response:
user_info = dump_user(self) user_info = dump_user(self)
return jsonify(user_info) return jsonify(user_info)
...@@ -37,6 +55,14 @@ class UserSchema(Schema): ...@@ -37,6 +55,14 @@ class UserSchema(Schema):
display_name = fields.Str(required=True) display_name = fields.Str(required=True)
profile_url = fields.Str(allow_none=True) profile_url = fields.Str(allow_none=True)
user_id = fields.Str(required=True)
github_name = fields.Str(allow_none=True)
is_active = fields.Bool(allow_none=True)
manager_name = fields.Str(allow_none=True)
role_name = fields.Str(allow_none=True)
slack_url = fields.Str(allow_none=True)
team_name = fields.Str(allow_none=True)
@pre_load @pre_load
def generate_display_name(self, data: Dict) -> Dict: def generate_display_name(self, data: Dict) -> Dict:
if data.get('display_name', None): if data.get('display_name', None):
...@@ -67,6 +93,8 @@ class UserSchema(Schema): ...@@ -67,6 +93,8 @@ class UserSchema(Schema):
def validate_user(self, data: Dict) -> None: def validate_user(self, data: Dict) -> None:
if not data.get('display_name', None): if not data.get('display_name', None):
raise ValidationError('One or more must be provided: "first_name", "last_name", "email", "display_name"') raise ValidationError('One or more must be provided: "first_name", "last_name", "email", "display_name"')
if not data.get('user_id', None):
raise ValidationError('"user_id" must be provided')
def load_user(user_data: Dict) -> User: def load_user(user_data: Dict) -> User:
......
...@@ -31,6 +31,11 @@ img.icon { ...@@ -31,6 +31,11 @@ img.icon {
mask-image: url('/static/images/icons/Down.svg'); mask-image: url('/static/images/icons/Down.svg');
} }
&.icon-github {
-webkit-mask-image: url('/static/images/icons/github.svg');
mask-image: url('/static/images/icons/github.svg');
}
&.icon-left { &.icon-left {
-webkit-mask-image: url('/static/images/icons/Left.svg'); -webkit-mask-image: url('/static/images/icons/Left.svg');
mask-image: url('/static/images/icons/Left.svg'); mask-image: url('/static/images/icons/Left.svg');
...@@ -41,6 +46,11 @@ img.icon { ...@@ -41,6 +46,11 @@ img.icon {
mask-image: url('/static/images/icons/Loader.svg'); mask-image: url('/static/images/icons/Loader.svg');
} }
&.icon-mail {
-webkit-mask-image: url('/static/images/icons/mail.svg');
mask-image: url('/static/images/icons/mail.svg');
}
&.icon-plus-circle { &.icon-plus-circle {
-webkit-mask-image: url('/static/images/icons/Plus-Circle.svg'); -webkit-mask-image: url('/static/images/icons/Plus-Circle.svg');
mask-image: url('/static/images/icons/Plus-Circle.svg'); mask-image: url('/static/images/icons/Plus-Circle.svg');
...@@ -61,10 +71,20 @@ img.icon { ...@@ -61,10 +71,20 @@ img.icon {
mask-image: url('/static/images/icons/Search.svg'); mask-image: url('/static/images/icons/Search.svg');
} }
&.icon-slack {
-webkit-mask-image: url('/static/images/icons/slack.svg');
mask-image: url('/static/images/icons/slack.svg');
}
&.icon-up { &.icon-up {
-webkit-mask-image: url('/static/images/icons/Up.svg'); -webkit-mask-image: url('/static/images/icons/Up.svg');
mask-image: url('/static/images/icons/Up.svg'); mask-image: url('/static/images/icons/Up.svg');
} }
&.icon-users {
-webkit-mask-image: url('/static/images/icons/users.svg');
mask-image: url('/static/images/icons/users.svg');
}
} }
.disabled, .disabled,
......
...@@ -6,8 +6,7 @@ ...@@ -6,8 +6,7 @@
border-left: none; border-left: none;
border-right: none; border-right: none;
cursor: pointer; cursor: pointer;
padding-left: 4px; padding: 0;
padding-right: 4px;
&:hover { &:hover {
border-color: $gray; border-color: $gray;
......
...@@ -4,4 +4,3 @@ ...@@ -4,4 +4,3 @@
@import 'variables-custom'; @import 'variables-custom';
// Bootstrap Default Values // Bootstrap Default Values
@import '~bootstrap-sass/assets/stylesheets/bootstrap/variables'; @import '~bootstrap-sass/assets/stylesheets/bootstrap/variables';
...@@ -35,7 +35,6 @@ form { ...@@ -35,7 +35,6 @@ form {
margin-bottom: 0; margin-bottom: 0;
} }
input { input {
&::-webkit-input-placeholder, &::-webkit-input-placeholder,
&::-moz-placeholder, &::-moz-placeholder,
...@@ -46,3 +45,8 @@ input { ...@@ -46,3 +45,8 @@ input {
} }
} }
.truncated {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-github"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-mail"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-slack"><path d="M14.5 10c-.83 0-1.5-.67-1.5-1.5v-5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5z"></path><path d="M20.5 10H19V8.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"></path><path d="M9.5 14c.83 0 1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5S8 21.33 8 20.5v-5c0-.83.67-1.5 1.5-1.5z"></path><path d="M3.5 14H5v1.5c0 .83-.67 1.5-1.5 1.5S2 16.33 2 15.5 2.67 14 3.5 14z"></path><path d="M14 14.5c0-.83.67-1.5 1.5-1.5h5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-5c-.83 0-1.5-.67-1.5-1.5z"></path><path d="M15.5 19H14v1.5c0 .83.67 1.5 1.5 1.5s1.5-.67 1.5-1.5-.67-1.5-1.5-1.5z"></path><path d="M10 9.5C10 8.67 9.33 8 8.5 8h-5C2.67 8 2 8.67 2 9.5S2.67 11 3.5 11h5c.83 0 1.5-.67 1.5-1.5z"></path><path d="M8.5 5H10V3.5C10 2.67 9.33 2 8.5 2S7 2.67 7 3.5 7.67 5 8.5 5z"></path></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-users"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
\ No newline at end of file
import * as React from 'react'; import * as React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import Avatar from 'react-avatar'; import Avatar from 'react-avatar';
import { Link, NavLink } from 'react-router-dom'; import { Link, NavLink } from 'react-router-dom';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
import AppConfig from '../../../config/config'; import AppConfig from '../../../config/config';
import { GlobalState } from "../../ducks/rootReducer"; import { GlobalState } from "../../ducks/rootReducer";
import { executeSearch } from '../../ducks/search/reducer'; import { getLoggedInUser } from "../../ducks/user/reducer";
import { getPopularTables } from '../../ducks/popularTables/reducer'; import { LoggedInUser, GetLoggedInUserRequest } from "../../ducks/user/types";
import { getCurrentUser } from "../../ducks/user/reducer";
import { CurrentUser, GetCurrentUserRequest } from "../../ducks/user/types";
import './styles.scss'; import './styles.scss';
// Props // Props
interface StateFromProps { interface StateFromProps {
currentUser: CurrentUser; loggedInUser: LoggedInUser;
} }
interface DispatchFromProps { interface DispatchFromProps {
getCurrentUser: () => GetCurrentUserRequest; getLoggedInUser: () => GetLoggedInUserRequest;
} }
type NavBarProps = StateFromProps & DispatchFromProps; type NavBarProps = StateFromProps & DispatchFromProps;
// State // State
interface NavBarState { interface NavBarState {
currentUser: CurrentUser; loggedInUser: LoggedInUser;
} }
export class NavBar extends React.Component<NavBarProps, NavBarState> { export class NavBar extends React.Component<NavBarProps, NavBarState> {
...@@ -36,17 +33,17 @@ export class NavBar extends React.Component<NavBarProps, NavBarState> { ...@@ -36,17 +33,17 @@ export class NavBar extends React.Component<NavBarProps, NavBarState> {
super(props); super(props);
this.state = { this.state = {
currentUser: this.props.currentUser, loggedInUser: this.props.loggedInUser,
}; };
} }
static getDerivedStateFromProps(nextProps, prevState) { static getDerivedStateFromProps(nextProps, prevState) {
const { currentUser } = nextProps; const { loggedInUser } = nextProps;
return { currentUser }; return { loggedInUser };
} }
componentDidMount() { componentDidMount() {
this.props.getCurrentUser(); this.props.getLoggedInUser();
} }
render() { render() {
...@@ -73,8 +70,11 @@ export class NavBar extends React.Component<NavBarProps, NavBarState> { ...@@ -73,8 +70,11 @@ export class NavBar extends React.Component<NavBarProps, NavBarState> {
}) })
} }
{ {
this.state.currentUser && this.state.loggedInUser &&
<Avatar name={this.state.currentUser.display_name} size={32} round={true}/> // TODO PEOPLE - Uncomment when enabling people
//<Link to={`/user/${this.state.loggedInUser.user_id}`}>
<Avatar name={this.state.loggedInUser.display_name} size={32} round={true} />
//</Link>
} }
</div> </div>
</div> </div>
...@@ -86,12 +86,12 @@ export class NavBar extends React.Component<NavBarProps, NavBarState> { ...@@ -86,12 +86,12 @@ export class NavBar extends React.Component<NavBarProps, NavBarState> {
export const mapStateToProps = (state: GlobalState) => { export const mapStateToProps = (state: GlobalState) => {
return { return {
currentUser: state.user.currentUser, loggedInUser: state.user.loggedInUser,
} }
}; };
export const mapDispatchToProps = (dispatch) => { const mapDispatchToProps = (dispatch) => {
return bindActionCreators({ getCurrentUser }, dispatch); return bindActionCreators({ getLoggedInUser }, dispatch);
}; };
export default withRouter(connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(NavBar)); export default withRouter(connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(NavBar));
...@@ -4,11 +4,13 @@ import * as DocumentTitle from 'react-document-title'; ...@@ -4,11 +4,13 @@ import * as DocumentTitle from 'react-document-title';
// TODO: Use css-modules instead of 'import' // TODO: Use css-modules instead of 'import'
import './styles.scss'; import './styles.scss';
import Breadcrumb from "../common/Breadcrumb";
const NotFoundPage: React.SFC<any> = () => { const NotFoundPage: React.SFC<any> = () => {
return ( return (
<DocumentTitle title="404 Page Not Found - Amundsen"> <DocumentTitle title="404 Page Not Found - Amundsen">
<div className="not-found-page"> <div className="container not-found-page">
<Breadcrumb path='/' text='Home'/>
<h1>404 Page Not Found</h1> <h1>404 Page Not Found</h1>
<span className="glyphicon glyphicon-exclamation-sign" /> <span className="glyphicon glyphicon-exclamation-sign" />
</div> </div>
......
@import 'variables'; @import 'variables';
.not-found-page { .not-found-page {
width: 70%; h1 {
text-align: center; text-align: center;
}
span {
text-align: center;
width: 100%;
}
} }
.glyphicon.glyphicon-exclamation-sign { .glyphicon.glyphicon-exclamation-sign {
......
import * as React from 'react';
import * as DocumentTitle from 'react-document-title';
import Avatar from 'react-avatar';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import LoadingSpinner from '../common/LoadingSpinner';
import { GlobalState } from "../../ducks/rootReducer";
import { getUserById } from "../../ducks/user/reducer";
import { LoggedInUser, GetUserRequest } from "../../ducks/user/types";
import Breadcrumb from "../common/Breadcrumb";
import Flag from "../common/Flag";
import Tabs from "../common/Tabs";
import './styles.scss';
interface StateFromProps {
user: LoggedInUser;
}
interface DispatchFromProps {
getUserById: (userId: string) => GetUserRequest;
}
type ProfilePageProps = StateFromProps & DispatchFromProps;
interface ProfilePageState {
user: LoggedInUser;
}
class ProfilePage extends React.Component<ProfilePageProps, ProfilePageState> {
private userId: string;
constructor(props) {
super(props);
const { match } = props;
const params = match.params;
this.userId = params ? params.userId : '';
this.state = {
user: this.props.user,
};
}
static getDerivedStateFromProps(nextProps, prevState) {
const { user } = nextProps;
return { user };
}
componentDidMount() {
this.props.getUserById(this.userId);
}
// TODO: consider moving logic for empty content into Tab component
createEmptyTabMessage = (message: string) => {
return (
<div className="empty-tab-message">
<text>{ message }</text>
</div>
);
}
generateTabInfo = () => {
const user = this.state.user;
const tabInfo = [];
// TODO: Populate tabs based on data
// TODO: consider moving logic for empty content into Tab component
tabInfo.push({
content: this.createEmptyTabMessage('User has no frequently used resources.'),
key: 'frequentUses_tab',
title: 'Frequently Uses (0)',
});
tabInfo.push({
content: this.createEmptyTabMessage('User has no bookmarked resources.'),
key: 'bookmarks_tab',
title: 'Bookmarks (0)',
});
tabInfo.push({
content: this.createEmptyTabMessage('User has no owned resources.'),
key: 'owner_tab',
title: 'Owner (0)',
});
return tabInfo;
}
/* TODO: Add support to direct to 404 page for edgecase of someone typing in
or pasting in a bad url. This would be consistent with TableDetail page behavior */
render() {
const user = this.state.user;
return (
<DocumentTitle title={ `${user.display_name} - Amundsen Profile` }>
<div className="container profile-page">
<Breadcrumb path='/' text='Search Results'/>
<div className="profile-header">
<div className="profile-avatar">
{
// default Avatar looks a bit jarring -- intentionally not rendering if no display_name
user.display_name && user.display_name.length > 0 &&
<Avatar name={user.display_name} size={74} round={true} />
}
</div>
<div className="profile-details">
<div className="profile-title">
<h1>{ user.display_name }</h1>
{
(user.is_active === false) &&
<Flag caseType="sentenceCase" labelStyle="label-danger" text="Alumni"/>
}
</div>
<text>{ `${user.role_name} on ${user.team_name}` }</text>
<text>{ `Manager: ${user.manager_name}` }</text>
<div className="profile-icons">
{
user.is_active &&
<a href={user.slack_url} className='btn btn-flat-icon' target='_blank'>
<img className='icon icon-slack'/>
<span>Slack</span>
</a>
}
{
user.is_active &&
<a href={`mailto:${user.email}`} className='btn btn-flat-icon' target='_blank'>
<img className='icon icon-mail'/>
<span>{ user.email }</span>
</a>
}
{
user.is_active &&
<a href={user.profile_url} className='btn btn-flat-icon' target='_blank'>
<img className='icon icon-users'/>
<span>Employee Profile</span>
</a>
}
<a href={`https://github.com/${user.github_name}`} className='btn btn-flat-icon' target='_blank'>
<img className='icon icon-github'/>
<span>Github</span>
</a>
</div>
</div>
</div>
<div className="profile-tabs">
<Tabs tabs={ this.generateTabInfo() } defaultTab='frequentUses_tab' />
</div>
</div>
</DocumentTitle>
);
}
}
const mapStateToProps = (state: GlobalState) => {
return {
user: state.user.profileUser,
}
}
const mapDispatchToProps = (dispatch) => {
return bindActionCreators({ getUserById }, dispatch);
};
export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(ProfilePage);
@import 'variables';
.profile-page {
margin-bottom: 48px;
}
.profile-header {
display: flex;
.profile-avatar {
margin-left: 24px;
margin-right: 24px;
}
.profile-details {
display: flex;
flex-direction: column;
h1 {
margin-bottom: 0px;
margin-top: 0px;
}
.profile-icons {
margin-top: 12px;
a {
margin-left: 8px;
margin-right: 8px;
&:first-child {
margin-left: 0px;
}
&:last-child {
margin-right: 0px;
}
}
}
.profile-title {
display: flex;
margin-bottom: 8px;
.flag {
height: min-content; // TODO: consider moving height into Flag component
font-size: 100%;
margin: auto auto auto 12px;
}
}
}
}
.profile-tabs {
margin-top: 48px;
}
// TODO: consider moving logic for empty content into Tab component
.empty-tab-message {
color: $gray-light;
margin-top: 64px;
text-align: center;
}
...@@ -7,7 +7,7 @@ const DEFAULT_SUBTEXT = `Search within a category using the pattern with wildcar ...@@ -7,7 +7,7 @@ const DEFAULT_SUBTEXT = `Search within a category using the pattern with wildcar
Current categories are 'column', 'schema', 'table', and 'tag'.`; Current categories are 'column', 'schema', 'table', and 'tag'.`;
interface SearchBarProps { interface SearchBarProps {
handleValueSubmit: (term: string, pageIndex: number) => void; handleValueSubmit: (term: string) => void;
placeholder?: string; placeholder?: string;
searchTerm?: string; searchTerm?: string;
subText?: string; subText?: string;
...@@ -50,7 +50,7 @@ class SearchBar extends React.Component<SearchBarProps, SearchBarState> { ...@@ -50,7 +50,7 @@ class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
event.preventDefault(); event.preventDefault();
if (this.isFormValid()) { if (this.isFormValid()) {
const inputElement = this.inputRef.current; const inputElement = this.inputRef.current;
this.props.handleValueSubmit(inputElement.value.toLowerCase(), 0); this.props.handleValueSubmit(inputElement.value.toLowerCase());
} }
}; };
......
...@@ -5,11 +5,11 @@ ...@@ -5,11 +5,11 @@
position: relative; position: relative;
.search-bar-button { .search-bar-button {
height: 24px;
left: 24px;
position: absolute; position: absolute;
top: 28px; height: 24px;
width: 24px; width: 24px;
top: 28px;
left: 24px;
} }
.search-bar-input { .search-bar-input {
......
...@@ -8,13 +8,15 @@ import Pagination from 'react-js-pagination'; ...@@ -8,13 +8,15 @@ import Pagination from 'react-js-pagination';
import SearchBar from './SearchBar'; import SearchBar from './SearchBar';
import SearchList from './SearchList'; import SearchList from './SearchList';
import InfoButton from '../common/InfoButton'; import InfoButton from '../common/InfoButton';
import { TableResource } from "../common/ResourceListItem/types"; import { ResourceType, TableResource } from "../common/ResourceListItem/types";
import { GlobalState } from "../../ducks/rootReducer"; import { GlobalState } from "../../ducks/rootReducer";
import { executeSearch } from '../../ducks/search/reducer'; import { searchAll, searchResource } from '../../ducks/search/reducer';
import { import {
ExecuteSearchRequest,
DashboardSearchResults, DashboardSearchResults,
SearchAllOptions,
SearchAllRequest,
SearchResourceRequest,
TableSearchResults, TableSearchResults,
UserSearchResults UserSearchResults
} from "../../ducks/search/types"; } from "../../ducks/search/types";
...@@ -23,6 +25,7 @@ import { GetPopularTablesRequest } from '../../ducks/popularTables/types'; ...@@ -23,6 +25,7 @@ import { GetPopularTablesRequest } from '../../ducks/popularTables/types';
// TODO: Use css-modules instead of 'import' // TODO: Use css-modules instead of 'import'
import './styles.scss'; import './styles.scss';
import TabsComponent from "../common/Tabs";
const RESULTS_PER_PAGE = 10; const RESULTS_PER_PAGE = 10;
...@@ -36,20 +39,21 @@ export interface StateFromProps { ...@@ -36,20 +39,21 @@ export interface StateFromProps {
} }
export interface DispatchFromProps { export interface DispatchFromProps {
executeSearch: (term: string, pageIndex: number) => ExecuteSearchRequest; searchAll: (term: string, options?: SearchAllOptions) => SearchAllRequest;
searchResource: (resource: ResourceType, term: string, pageIndex: number) => SearchResourceRequest;
getPopularTables: () => GetPopularTablesRequest; getPopularTables: () => GetPopularTablesRequest;
} }
type SearchPageProps = StateFromProps & DispatchFromProps; type SearchPageProps = StateFromProps & DispatchFromProps;
interface SearchPageState { interface SearchPageState {
pageIndex: number; selectedTab: ResourceType;
searchTerm: string;
} }
export class SearchPage extends React.Component<SearchPageProps, SearchPageState> { export class SearchPage extends React.Component<SearchPageProps, SearchPageState> {
public static defaultProps: SearchPageProps = { public static defaultProps: SearchPageProps = {
executeSearch: () => undefined, searchAll: () => undefined,
searchResource: () => undefined,
getPopularTables: () => undefined, getPopularTables: () => undefined,
searchTerm: '', searchTerm: '',
popularTables: [], popularTables: [],
...@@ -73,102 +77,168 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState ...@@ -73,102 +77,168 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState
constructor(props) { constructor(props) {
super(props); super(props);
this.handlePageChange = this.handlePageChange.bind(this); this.state = {
this.updateQueryString = this.updateQueryString.bind(this); selectedTab: ResourceType.table,
};
} }
componentDidMount() { componentDidMount() {
this.props.getPopularTables(); this.props.getPopularTables();
const params = qs.parse(window.location.search); const params = qs.parse(window.location.search);
const searchTerm = params['searchTerm']; const { searchTerm, pageIndex, selectedTab} = params;
const pageIndex = params['pageIndex'];
const validTab = this.validateTab(selectedTab);
this.setState({ selectedTab: validTab });
if (searchTerm && searchTerm.length > 0) { if (searchTerm && searchTerm.length > 0) {
const index = pageIndex || '0'; const index = pageIndex || 0;
this.props.executeSearch(searchTerm, index); this.props.searchAll(searchTerm, this.getSearchOptions(index, validTab));
// Update the page URL with validated parameters.
this.updatePageUrl(searchTerm, validTab, index);
} }
} }
createErrorMessage() { validateTab = (newTab) => {
const items = this.props.tables; switch(newTab) {
const { page_index, total_results } = items; case ResourceType.table:
const { searchTerm } = this.props; case ResourceType.user:
if (total_results === 0 && searchTerm.length > 0) { return newTab;
return ( case ResourceType.dashboard:
<label> default:
Your search - <i>{ searchTerm }</i> - did not match any tables. return this.state.selectedTab;
</label>
)
} }
if (total_results > 0 && (RESULTS_PER_PAGE * page_index) + 1 > total_results) { };
return (
<label> getSearchOptions = (pageIndex, selectedTab) => {
Page index out of bounds for available matches. return {
</label> dashboardIndex: (selectedTab === ResourceType.dashboard) ? pageIndex : 0,
) userIndex: (selectedTab === ResourceType.user) ? pageIndex : 0,
tableIndex: (selectedTab === ResourceType.table) ? pageIndex : 0,
};
};
getPageIndex = (tab) => {
switch(tab) {
case ResourceType.table:
return this.props.tables.page_index;
case ResourceType.user:
return this.props.users.page_index;
case ResourceType.dashboard:
return this.props.dashboards.page_index;
} }
return null; return 0;
} };
onSearchBarSubmit = (searchTerm: string) => {
this.props.searchAll(searchTerm);
this.updatePageUrl(searchTerm, this.state.selectedTab,0);
};
handlePageChange(pageNumber) { onPaginationChange = (pageNumber) => {
// subtract 1 : pagination component indexes from 1, while our api is 0-indexed // subtract 1 : pagination component indexes from 1, while our api is 0-indexed
this.updateQueryString(this.props.searchTerm, pageNumber - 1); const index = pageNumber - 1;
}
this.props.searchResource(this.state.selectedTab, this.props.searchTerm, index);
this.updatePageUrl(this.props.searchTerm, this.state.selectedTab, index);
};
updateQueryString(searchTerm, pageIndex) { onTabChange = (tab: ResourceType) => {
const pathName = `/search?searchTerm=${searchTerm}&pageIndex=${pageIndex}`; const validTab = this.validateTab(tab);
this.setState({ selectedTab: validTab });
this.updatePageUrl(this.props.searchTerm, validTab, this.getPageIndex(validTab));
};
updatePageUrl = (searchTerm, tab, pageIndex) => {
const pathName = `/search?searchTerm=${searchTerm}&selectedTab=${tab}&pageIndex=${pageIndex}`;
window.history.pushState({}, '', `${window.location.origin}${pathName}`); window.history.pushState({}, '', `${window.location.origin}${pathName}`);
this.props.executeSearch(searchTerm, pageIndex); };
}
// TODO: Hard-coded text strings should be translatable/customizable renderPopularTables = () => {
renderSearchResults() { const searchListParams = {
const errorMessage = this.createErrorMessage(); source: 'popular_tables',
if (errorMessage) { paginationStartIndex: 0,
return ( };
<div className="col-xs-12"> return (
<div className="col-xs-12 col-md-offset-1 col-md-10">
<div className="search-list-container"> <div className="search-list-container">
<div className="search-list-header"> <div className="popular-tables-header">
{ errorMessage } <label>Popular Tables</label>
<InfoButton infoText={ "These are some of the most commonly accessed tables within your organization." }/>
</div> </div>
<SearchList results={ this.props.popularTables } params={ searchListParams }/>
</div> </div>
</div> </div>
) )
} };
renderSearchResults = () => {
const tabConfig = [
{
title: `Tables (${ this.props.tables.total_results })`,
key: ResourceType.table,
content: this.getTabContent(this.props.tables, 'tables'),
},
// TODO PEOPLE - Uncomment when enabling people
// {
// title: `Users (${ this.props.users.total_results })`,
// key: ResourceType.user,
// content: this.getTabContent(this.props.users, 'users'),
// },
];
return (
<div className="col-xs-12 col-md-offset-1 col-md-10">
<TabsComponent
tabs={ tabConfig }
defaultTab={ ResourceType.table }
activeKey={ this.state.selectedTab }
onSelect={ this.onTabChange }
/>
</div>
);
};
const items = this.props.tables; // TODO: Hard-coded text strings should be translatable/customizable
const { page_index, results, total_results } = items; getTabContent = (results, tabLabel) => {
const { popularTables } = this.props; const { searchTerm } = this.props;
const { page_index, total_results } = results;
const showResultsList = results.length > 0 || popularTables.length > 0; const startIndex = (RESULTS_PER_PAGE * page_index) + 1;
const endIndex = RESULTS_PER_PAGE * ( page_index + 1);
if (showResultsList) {
const startIndex = (RESULTS_PER_PAGE * page_index) + 1;
const endIndex = RESULTS_PER_PAGE * ( page_index + 1);
let listTitle = `${startIndex}-${Math.min(endIndex, total_results)} of ${total_results} results`;
let infoText = "Ordered by the relevance of matches within a resource's metadata, as well as overall usage.";
const searchListParams = {
source: 'search_results',
paginationStartIndex: RESULTS_PER_PAGE * page_index
};
const showPopularTables = total_results < 1;
if (showPopularTables) {
listTitle = 'Popular Tables';
infoText = "These are some of the most commonly accessed tables within your organization.";
searchListParams.source = 'popular_tables';
}
// TODO - Move error messages into Tab Component
// Check no results
if (total_results === 0 && searchTerm.length > 0) {
return ( return (
<div className="col-xs-12 col-md-offset-1 col-md-10"> <div className="search-list-container">
<div className="search-list-container"> <div className="search-error">
<div className="search-list-header"> Your search - <i>{ searchTerm }</i> - did not match any { tabLabel } result
<label> { listTitle } </label> </div>
<InfoButton infoText={ infoText }/> </div>
</div> )
<SearchList results={ showPopularTables ? popularTables : results } params={ searchListParams }/> }
// Check page_index bounds
if (page_index < 0 || startIndex > total_results) {
return (
<div className="search-list-container">
<div className="search-error">
Page index out of bounds for available matches.
</div> </div>
<div className="search-pagination-component"> </div>
)
}
let title =`${startIndex}-${Math.min(endIndex, total_results)} of ${total_results} results`;
return (
<div className="search-list-container">
<div className="search-list-header">
<label>{ title }</label>
<InfoButton infoText={ "Ordered by the relevance of matches within a resource's metadata, as well as overall usage." }/>
</div>
<SearchList results={ results.results } params={ {source: 'search_results', paginationStartIndex: 0 } }/>
<div className="search-pagination-component">
{ {
total_results > RESULTS_PER_PAGE && total_results > RESULTS_PER_PAGE &&
<Pagination <Pagination
...@@ -176,23 +246,22 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState ...@@ -176,23 +246,22 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState
itemsCountPerPage={ RESULTS_PER_PAGE } itemsCountPerPage={ RESULTS_PER_PAGE }
totalItemsCount={ total_results } totalItemsCount={ total_results }
pageRangeDisplayed={ 10 } pageRangeDisplayed={ 10 }
onChange={ this.handlePageChange } onChange={ this.onPaginationChange }
/> />
} }
</div> </div>
</div> </div>
) );
} };
}
// TODO: Hard-coded text strings should be translatable/customizable
render() { render() {
const { searchTerm } = this.props; const { searchTerm } = this.props;
const innerContent = ( const innerContent = (
<div className="container search-page"> <div className="container search-page">
<div className="row"> <div className="row">
<SearchBar handleValueSubmit={ this.updateQueryString } searchTerm={ searchTerm }/> <SearchBar handleValueSubmit={ this.onSearchBarSubmit } searchTerm={ searchTerm }/>
{ this.renderSearchResults() } { searchTerm.length > 0 && this.renderSearchResults() }
{ searchTerm.length === 0 && this.renderPopularTables() }
</div> </div>
</div> </div>
); );
...@@ -209,7 +278,7 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState ...@@ -209,7 +278,7 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState
export const mapStateToProps = (state: GlobalState) => { export const mapStateToProps = (state: GlobalState) => {
return { return {
searchTerm: state.search.searchTerm, searchTerm: state.search.search_term,
popularTables: state.popularTables, popularTables: state.popularTables,
tables: state.search.tables, tables: state.search.tables,
users: state.search.users, users: state.search.users,
...@@ -218,7 +287,7 @@ export const mapStateToProps = (state: GlobalState) => { ...@@ -218,7 +287,7 @@ export const mapStateToProps = (state: GlobalState) => {
}; };
export const mapDispatchToProps = (dispatch: any) => { export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ executeSearch, getPopularTables } , dispatch); return bindActionCreators({ searchAll, searchResource, getPopularTables } , dispatch);
}; };
export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(SearchPage); export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(SearchPage);
@import 'variables'; @import 'variables';
.search-page { .search-page {
.tabs-component,
.search-list-container { .search-list-container {
margin: 64px 0 0 0; margin-top: 32px;
} }
@media (max-width: $screen-sm-max) { @media (max-width: $screen-sm-max) {
.tabs-component,
.search-list-container { .search-list-container {
margin: 32px 0 0 0; margin-top: 16px;
} }
} }
.search-list-header { .popular-tables-header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
margin-bottom: 32px; margin-bottom: 32px;
label {
font-family: $font-family-sans-serif-bold;
font-weight: $font-weight-sans-serif-bold;
font-size: 20px;
margin-top: auto;
margin-bottom: auto;
width: fit-content;
line-height: 24px;
}
}
.tab-content {
.search-list-header {
label {
color: $text-medium;
font-size: 18px;
}
}
.list-group-item:first-child {
border-top: none;
}
} }
.search-list-header label {
font-family: $font-family-sans-serif-bold; .search-error {
font-weight: $font-weight-sans-serif-bold; color: $gray-lighter;
font-size: 20px; text-align: center;
margin-top: auto;
margin-bottom: auto;
width: fit-content;
line-height: 24px;
} }
.search-pagination-component { .search-pagination-component {
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
}
.pagination { .pagination > li {
} > a,
.pagination>li>a, > span {
.pagination>li>span { border: 1px solid $gray-lighter;
color: $brand-color-4; color: $brand-color-4;
border: 1px solid $gray-lighter;
} &:focus,
.pagination>.active>a, &:hover {
.pagination>.active>a:hover, background-color: $gray-lighter;
.pagination>.active>a:focus, color: $link-hover-color;
.pagination>.active>span, z-index: 0;
.pagination>.active>span:hover, }
.pagination>.active>span:focus { }
z-index: 0;
background-color: $brand-color-4; &.active {
border-color: $brand-color-4 > a,
} > span {
.pagination>li>a:focus, &,
.pagination>li>a:hover, &:active,
.pagination>li>span:focus, &:hover,
.pagination>li>span:hover { &:focus {
z-index: 0; background-color: $brand-color-4;
color: $link-hover-color; border-color: $brand-color-4;
background-color: $gray-lighter; color: white;
z-index: 0;
}
}
}
}
} }
import * as React from 'react'; import * as React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { Button, Modal, OverlayTrigger, Popover, Table } from 'react-bootstrap'; import { Button, Modal, OverlayTrigger, Popover, Table } from 'react-bootstrap';
import Linkify from 'react-linkify' import Linkify from 'react-linkify'
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
border-top-color: $gray-lighter !important; border-top-color: $gray-lighter !important;
border-bottom-color: $gray-lighter !important; border-bottom-color: $gray-lighter !important;
background-color: transparent !important; background-color: transparent !important;
padding: 10px 4px;
.description { .description {
color: $text-medium; color: $text-medium;
...@@ -54,7 +55,7 @@ ...@@ -54,7 +55,7 @@
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background-image: linear-gradient($brand-color-1, $brand-color-1, white); background-image: linear-gradient($gray-lighter, $gray-lighter, white);
.type { .type {
color: $brand-color-4; color: $brand-color-4;
......
...@@ -3,7 +3,6 @@ import { connect } from 'react-redux'; ...@@ -3,7 +3,6 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import * as DocumentTitle from 'react-document-title'; import * as DocumentTitle from 'react-document-title';
import * as $ from 'jquery';
import * as qs from 'simple-query-string'; import * as qs from 'simple-query-string';
import { GlobalState } from "../../ducks/rootReducer"; import { GlobalState } from "../../ducks/rootReducer";
...@@ -12,6 +11,7 @@ import { GetTableDataRequest } from '../../ducks/tableMetadata/types'; ...@@ -12,6 +11,7 @@ import { GetTableDataRequest } from '../../ducks/tableMetadata/types';
import AppConfig from '../../../config/config'; import AppConfig from '../../../config/config';
import AvatarLabel from '../common/AvatarLabel'; import AvatarLabel from '../common/AvatarLabel';
import Breadcrumb from "../common/Breadcrumb";
import DataPreviewButton from './DataPreviewButton'; import DataPreviewButton from './DataPreviewButton';
import DetailList from './DetailList'; import DetailList from './DetailList';
import EntityCard from '../common/EntityCard'; import EntityCard from '../common/EntityCard';
...@@ -21,8 +21,6 @@ import TableDescEditableText from './TableDescEditableText'; ...@@ -21,8 +21,6 @@ import TableDescEditableText from './TableDescEditableText';
import TagInput from '../Tags/TagInput'; import TagInput from '../Tags/TagInput';
import WatermarkLabel from "./WatermarkLabel"; import WatermarkLabel from "./WatermarkLabel";
import { Tag } from '../Tags/types';
import Avatar from 'react-avatar'; import Avatar from 'react-avatar';
import { OverlayTrigger, Popover } from 'react-bootstrap'; import { OverlayTrigger, Popover } from 'react-bootstrap';
import { RouteComponentProps } from 'react-router'; import { RouteComponentProps } from 'react-router';
...@@ -304,13 +302,15 @@ export class TableDetail extends React.Component<TableDetailProps & RouteCompone ...@@ -304,13 +302,15 @@ export class TableDetail extends React.Component<TableDetailProps & RouteCompone
this.props.history.push('/404'); this.props.history.push('/404');
} else if (this.state.statusCode === 500) { } else if (this.state.statusCode === 500) {
innerContent = ( innerContent = (
<div className="error-label"> <div className="container error-label">
<Breadcrumb path='/' text='Search Results'/>
<label className="d-block m-auto">Something went wrong...</label> <label className="d-block m-auto">Something went wrong...</label>
</div> </div>
) )
} else { } else {
innerContent = ( innerContent = (
<div className="container table-detail"> <div className="container table-detail">
<Breadcrumb path='/' text='Search Results'/>
<div className="row"> <div className="row">
<div className="detail-header col-xs-12 col-md-7 col-lg-8"> <div className="detail-header col-xs-12 col-md-7 col-lg-8">
<div className="title">{ `${data.schema}.${data.table_name}` }</div> <div className="title">{ `${data.schema}.${data.table_name}` }</div>
......
import * as React from 'react';
import { Link } from 'react-router-dom';
import './styles.scss';
interface BreadcrumbProps {
path: string;
text: string;
}
const Breadcrumb: React.SFC<BreadcrumbProps> = ({ path, text }) => {
return (
<div className="amundsen-breadcrumb">
<Link to={path}>
<button className='btn btn-flat-icon'>
<img className='icon icon-left'/>
<span>{text}</span>
</button>
</Link>
</div>
);
};
Breadcrumb.defaultProps = {
path: '/',
text: 'Home',
};
export default Breadcrumb;
@import 'variables';
// Margins values chosen for the breadcrumb to sort-of split the difference/center
// itself in our 96px/64px/32px top margins.
.amundsen-breadcrumb {
color: $text-medium;
font-family: $font-family-sans-serif-bold;
font-weight: $font-weight-sans-serif-bold;
height: 24px;
margin-top: -72px;
margin-bottom: 48px;
img.icon-left {
margin: -3px 0 -3px -8px;
}
span {
display: inline-block;
height: 24px;
line-height: 24px;
}
}
@media (max-width: $screen-md-max) {
.amundsen-breadcrumb {
margin-top: -40px;
margin-bottom: 16px;
}
}
@media (max-width: $screen-sm-max) {
.amundsen-breadcrumb {
margin-top: -10px;
margin-bottom: 10px;
}
}
...@@ -32,14 +32,14 @@ const Flag: React.SFC<FlagProps> = ({ caseType, text, labelStyle }) => { ...@@ -32,14 +32,14 @@ const Flag: React.SFC<FlagProps> = ({ caseType, text, labelStyle }) => {
// TODO: After upgrading to Bootstrap 4, this component should leverage badges // TODO: After upgrading to Bootstrap 4, this component should leverage badges
// https://getbootstrap.com/docs/4.1/components/badge/ // https://getbootstrap.com/docs/4.1/components/badge/
return ( return (
<span className={`flag label ${labelStyle}`}>{convertText(text, caseType)}</span> <span className={`flag label label-${labelStyle}`}>{convertText(text, caseType)}</span>
); );
}; };
Flag.defaultProps = { Flag.defaultProps = {
caseType: null, caseType: null,
text: '', text: '',
labelStyle: 'label-default', labelStyle: 'default',
}; };
export default Flag; export default Flag;
...@@ -3,10 +3,8 @@ import { Link } from 'react-router-dom'; ...@@ -3,10 +3,8 @@ import { Link } from 'react-router-dom';
import { LoggingParams, TableResource} from '../types'; import { LoggingParams, TableResource} from '../types';
import './styles.scss';
interface TableListItemProps { interface TableListItemProps {
item: TableResource; table: TableResource;
logging: LoggingParams; logging: LoggingParams;
} }
...@@ -15,38 +13,44 @@ class TableListItem extends React.Component<TableListItemProps, {}> { ...@@ -15,38 +13,44 @@ class TableListItem extends React.Component<TableListItemProps, {}> {
super(props); super(props);
} }
/* TODO: We have to fix a bug with this feature. Commented out support.
const createLastUpdatedTimestamp = () => {
if (lastUpdated) {
const dateTokens = new Date(lastUpdated).toDateString().split(' ');
return (
<label>
{`${dateTokens[1].toUpperCase()} ${dateTokens[2]}`}
</label>
)
}
return null;
}*/
getLink = () => { getLink = () => {
const { item, logging } = this.props; const { table, logging } = this.props;
return `/table_detail/${item.cluster}/${item.database}/${item.schema_name}/${item.name}` return `/table_detail/${table.cluster}/${table.database}/${table.schema_name}/${table.name}`
+ `?index=${logging.index}&source=${logging.source}`; + `?index=${logging.index}&source=${logging.source}`;
}; };
render() { render() {
const { item } = this.props; const { table } = this.props;
const hasLastUpdated = !!table.last_updated_epoch;
const dateTokens = new Date(table.last_updated_epoch * 1000).toDateString().split(' ');
const dateLabel = `${dateTokens[1]} ${dateTokens[2]}, ${dateTokens[3]}`;
return ( return (
<li className="list-group-item search-list-item"> <li className="list-group-item">
<Link to={ this.getLink() }> <Link className="resource-list-item table-list-item" to={ this.getLink() }>
<img className="icon icon-color icon-database" /> <img className="icon icon-database icon-color" />
<div className="resultInfo"> <div className="content">
<span className="title truncated">{ `${item.schema_name}.${item.name} `}</span> <div className={ hasLastUpdated? "col-sm-9 col-md-10" : "col-sm-12"}>
<span className="subtitle truncated">{ item.description }</span> <div className="main-title truncated">{ `${table.schema_name}.${table.name}`}</div>
<div className="description truncated">{ table.description }</div>
</div>
{/*<div className={ hasLastUpdated? "hidden-xs col-sm-3 col-md-4" : "hidden-xs col-sm-6"}>*/}
{/*<div className="secondary-title">Frequent Users</div>*/}
{/*<div className="description truncated">*/}
{/*<label> </label>*/}
{/*</div>*/}
{/*</div>*/}
{
hasLastUpdated &&
<div className="hidden-xs col-sm-3 col-md-2">
<div className="secondary-title">Latest Data</div>
<div className="description truncated">
{ dateLabel }
</div>
</div>
}
</div> </div>
{ /*createLastUpdatedTimestamp()*/ }
<img className="icon icon-right" /> <img className="icon icon-right" />
</Link> </Link>
</li> </li>
......
@import 'variables';
.search-list-item {
padding: 16px 4px;
.resultInfo {
margin-left: 8px;
margin-right: 8px;
width: 100%;
min-width: 0px;
}
.title {
font-family: $font-family-sans-serif-bold;
font-weight: $font-weight-sans-serif-bold;
color: $text-dark;
font-size: 16px;
}
.subtitle {
color: $text-medium;
font-size: 14px;
}
.truncated {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
label {
font-family: $font-family-sans-serif-bold;
font-weight: $font-weight-sans-serif-bold;
font-size: 12px;
color: $text-medium;
margin-top: auto;
margin-bottom: auto;
white-space: nowrap;
}
a {
text-decoration: none;
color: $text-dark;
display: flex;
flex-direction: row;
}
img.icon {
margin: 8px;
}
&:hover img.icon {
background-color: $brand-color-4;
}
}
import * as React from 'react';
import Avatar from 'react-avatar';
import { Link } from 'react-router-dom';
import { LoggingParams, UserResource} from '../types';
import Flag from '../../Flag';
interface UserListItemProps {
user: UserResource;
logging: LoggingParams;
}
class UserListItem extends React.Component<UserListItemProps, {}> {
constructor(props) {
super(props);
}
getLink = () => {
const { user, logging } = this.props;
return `/user/${user.id}/?index=${logging.index}&source=${logging.source}`;
};
render() {
const { user } = this.props;
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 } />
<div className="content">
<div className="col-xs-12 col-sm-6">
<div className="main-title">
{ user.name }
{
!user.active &&
<Flag text="Alumni" labelStyle='danger' />
}
</div>
<div className="description">
{ `${user.role} on ${user.team_name}` }
</div>
</div>
<div className="hidden-xs col-sm-6">
<div className="secondary-title">Frequently Uses</div>
<div className="description truncated">
{ /*TODO Fill this with a real value*/ }
<label>{ user.title }</label>
</div>
</div>
</div>
<img className="icon icon-right" />
</Link>
</li>
);
}
}
export default UserListItem;
import * as React from 'react' import * as React from 'react'
import { LoggingParams, Resource, ResourceType, TableResource } from './types'; import { LoggingParams, Resource, ResourceType, TableResource, UserResource } from './types';
import TableListItem from './TableListItem'; import TableListItem from './TableListItem';
import UserListItem from './UserListItem';
import './styles.scss';
interface ListItemProps { interface ListItemProps {
logging: LoggingParams; logging: LoggingParams;
...@@ -16,9 +20,10 @@ export default class ResourceListItem extends React.Component<ListItemProps, {}> ...@@ -16,9 +20,10 @@ export default class ResourceListItem extends React.Component<ListItemProps, {}>
render() { render() {
switch(this.props.item.type) { switch(this.props.item.type) {
case ResourceType.table: case ResourceType.table:
return (<TableListItem item={ this.props.item as TableResource } logging={ this.props.logging } />); return (<TableListItem table={ this.props.item as TableResource } logging={ this.props.logging } />);
// case ListItemType.user: case ResourceType.user:
// case ListItemType.dashboard: return (<UserListItem user={ this.props.item as UserResource } logging={ this.props.logging } />);
// case ResourceType.dashboard:
default: default:
return (null); return (null);
} }
......
@import 'variables';
.list-group-item .resource-list-item {
color: $text-dark;
display: flex;
flex-direction: row;
height: 78px;
padding: 16px 8px;
text-decoration: none;
img.icon,
.sb-avatar {
margin: auto 0;
}
&:hover img.icon {
background-color: $brand-color-4;
}
.content {
width: 100%;
min-width: 0; /* Needed to support `white-space: nowrap` */
.main-title {
color: $text-dark;
font-size: $font-size-large;
font-family: $font-family-sans-serif-bold;
font-weight: $font-weight-sans-serif-bold;
}
.secondary-title {
color: $text-medium;
font-family: $font-family-sans-serif-bold;
font-weight: $font-weight-sans-serif-bold;
}
.description {
color: $text-medium;
display: block;
font-size: $font-size-base;
margin-top: 2px;
> a {
color: $brand-color-4;
&:hover {
color: $brand-color-5;
}
}
}
}
}
...@@ -10,21 +10,69 @@ export interface Resource { ...@@ -10,21 +10,69 @@ export interface Resource {
export interface TableResource extends Resource { export interface TableResource extends Resource {
type: ResourceType.table; type: ResourceType.table;
database: string;
cluster: string; cluster: string;
database: string;
description: string; description: string;
key: string; key: string;
last_updated: number; // 'popular_tables' currently does not support 'last_updated_epoch'
last_updated_epoch?: number;
name: string; name: string;
schema_name: string; 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. // Placeholder until the schema is defined.
export interface UserResource extends Resource { export interface UserResource extends Resource {
type: ResourceType.user; type: ResourceType.user;
active : boolean;
birthday : string | null;
department: string;
email: string;
first_name: string; first_name: string;
github_username: string;
id: number;
last_name: string; last_name: string;
email: string; manager_email : string;
name : string;
offboarded : boolean;
office: string;
role: string;
start_date : string;
team_name: string;
title: string;
} }
// Placeholder until the schema is defined. // Placeholder until the schema is defined.
......
...@@ -5,7 +5,7 @@ import './styles.scss'; ...@@ -5,7 +5,7 @@ import './styles.scss';
export interface TabsProps { export interface TabsProps {
tabs: TabInfo[]; tabs: TabInfo[];
activeKey?: string;
defaultTab?: string; defaultTab?: string;
onSelect?: (key: string) => void; onSelect?: (key: string) => void;
} }
...@@ -16,12 +16,13 @@ interface TabInfo { ...@@ -16,12 +16,13 @@ interface TabInfo {
title: string; title: string;
} }
const TabsComponent: React.SFC<TabsProps> = ({tabs, defaultTab, onSelect}) => { const TabsComponent: React.SFC<TabsProps> = ({tabs, activeKey, defaultTab, onSelect}) => {
return ( return (
<Tabs <Tabs
id="tab" id="tab"
className="tabs-component" className="tabs-component"
defaultActiveKey={ defaultTab } defaultActiveKey={ defaultTab }
activeKey={ activeKey }
onSelect={ onSelect } onSelect={ onSelect }
> >
{ {
......
@import 'variables'; @import 'variables';
.tabs-component { .tabs-component .nav.nav-tabs {
.nav.nav-tabs { border: none;
border: none;
> li { > li {
&.active > a { &.active > a {
&, &,
&:hover { &:hover {
color: $brand-color-4; color: $brand-color-4;
} }
&:after { &:after {
opacity: 1; opacity: 1;
}
} }
}
> a { > a {
background: none; background: none;
border: none; border: none;
color: $text-medium; color: $text-medium;
font-size: $font-size-large; font-size: $font-size-large;
line-height: $line-height-large; line-height: $line-height-large;
padding: 4px 8px 12px;
&:hover { &:hover {
color: $text-dark; color: $text-dark;
} }
// Active tab indicator // Active tab indicator
&:after { &:after {
border: 2px solid $brand-color-4; border: 2px solid $brand-color-4;
bottom: 0; bottom: 0;
content: ""; content: "";
left: 0; left: 0;
opacity: 0; opacity: 0;
position: absolute; position: absolute;
transition: opacity 200ms ease-in; transition: opacity 200ms ease-in;
width: 100%; width: 100%;
}
} }
} }
} }
......
...@@ -8,7 +8,7 @@ import { submitFeedbackWatcher } from './feedback/sagas'; ...@@ -8,7 +8,7 @@ import { submitFeedbackWatcher } from './feedback/sagas';
// SearchPage // SearchPage
import { getPopularTablesWatcher } from './popularTables/sagas'; import { getPopularTablesWatcher } from './popularTables/sagas';
import { executeSearchWatcher } from './search/sagas'; import { searchAllWatcher, searchResourceWatcher } from './search/sagas';
// TableDetail // TableDetail
import { updateTableOwnerWatcher } from './tableMetadata/owners/sagas'; import { updateTableOwnerWatcher } from './tableMetadata/owners/sagas';
...@@ -27,7 +27,7 @@ import { ...@@ -27,7 +27,7 @@ import {
import { getAllTagsWatcher } from './allTags/sagas'; import { getAllTagsWatcher } from './allTags/sagas';
// User // User
import { getCurrentUserWatcher } from "./user/sagas"; import { getLoggedInUserWatcher, getUserWatcher } from "./user/sagas";
export default function* rootSaga() { export default function* rootSaga() {
yield all([ yield all([
...@@ -36,7 +36,8 @@ export default function* rootSaga() { ...@@ -36,7 +36,8 @@ export default function* rootSaga() {
// FeedbackForm // FeedbackForm
submitFeedbackWatcher(), submitFeedbackWatcher(),
// SearchPage // SearchPage
executeSearchWatcher(), searchAllWatcher(),
searchResourceWatcher(),
getPopularTablesWatcher(), getPopularTablesWatcher(),
// Tags // Tags
getAllTagsWatcher(), getAllTagsWatcher(),
...@@ -51,6 +52,7 @@ export default function* rootSaga() { ...@@ -51,6 +52,7 @@ export default function* rootSaga() {
updateTableOwnerWatcher(), updateTableOwnerWatcher(),
updateTableTagsWatcher(), updateTableTagsWatcher(),
// User // User
getCurrentUserWatcher(), getLoggedInUserWatcher(),
getUserWatcher(),
]); ]);
} }
import axios, { AxiosResponse, AxiosError } from 'axios'; import axios, { AxiosError, AxiosResponse } from 'axios';
import { SearchResponse } from '../types'; import { SearchAllRequest, SearchResponse, SearchResourceRequest } from '../types';
import { SearchReducerState } from '../reducer'; const BASE_URL = '/api/search/v0';
function transformSearchResults(data: SearchResponse): SearchReducerState {
return {
searchTerm: data.search_term,
dashboards: data.dashboards,
tables: data.tables,
users: data.users,
};
}
export function searchExecuteSearch(action) { export function searchAll(action: SearchAllRequest) {
const { term, pageIndex } = action; const { term, options } = action;
return axios.get(`/api/search/v0/?query=${term}&page_index=${pageIndex}`) return axios.all([
.then((response: AxiosResponse<SearchResponse>) => transformSearchResults(response.data)) axios.get(`${BASE_URL}/table?query=${term}&page_index=${options.tableIndex || 0}`),
.catch((error: AxiosError) => { // TODO PEOPLE - Uncomment when enabling People feature
const data = error.response ? error.response.data : {}; // axios.get(`${BASE_URL}/user?query=${term}&page_index=${options.userIndex || 0}`),
return transformSearchResults(data); ]).then(axios.spread((tableResponse: AxiosResponse<SearchResponse> /*, userResponse: AxiosResponse<SearchResponse>*/) => {
return {
search_term: tableResponse.data.search_term,
tables: tableResponse.data.tables,
// users: userResponse.data.users,
}
})).catch((error: AxiosError) => {
// TODO - handle errors
}); });
} }
export function searchResource(action: SearchResourceRequest) {
const { term, pageIndex, resource } = action;
return axios.get(`${BASE_URL}/${resource}?query=${term}&page_index=${pageIndex}`)
.then((response: AxiosResponse) => {
const { data } = response;
const ret = { searchTerm: data.search_term };
['tables', 'users'].forEach((key) => {
if (data[key]) {
ret[key] = data[key];
}
});
return ret;
}).catch((error: AxiosError) => {
// TODO - handle errors
});
}
import { import {
ExecuteSearch, SearchAll,
ExecuteSearchRequest, SearchAllOptions,
ExecuteSearchResponse, SearchAllRequest,
SearchAllResponse,
SearchResource,
SearchResourceRequest,
SearchResourceResponse,
DashboardSearchResults, DashboardSearchResults,
TableSearchResults, TableSearchResults,
UserSearchResults, UserSearchResults,
} from './types'; } from './types';
import { ResourceType } from "../../components/common/ResourceListItem/types";
export type SearchReducerAction = ExecuteSearchRequest | ExecuteSearchResponse; export type SearchReducerAction = SearchAllResponse | SearchResourceResponse;
export interface SearchReducerState { export interface SearchReducerState {
searchTerm: string; search_term: string;
dashboards: DashboardSearchResults; dashboards: DashboardSearchResults;
tables: TableSearchResults; tables: TableSearchResults;
users: UserSearchResults; users: UserSearchResults;
} }
export function executeSearch(term: string, pageIndex: number): ExecuteSearchRequest { export function searchAll(term: string, options: SearchAllOptions = {}): SearchAllRequest {
return { return {
options,
term, term,
type: SearchAll.ACTION,
};
}
export function searchResource(resource: ResourceType, term: string, pageIndex: number): SearchResourceRequest {
return {
pageIndex, pageIndex,
type: ExecuteSearch.ACTION, term,
resource,
type: SearchResource.ACTION,
}; };
} }
const initialState: SearchReducerState = { const initialState: SearchReducerState = {
searchTerm: '', search_term: '',
dashboards: { dashboards: {
page_index: 0, page_index: 0,
results: [], results: [],
...@@ -44,10 +58,22 @@ const initialState: SearchReducerState = { ...@@ -44,10 +58,22 @@ const initialState: SearchReducerState = {
}; };
export default function reducer(state: SearchReducerState = initialState, action: SearchReducerAction): SearchReducerState { export default function reducer(state: SearchReducerState = initialState, action: SearchReducerAction): SearchReducerState {
let newState = action.payload;
switch (action.type) { switch (action.type) {
case ExecuteSearch.SUCCESS: // SearchAll will reset all resources with search results or the initial state
return action.payload; case SearchAll.SUCCESS:
case ExecuteSearch.FAILURE: return {
...initialState,
...newState,
};
// SearchResource will set only a single resource and preserves search state for other resources
case SearchResource.SUCCESS:
return {
...state,
...newState,
};
case SearchAll.FAILURE:
case SearchResource.FAILURE:
return initialState; return initialState;
default: default:
return state; return state;
......
...@@ -2,24 +2,42 @@ import { call, put, takeEvery } from 'redux-saga/effects'; ...@@ -2,24 +2,42 @@ import { call, put, takeEvery } from 'redux-saga/effects';
import { SagaIterator } from 'redux-saga'; import { SagaIterator } from 'redux-saga';
import { import {
ExecuteSearch, SearchAll,
ExecuteSearchRequest, SearchAllRequest,
SearchResource,
SearchResourceRequest,
} from './types'; } from './types';
import { import {
searchExecuteSearch, searchAll, searchResource,
} from './api/v0'; } from './api/v0';
export function* executeSearchWorker(action: ExecuteSearchRequest): SagaIterator { // SearchAll
export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
try { try {
const searchResults = yield call(searchExecuteSearch, action); const searchResults = yield call(searchAll, action);
yield put({ type: ExecuteSearch.SUCCESS, payload: searchResults }); yield put({ type: SearchAll.SUCCESS, payload: searchResults });
} catch (e) { } catch (e) {
yield put({ type: ExecuteSearch.FAILURE }); yield put({ type: SearchAll.FAILURE });
} }
} }
export function* executeSearchWatcher(): SagaIterator { export function* searchAllWatcher(): SagaIterator {
yield takeEvery(ExecuteSearch.ACTION, executeSearchWorker); yield takeEvery(SearchAll.ACTION, searchAllWorker);
}
// SearchResource
export function* searchResourceWorker(action: SearchResourceRequest): SagaIterator {
try {
const searchResults = yield call(searchResource, action);
yield put({ type: SearchResource.SUCCESS, payload: searchResults });
} catch (e) {
yield put({ type: SearchResource.FAILURE });
}
}
export function* searchResourceWatcher(): SagaIterator {
yield takeEvery(SearchResource.ACTION, searchResourceWorker);
} }
import { Resource, DashboardResource, TableResource, UserResource } from "../../components/common/ResourceListItem/types"; import {
Resource,
ResourceType,
DashboardResource,
TableResource,
UserResource,
} from "../../components/common/ResourceListItem/types";
import { SearchReducerState } from './reducer'; import { SearchReducerState } from './reducer';
interface SearchResults<T extends Resource> { interface SearchResults<T extends Resource> {
...@@ -14,25 +20,51 @@ export type SearchResponse = { ...@@ -14,25 +20,51 @@ export type SearchResponse = {
msg: string; msg: string;
status_code: number; status_code: number;
search_term: string; search_term: string;
dashboards: DashboardSearchResults; dashboards?: DashboardSearchResults;
tables: TableSearchResults; tables?: TableSearchResults;
users: UserSearchResults; users?: UserSearchResults;
} }
/* executeSearch */ /* searchAll - Search all resource types */
export enum ExecuteSearch { export enum SearchAll {
ACTION = 'amundsen/search/EXECUTE_SEARCH', ACTION = 'amundsen/search/SEARCH_ALL',
SUCCESS = 'amundsen/search/EXECUTE_SEARCH_SUCCESS', SUCCESS = 'amundsen/search/SEARCH_ALL_SUCCESS',
FAILURE = 'amundsen/search/EXECUTE_SEARCH_FAILURE', FAILURE = 'amundsen/search/SEARCH_ALL_FAILURE',
} }
export interface ExecuteSearchRequest { export interface SearchAllOptions {
type: ExecuteSearch.ACTION; dashboardIndex?: number;
tableIndex?: number;
userIndex?: number;
}
export interface SearchAllRequest {
options: SearchAllOptions;
term: string; term: string;
type: SearchAll.ACTION;
}
export interface SearchAllResponse {
type: SearchAll.SUCCESS | SearchAll.FAILURE;
payload?: SearchReducerState;
}
/* searchResource - Search a single resource type */
export enum SearchResource {
ACTION = 'amundsen/search/SEARCH_RESOURCE',
SUCCESS = 'amundsen/search/SEARCH_RESOURCE_SUCCESS',
FAILURE = 'amundsen/search/SEARCH_RESOURCE_FAILURE',
}
export interface SearchResourceRequest {
pageIndex: number; pageIndex: number;
resource: ResourceType;
term: string;
type: SearchResource.ACTION;
} }
export interface ExecuteSearchResponse { export interface SearchResourceResponse {
type: ExecuteSearch.SUCCESS | ExecuteSearch.FAILURE; type: SearchResource.SUCCESS | SearchResource.FAILURE;
payload?: SearchReducerState; payload?: SearchReducerState;
} }
...@@ -129,10 +129,20 @@ export default function reducer(state: TableMetadataReducerState = initialState, ...@@ -129,10 +129,20 @@ export default function reducer(state: TableMetadataReducerState = initialState,
...state, ...state,
isLoading: true, isLoading: true,
preview: initialPreviewState, preview: initialPreviewState,
tableData: initialTableDataState,
tableOwners: tableOwnersReducer(state.tableOwners, action), tableOwners: tableOwnersReducer(state.tableOwners, action),
tableTags: tableTagsReducer(state.tableTags, action), tableTags: tableTagsReducer(state.tableTags, action),
}; };
case GetTableData.FAILURE: case GetTableData.FAILURE:
return {
...state,
isLoading: false,
preview: initialPreviewState,
statusCode: action.payload.statusCode,
tableData: initialTableDataState,
tableOwners: tableOwnersReducer(state.tableOwners, action),
tableTags: tableTagsReducer(state.tableTags, action),
};
case GetTableData.SUCCESS: case GetTableData.SUCCESS:
return { return {
...state, ...state,
...@@ -148,12 +158,13 @@ export default function reducer(state: TableMetadataReducerState = initialState, ...@@ -148,12 +158,13 @@ export default function reducer(state: TableMetadataReducerState = initialState,
case GetColumnDescription.FAILURE: case GetColumnDescription.FAILURE:
case GetColumnDescription.SUCCESS: case GetColumnDescription.SUCCESS:
return { ...state, tableData: action.payload }; return { ...state, tableData: action.payload };
case GetLastIndexed.SUCCESS:
return { ...state, lastIndexed: action.payload };
case GetLastIndexed.FAILURE: case GetLastIndexed.FAILURE:
return { ...state, lastIndexed: null }; return { ...state, lastIndexed: null };
case GetPreviewData.SUCCESS: case GetLastIndexed.SUCCESS:
return { ...state, lastIndexed: action.payload };
case GetPreviewData.FAILURE: case GetPreviewData.FAILURE:
return { ...state, preview: initialPreviewState };
case GetPreviewData.SUCCESS:
return { ...state, preview: action.payload }; return { ...state, preview: action.payload };
case UpdateTableOwner.ACTION: case UpdateTableOwner.ACTION:
case UpdateTableOwner.FAILURE: case UpdateTableOwner.FAILURE:
......
...@@ -24,10 +24,10 @@ import { ...@@ -24,10 +24,10 @@ import {
// getTableData // getTableData
export function* getTableDataWorker(action: GetTableDataRequest): SagaIterator { export function* getTableDataWorker(action: GetTableDataRequest): SagaIterator {
try { try {
const { data, owners, tags } = yield call(metadataGetTableData, action); const { data, owners, statusCode, tags } = yield call(metadataGetTableData, action);
yield put({ type: GetTableData.SUCCESS, payload: { data, owners, tags } }); yield put({ type: GetTableData.SUCCESS, payload: { data, owners, statusCode, tags } });
} catch (e) { } catch (e) {
yield put({ type: GetTableData.FAILURE, payload: { data: {}, owners: [], tags: [] } }); yield put({ type: GetTableData.FAILURE, payload: { data: {}, owners: [], statusCode: 500, tags: [] } });
} }
} }
......
import axios, { AxiosResponse, AxiosError } from 'axios'; import axios, { AxiosResponse, AxiosError } from 'axios';
import { CurrentUser } from '../types'; import { LoggedInUser, UserResponse } from '../types';
export function getCurrentUser() { export function getLoggedInUser() {
return axios.get(`/api/current_user`) return axios.get(`/api/auth_user`)
.then((response: AxiosResponse<CurrentUser>) => { .then((response: AxiosResponse<LoggedInUser>) => {
return response.data; return response.data;
}).catch((error: AxiosError) => { }).catch((error: AxiosError) => {
return {}; return {};
}); });
} }
export function getUserById(userId: string) {
return axios.get(`/api/metadata/v0/user?user_id=${userId}`)
.then((response: AxiosResponse<UserResponse>) => {
return response.data.user;
})
.catch((error: AxiosError) => {
return {};
});
}
import { import {
GetCurrentUser, GetLoggedInUser,
GetCurrentUserRequest, GetLoggedInUserRequest,
GetCurrentUserResponse, GetLoggedInUserResponse,
CurrentUser GetUser,
GetUserRequest,
GetUserResponse,
LoggedInUser, User
} from './types'; } from './types';
type UserReducerAction = GetCurrentUserRequest | GetCurrentUserResponse; type UserReducerAction =
GetLoggedInUserRequest | GetLoggedInUserResponse |
GetUserRequest | GetUserResponse ;
export interface UserReducerState { export interface UserReducerState {
currentUser: CurrentUser; loggedInUser: LoggedInUser;
profileUser: User;
} }
export function getCurrentUser(): GetCurrentUserRequest { export function getLoggedInUser(): GetLoggedInUserRequest {
return { type: GetCurrentUser.ACTION }; return { type: GetLoggedInUser.ACTION };
} }
export function getUserById(userId: string): GetUserRequest {
return { userId, type: GetUser.ACTION };
}
const defaultUser = {
user_id: '',
display_name: '',
};
const initialState: UserReducerState = { const initialState: UserReducerState = {
currentUser: null, loggedInUser: defaultUser,
profileUser: defaultUser,
}; };
export default function reducer(state: UserReducerState = initialState, action: UserReducerAction): UserReducerState { export default function reducer(state: UserReducerState = initialState, action: UserReducerAction): UserReducerState {
switch (action.type) { switch (action.type) {
case GetCurrentUser.SUCCESS: case GetLoggedInUser.SUCCESS:
return { ...state, currentUser: action.payload }; return { ...state, loggedInUser: action.payload };
case GetUser.ACTION:
case GetUser.FAILURE:
return { ...state, profileUser: defaultUser };
case GetUser.SUCCESS:
return { ...state, profileUser: action.payload };
default: default:
return state; return state;
} }
......
import { SagaIterator } from 'redux-saga'; import { SagaIterator } from 'redux-saga';
import { call, put, takeEvery } from 'redux-saga/effects'; import { call, put, takeEvery } from 'redux-saga/effects';
import { GetCurrentUser } from './types'; import { GetLoggedInUser, GetUser, GetUserRequest } from './types';
import { getCurrentUser } from './api/v0'; import { getLoggedInUser, getUserById } from './api/v0';
export function* getUserWorker(): SagaIterator { export function* getLoggedInUserWorker(): SagaIterator {
try { try {
const user = yield call(getCurrentUser); const user = yield call(getLoggedInUser);
yield put({ type: GetCurrentUser.SUCCESS, payload: user }); const otherUserInfo = yield call(getUserById, user.user_id);
yield put({ type: GetLoggedInUser.SUCCESS, payload: { ...otherUserInfo, ...user }});
} catch (e) { } catch (e) {
yield put({ type: GetCurrentUser.FAILURE }); yield put({ type: GetLoggedInUser.FAILURE });
} }
} }
export function* getCurrentUserWatcher(): SagaIterator { export function* getLoggedInUserWatcher(): SagaIterator {
yield takeEvery(GetCurrentUser.ACTION, getUserWorker); yield takeEvery(GetLoggedInUser.ACTION, getLoggedInUserWorker);
}
export function* getUserWorker(action: GetUserRequest): SagaIterator {
try {
const user = yield call(getUserById, action.userId);
yield put({ type: GetUser.SUCCESS, payload: user });
} catch (e) {
yield put({ type: GetUser.FAILURE});
}
}
export function* getUserWatcher(): SagaIterator {
yield takeEvery(GetUser.ACTION, getUserWorker);
} }
export interface CurrentUser { // Setting up different types for now so we can iterate faster as shared params change
export interface User {
user_id: string;
display_name: string; display_name: string;
email: string; email?: string;
first_name: string; first_name?: string;
last_name: string; github_name?: string;
profile_url: string; is_active?: boolean;
last_name?: string;
manager_name?: string;
profile_url?: string;
role_name?: string;
slack_url?: string;
team_name?: string;
} }
export type LoggedInUser = User & {};
export enum GetCurrentUser { export type UserResponse = { user: User; msg: string; };
/* getLoggedInUser */
export enum GetLoggedInUser {
ACTION = 'amundsen/current_user/GET_ACTION',
SUCCESS = 'amundsen/current_user/GET_SUCCESS',
FAILURE = 'amundsen/current_user/GET_FAILURE',
}
export interface GetLoggedInUserRequest {
type: GetLoggedInUser.ACTION;
}
export interface GetLoggedInUserResponse {
type: GetLoggedInUser.SUCCESS | GetLoggedInUser.FAILURE;
payload?: LoggedInUser;
}
/* getUserById */
export enum GetUser {
ACTION = 'amundsen/user/GET_ACTION', ACTION = 'amundsen/user/GET_ACTION',
SUCCESS = 'amundsen/user/GET_SUCCESS', SUCCESS = 'amundsen/user/GET_SUCCESS',
FAILURE = 'amundsen/user/GET_FAILURE', FAILURE = 'amundsen/user/GET_FAILURE',
} }
export interface GetCurrentUserRequest { export interface GetUserRequest {
type: GetCurrentUser.ACTION; type: GetUser.ACTION;
userId: string;
} }
export interface GetCurrentUserResponse { export interface GetUserResponse {
type: GetCurrentUser.SUCCESS | GetCurrentUser.FAILURE; type: GetUser.SUCCESS | GetUser.FAILURE;
payload?: CurrentUser; payload?: User;
} }
...@@ -15,6 +15,7 @@ import Feedback from './components/Feedback'; ...@@ -15,6 +15,7 @@ import Feedback from './components/Feedback';
import Footer from './components/Footer'; import Footer from './components/Footer';
import NavBar from './components/NavBar'; import NavBar from './components/NavBar';
import NotFoundPage from './components/NotFoundPage'; import NotFoundPage from './components/NotFoundPage';
import ProfilePage from './components/ProfilePage';
import SearchPage from './components/SearchPage'; import SearchPage from './components/SearchPage';
import TableDetail from './components/TableDetail'; import TableDetail from './components/TableDetail';
...@@ -38,6 +39,7 @@ ReactDOM.render( ...@@ -38,6 +39,7 @@ ReactDOM.render(
<Route path="/announcements" component={AnnouncementPage} /> <Route path="/announcements" component={AnnouncementPage} />
<Route path="/browse" component={BrowsePage} /> <Route path="/browse" component={BrowsePage} />
<Route path="/search" component={SearchPage} /> <Route path="/search" component={SearchPage} />
<Route path="/user/:userId" component={ProfilePage} />
<Route path="/404" component={NotFoundPage} /> <Route path="/404" component={NotFoundPage} />
<Route path="/" component={SearchPage} /> <Route path="/" component={SearchPage} />
</Switch> </Switch>
......
...@@ -66,7 +66,6 @@ class MetadataTest(unittest.TestCase): ...@@ -66,7 +66,6 @@ class MetadataTest(unittest.TestCase):
'id': 'test_id', 'id': 'test_id',
'description': 'This is a test' 'description': 'This is a test'
}, },
'last_updated_timestamp': 1534191754
} }
self.expected_parsed_metadata = { self.expected_parsed_metadata = {
'cluster': 'test_cluster', 'cluster': 'test_cluster',
......
...@@ -12,7 +12,7 @@ local_app = create_app('amundsen_application.config.LocalConfig', 'static/templa ...@@ -12,7 +12,7 @@ local_app = create_app('amundsen_application.config.LocalConfig', 'static/templa
class SearchTest(unittest.TestCase): class SearchTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.mock_search_results = { self.mock_search_table_results = {
'total_results': 1, 'total_results': 1,
'results': [ 'results': [
{ {
...@@ -25,83 +25,129 @@ class SearchTest(unittest.TestCase): ...@@ -25,83 +25,129 @@ class SearchTest(unittest.TestCase):
'database': 'test_db', 'database': 'test_db',
'description': 'This is a test', 'description': 'This is a test',
'key': 'test_key', 'key': 'test_key',
'last_updated': 1527283287, 'last_updated_epoch': 1527283287,
'name': 'test_table', 'name': 'test_table',
'schema_name': 'test_schema', 'schema_name': 'test_schema',
'tags': [], 'tags': [],
} }
] ]
} }
self.expected_parsed_search_results = [ self.expected_parsed_search_table_results = [
{ {
'type': 'table', 'type': 'table',
'cluster': 'test_cluster', 'cluster': 'test_cluster',
'database': 'test_db', 'database': 'test_db',
'description': 'This is a test', 'description': 'This is a test',
'key': 'test_key', 'key': 'test_key',
'last_updated': 1527283287, 'last_updated_epoch': 1527283287,
'name': 'test_table', 'name': 'test_table',
'schema_name': 'test_schema', 'schema_name': 'test_schema',
} }
] ]
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',
}
]
}
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',
}
]
self.bad_search_results = { self.bad_search_results = {
'total_results': 1, 'total_results': 1,
'results': 'Bad results to trigger exception' 'results': 'Bad results to trigger exception'
} }
def test_search_fail_if_no_query(self) -> None: # ----- Table Search Tests ---- #
def test_search_table_fail_if_no_query(self) -> None:
""" """
Test request failure if 'query' is not provided in the query string Test request failure if 'query' is not provided in the query string
to the search endpoint to the search endpoint
:return: :return:
""" """
with local_app.test_client() as test: with local_app.test_client() as test:
response = test.get('/api/search/v0/', query_string=dict(page_index='0')) response = test.get('/api/search/v0/table', query_string=dict(page_index='0'))
self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR) self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
def test_search_fail_if_no_page_index(self) -> None: def test_search_table_fail_if_no_page_index(self) -> None:
""" """
Test request failure if 'page_index' is not provided in the query string Test request failure if 'page_index' is not provided in the query string
to the search endpoint to the search endpoint
:return: :return:
""" """
with local_app.test_client() as test: with local_app.test_client() as test:
response = test.get('/api/search/v0/', query_string=dict(query='test')) response = test.get('/api/search/v0/table', query_string=dict(query='test'))
self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR) self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
@responses.activate @responses.activate
def test_search_success(self) -> None: def test_search_table_success(self) -> None:
""" """
Test request success Test request success
:return: :return:
""" """
responses.add(responses.GET, local_app.config['SEARCHSERVICE_ENDPOINT'], responses.add(responses.GET, local_app.config['SEARCHSERVICE_ENDPOINT'],
json=self.mock_search_results, status=HTTPStatus.OK) json=self.mock_search_table_results, status=HTTPStatus.OK)
with local_app.test_client() as test: with local_app.test_client() as test:
response = test.get('/api/search/v0/', query_string=dict(query='test', page_index='0')) response = test.get('/api/search/v0/table', query_string=dict(query='test', page_index='0'))
data = json.loads(response.data) data = json.loads(response.data)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
tables = data.get('tables') tables = data.get('tables')
self.assertEqual(tables.get('total_results'), self.mock_search_results.get('total_results')) self.assertEqual(tables.get('total_results'), self.mock_search_table_results.get('total_results'))
self.assertCountEqual(tables.get('results'), self.expected_parsed_search_results) self.assertCountEqual(tables.get('results'), self.expected_parsed_search_table_results)
@responses.activate @responses.activate
def test_search_fail_on_non_200_response(self) -> None: def test_search_table_fail_on_non_200_response(self) -> None:
""" """
Test request failure if search endpoint returns non-200 http code Test request failure if search endpoint returns non-200 http code
:return: :return:
""" """
responses.add(responses.GET, local_app.config['SEARCHSERVICE_ENDPOINT'], responses.add(responses.GET, local_app.config['SEARCHSERVICE_ENDPOINT'],
json=self.mock_search_results, status=HTTPStatus.INTERNAL_SERVER_ERROR) json=self.mock_search_table_results, status=HTTPStatus.INTERNAL_SERVER_ERROR)
with local_app.test_client() as test: with local_app.test_client() as test:
response = test.get('/api/search/v0/', query_string=dict(query='test', page_index='0')) response = test.get('/api/search/v0/table', query_string=dict(query='test', page_index='0'))
self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR) self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
@responses.activate @responses.activate
def test_search_fail_on_proccessing_bad_response(self) -> None: def test_search_table_fail_on_proccessing_bad_response(self) -> None:
""" """
Test catching exception if there is an error processing the results Test catching exception if there is an error processing the results
from the search endpoint from the search endpoint
...@@ -111,11 +157,11 @@ class SearchTest(unittest.TestCase): ...@@ -111,11 +157,11 @@ class SearchTest(unittest.TestCase):
json=self.bad_search_results, status=HTTPStatus.OK) json=self.bad_search_results, status=HTTPStatus.OK)
with local_app.test_client() as test: with local_app.test_client() as test:
response = test.get('/api/search/v0/', query_string=dict(query='test', page_index='0')) response = test.get('/api/search/v0/table', query_string=dict(query='test', page_index='0'))
self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR) self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
@responses.activate @responses.activate
def test_search_with_field(self) -> None: def test_search_table_with_field(self) -> None:
""" """
Test search request if user search with colon Test search request if user search with colon
:return: :return:
...@@ -154,3 +200,71 @@ class SearchTest(unittest.TestCase): ...@@ -154,3 +200,71 @@ class SearchTest(unittest.TestCase):
'/hive?page_index=1' '/hive?page_index=1'
self.assertEqual(_create_url_with_field(search_term=search_term, self.assertEqual(_create_url_with_field(search_term=search_term,
page_index=1), expected) page_index=1), expected)
# ----- User Search Tests ---- #
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)
# TODO - Uncomment test once the API is integrated
# @responses.activate
# def test_search_user_success(self) -> None:
# """
# Test request success
# :return:
# """
# responses.add(responses.GET, local_app.config['SEARCHSERVICE_ENDPOINT'],
# json=self.mock_search_table_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_table_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_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)
@responses.activate
def test_search_user_fail_on_proccessing_bad_response(self) -> None:
"""
Test catching exception if there is an error processing the results
from the search endpoint
:return:
"""
responses.add(responses.GET, local_app.config['SEARCHSERVICE_ENDPOINT'],
json=self.bad_search_results, status=HTTPStatus.OK)
with local_app.test_client() as test:
response = test.get('/api/search/v0/table', 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