Commit 523826c0 authored by Ryan Lieu's avatar Ryan Lieu Committed by Tamika Tannis

Amundsen Notifications Without Preferences (#273)

* Initial start to notifications API (#215)

* initial start to notifications API

* fixing some styling

* fixed lint errors

* update types

* added tests

* linter, moved notification types

* addressed comments regarding imports/enum naming

* fixed alphabetical order

* Notifs post email functionality (#222)

* initial start to notifications API

* fixing some styling

* fixed lint errors

* update types

* added tests

* linter, moved notification types

* added template support

* made changes to reflect private changes

* added helper function

* fixed lint issue

* addressed comments, added some type checking and cleaned up comments

* testing removing test

* fixed linter

* fixed lint

* fixed linting issues

* skip type checking

* fixed lint

* fixed typing on get request args

* removed typing for get request to fix lint issues

* fixed linter again

* re added test

* raise exception inside of getmailclient

* added exceptions

* addressed comments

* whitespace issue

* removed calls to get_query_param

* fixed syntax error

* Send notification when adding/removing owner from table (#237)

* basic e2e functionality for adding/removing

* send_notification refactor

* fix lint errors

* blank line lint error

* fixed syntax issue

* arg typing

* addressed comments, fixed code style

* Prevent Self-Notifications (#243)

* Prevent user from notifying themselves

* removed exception

* added owner check to send_notification

* Fixed return for no recipients (#244)

* fixed return for no recipients

* fixed linter issue

* Request notifications component (#238)

* init of request form

* basic request component

* getting basic functionality in

* clearing out css

* removed z-index fixes and add constants

* fixed string casting

* added redux-saga calls

* removed reset request notification

* fixed tests

* addressed comments, added basic test, added redux state management for opening/closing component

* added tests, just need to add render test

* cleaned up component tests:

* addressed html/css comments

* removed unecessary styling

* removed collapsed class

* cleaned up render method

* fixed test

* Open request component (#254)

* added button to open up request component

* removed tabledetail changes

* className styling

* fixed text-decoration

* added tests, changed naming for OpenRequest

* styling formatting

* Add, Request, and Remove Email Copy (#257)

* init for fixing email copy for request, add, and remove

* removed print statement

* fixed python unit test

* fixed linter issues

* addressed comments, fixed linter issues

* added notification unit test

* fixed test positional arg

* fix test

* Add notification action logging (#258)

* init of adding action logging

* changed location of action logging

* fixed linter errors

* fixed comment

* addressed comments

* remove request test call (#259)

* hide request if description already exists (#269)

* fixed open request button, request form styling (#267)

* Added request dropdown component (#262)

* init

* made fixes

* cleaned up code

* fixed color issues

* fixed import order

* fixed styling, changed ducks/sagas

* User dropdown (#263)

* init

* fixed sty;es

* fixed test issue

* fixed test

* added tests, addressed comments

* Request Metadata Component Tests (#270)

* added tests + readonly field to stop errors

* fixed tslint

* addressed comments, added header tests

* Request form navigation fix, dropdown fix (#272)

* Request form navigation fix, dropdown fix

* added test

* added unique id to dropdown

* Creates User Preferences page with no functionality (#266)

* init

* added event handlers

* removed test file

* added constants

* addressed comments

* fixed test, removed all links to page

* updated test

* fixed call to onclick

* removed preferences page

* Python cleanup + tests (#277)

* Python cleanup + tests

* More tests + revert some unecessary changes

* Bring dropdown UI closer to design (#278)

* Rename OpenRequestDescription for clarity + code cleanup + test additions (#279)

* Notifications ducks cleanup + tests (#280)

* Notifications ducks cleanup + tests

* Fix issues

* Fix template for edge case of empty form (#281)

* Temporary debugging code, will revert

* Temporary debugging code, will revert

* Implement notification form confirmation (#289)

* Preserve compatibility in base_mail_client (#290)

* Notifications Configs + Doc (#291)

* Add notification config

* Code cleanup

* More cleanup + add a test

* Add some doc for how to enable features

* Add config utils test + fix type error

* Relative URLs to child configuration docs (#294)

* Relative URLs to child configuration docs

Relative URLs to docs in the same folder should do. They work for any branch, local copies of the docs - and should work better if we ever (or whenever :-) we get to having e.g a Sphinx generated site.

* Update application_config.md

Relative doc link

* Update flask_config.md

Relative doc link

* Update flask_config.md

Relative doc link

* Remove temporary debugging code

* Improve behavior of notification sending for owner editing (#296)

* Initial Implementation: Notification only on success

* Cleanup + tests: Notification only on success

* Cleanup: Remove test code to trigger failure

* Cleanup: Lint fix

* Workaround for not notifying teams or alumni

* Cleanup: Remove import mistake

* Utilize NotificationType enums instead of hardcoded string

* Remove use of render_template

* More minor cleanups

* Address some feedback

* Cleanup

* More cleanup
parent 03ddbfbb
class MailClientNotImplemented(Exception):
"""
An exception when Mail Client is not implemented
"""
pass
......@@ -6,6 +6,8 @@ from flask import Response, jsonify, make_response, request
from flask import current_app as app
from flask.blueprints import Blueprint
from amundsen_application.api.exceptions import MailClientNotImplemented
from amundsen_application.api.utils.notification_utils import get_mail_client, send_notification
from amundsen_application.log.action_log import action_logging
LOGGER = logging.getLogger(__name__)
......@@ -15,15 +17,12 @@ mail_blueprint = Blueprint('mail', __name__, url_prefix='/api/mail/v0')
@mail_blueprint.route('/feedback', methods=['POST'])
def feedback() -> Response:
""" An instance of BaseMailClient client must be configured on MAIL_CLIENT """
mail_client = app.config['MAIL_CLIENT']
if not mail_client:
message = 'An instance of BaseMailClient client must be configured on MAIL_CLIENT'
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.NOT_IMPLEMENTED)
"""
Uses the instance of BaseMailClient client configured on the MAIL_CLIENT
config variable to send an email with feedback data
"""
try:
mail_client = get_mail_client()
data = request.form.to_dict()
text_content = '\r\n'.join('{}:\r\n{}\r\n'.format(k, v) for k, v in data.items())
html_content = ''.join('<div><strong>{}:</strong><br/>{}</div><br/>'.format(k, v) for k, v in data.items())
......@@ -47,7 +46,12 @@ def feedback() -> Response:
value_prop=value_prop,
subject=subject)
response = mail_client.send_email(subject=subject, text=text_content, html=html_content, optional_data=data)
options = {
'email_type': 'feedback',
'form_data': data
}
response = mail_client.send_email(subject=subject, text=text_content, html=html_content, optional_data=options)
status_code = response.status_code
if status_code == HTTPStatus.OK:
......@@ -57,9 +61,13 @@ def feedback() -> Response:
logging.error(message)
return make_response(jsonify({'msg': message}), status_code)
except Exception as e:
except MailClientNotImplemented as e:
message = 'Encountered exception: ' + str(e)
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.NOT_IMPLEMENTED)
except Exception as e1:
message = 'Encountered exception: ' + str(e1)
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.INTERNAL_SERVER_ERROR)
......@@ -75,3 +83,37 @@ def _feedback(*,
subject: str) -> None:
""" Logs the content of the feedback form """
pass # pragma: no cover
@mail_blueprint.route('/notification', methods=['POST'])
def notification() -> Response:
"""
Uses the instance of BaseMailClient client configured on the MAIL_CLIENT
config variable to send a notification email based on data passed from the request
"""
try:
data = request.get_json()
notification_type = data.get('notificationType')
if notification_type is None:
message = 'Encountered exception: notificationType must be provided in the request payload'
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.BAD_REQUEST)
sender = data.get('sender')
if sender is None:
sender = app.config['AUTH_USER_METHOD'](app).email
options = data.get('options', {})
recipients = data.get('recipients', [])
return send_notification(
notification_type=notification_type,
options=options,
recipients=recipients,
sender=sender
)
except Exception as e:
message = 'Encountered exception: ' + str(e)
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.INTERNAL_SERVER_ERROR)
......@@ -138,28 +138,33 @@ def _get_table_metadata(*, table_key: str, index: int, source: str) -> Dict[str,
return results_dict
@action_logging
def _update_table_owner(*, table_key: str, method: str, owner: str) -> Dict[str, str]:
try:
table_endpoint = _get_table_endpoint()
url = '{0}/{1}/owner/{2}'.format(table_endpoint, table_key, owner)
request_metadata(url=url, method=method)
# TODO: Figure out a way to get this payload from flask.jsonify which wraps with app's response_class
return {'msg': 'Updated owner'}
except Exception as e:
return {'msg': 'Encountered exception: ' + str(e)}
@metadata_blueprint.route('/update_table_owner', methods=['PUT', 'DELETE'])
def update_table_owner() -> Response:
@action_logging
def _log_update_table_owner(*, table_key: str, method: str, owner: str) -> None:
pass # pragma: no cover
try:
args = request.get_json()
table_key = get_query_param(args, 'key')
owner = get_query_param(args, 'owner')
payload = jsonify(_update_table_owner(table_key=table_key, method=request.method, owner=owner))
return make_response(payload, HTTPStatus.OK)
table_endpoint = _get_table_endpoint()
url = '{0}/{1}/owner/{2}'.format(table_endpoint, table_key, owner)
method = request.method
_log_update_table_owner(table_key=table_key, method=method, owner=owner)
response = request_metadata(url=url, method=method)
status_code = response.status_code
if status_code == HTTPStatus.OK:
message = 'Updated owner'
else:
message = 'There was a problem updating owner {0}'.format(owner)
payload = jsonify({'msg': message})
return make_response(payload, status_code)
except Exception as e:
payload = jsonify({'msg': 'Encountered exception: ' + str(e)})
return make_response(payload, HTTPStatus.INTERNAL_SERVER_ERROR)
......
import logging
from http import HTTPStatus
from flask import current_app as app
from flask import jsonify, make_response, Response
from typing import Dict, List
from amundsen_application.api.exceptions import MailClientNotImplemented
from amundsen_application.log.action_log import action_logging
NOTIFICATION_STRINGS = {
'added': {
'comment': ('<br/>What is expected of you?<br/>As an owner, you take an important part in making '
'sure that the datasets you own can be used as swiftly as possible across the company.<br/>'
'Make sure the metadata is correct and up to date.<br/>'),
'end_note': ('<br/>If you think you are not the best person to own this dataset and know someone who might '
'be, please contact this person and ask them if they want to replace you. It is important that we '
'keep multiple owners for each dataset to ensure continuity.<br/>'),
'notification': ('<br/>You have been added to the owners list of the <a href="{resource_url}">'
'{resource_name}</a> dataset by {sender}.<br/>'),
},
'removed': {
'comment': '',
'end_note': ('<br/>If you think you have been incorrectly removed as an owner, '
'add yourself back to the owners list.<br/>'),
'notification': ('<br/>You have been removed from the owners list of the <a href="{resource_url}">'
'{resource_name}</a> dataset by {sender}.<br/>'),
},
'requested': {
'comment': '',
'end_note': '<br/>Please visit the provided link and improve descriptions on that resource.<br/>',
'notification': '<br/>{sender} is trying to use <a href="{resource_url}">{resource_name}</a>, ',
}
}
def get_mail_client(): # type: ignore
"""
Gets a mail_client object to send emails, raises an exception
if mail client isn't implemented
"""
mail_client = app.config['MAIL_CLIENT']
if not mail_client:
raise MailClientNotImplemented('An instance of BaseMailClient client must be configured on MAIL_CLIENT')
return mail_client
def get_notification_html(*, notification_type: str, options: Dict, sender: str) -> str:
"""
Returns the formatted html for the notification based on the notification_type
:return: A string representing the html markup to send in the notification
"""
resource_url = options.get('resource_url')
if resource_url is None:
raise Exception('resource_url was not provided in the notification options')
resource_name = options.get('resource_name')
if resource_name is None:
raise Exception('resource_name was not provided in the notification options')
notification_strings = NOTIFICATION_STRINGS.get(notification_type)
if notification_strings is None:
raise Exception('Unsupported notification_type')
greeting = 'Hello,<br/>'
notification = notification_strings.get('notification', '').format(resource_url=resource_url,
resource_name=resource_name,
sender=sender)
comment = notification_strings.get('comment', '')
end_note = notification_strings.get('end_note', '')
salutation = '<br/>Thanks,<br/>Amundsen Team'
if notification_type == 'requested':
options_comment = options.get('comment')
need_resource_description = options.get('description_requested')
need_fields_descriptions = options.get('fields_requested')
if need_resource_description and need_fields_descriptions:
notification = notification + 'and requests improved table and column descriptions.<br/>'
elif need_resource_description:
notification = notification + 'and requests an improved table description.<br/>'
elif need_fields_descriptions:
notification = notification + 'and requests improved column descriptions.<br/>'
else:
notification = notification + 'and requests more information about that resource.<br/>'
if options_comment:
comment = ('<br/>{sender} has included the following information with their request:'
'<br/>{comment}<br/>').format(sender=sender, comment=options_comment)
return '{greeting}{notification}{comment}{end_note}{salutation}'.format(greeting=greeting,
notification=notification,
comment=comment,
end_note=end_note,
salutation=salutation)
def get_notification_subject(*, notification_type: str, options: Dict) -> str:
"""
Returns the subject to use for the given notification_type
:param notification_type: type of notification
:param options: data necessary to render email template content
:return: The subject to be used with the notification
"""
notification_subject_dict = {
'added': 'You are now an owner of {}'.format(options['resource_name']),
'removed': 'You have been removed as an owner of {}'.format(options['resource_name']),
'edited': 'Your dataset {}\'s metadata has been edited'.format(options['resource_name']),
'requested': 'Request for metadata on {}'.format(options['resource_name']),
}
return notification_subject_dict.get(notification_type, '')
def send_notification(*, notification_type: str, options: Dict, recipients: List, sender: str) -> Response:
"""
Sends a notification via email to a given list of recipients
:param notification_type: type of notification
:param options: data necessary to render email template content
:param recipients: list of recipients who should receive notification
:param sender: email of notification sender
:return: Response
"""
@action_logging
def _log_send_notification(*, notification_type: str, options: Dict, recipients: List, sender: str) -> None:
""" Logs the content of a sent notification"""
pass # pragma: no cover
try:
if not app.config['NOTIFICATIONS_ENABLED']:
message = 'Notifications are not enabled. Request was accepted but no notification will be sent.'
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.ACCEPTED)
if sender in recipients:
recipients.remove(sender)
if len(recipients) == 0:
logging.info('No recipients exist for notification')
return make_response(
jsonify({
'msg': 'No valid recipients exist for notification, notification was not sent.'
}),
HTTPStatus.OK
)
mail_client = get_mail_client()
html = get_notification_html(notification_type=notification_type, options=options, sender=sender)
subject = get_notification_subject(notification_type=notification_type, options=options)
_log_send_notification(
notification_type=notification_type,
options=options,
recipients=recipients,
sender=sender
)
response = mail_client.send_email(
recipients=recipients,
sender=sender,
subject=subject,
html=html,
optional_data={
'email_type': 'notification'
},
)
status_code = response.status_code
if status_code == HTTPStatus.OK:
message = 'Success'
else:
message = 'Mail client failed with status code ' + str(status_code)
logging.error(message)
return make_response(jsonify({'msg': message}), status_code)
except MailClientNotImplemented as e:
message = 'Encountered exception: ' + str(e)
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.NOT_IMPLEMENTED)
except Exception as e1:
message = 'Encountered exception: ' + str(e1)
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.INTERNAL_SERVER_ERROR)
......@@ -18,12 +18,17 @@ class Config:
# Request Timeout Configurations in Seconds
REQUEST_SESSION_TIMEOUT_SEC = 3
# Mail Client Features
MAIL_CLIENT = None
NOTIFICATIONS_ENABLED = False
class LocalConfig(Config):
DEBUG = False
TESTING = False
LOG_LEVEL = 'DEBUG'
FRONTEND_PORT = '5000'
# If installing locally directly from the github source
# modify these ports if necessary to point to you local search and metadata services
SEARCH_PORT = '5001'
......@@ -32,6 +37,12 @@ class LocalConfig(Config):
# If installing using the Docker bootstrap, this should be modified to the docker host ip.
LOCAL_HOST = '0.0.0.0'
FRONTEND_BASE = os.environ.get('FRONTEND_BASE',
'http://{LOCAL_HOST}:{PORT}'.format(
LOCAL_HOST=LOCAL_HOST,
PORT=FRONTEND_PORT)
)
SEARCHSERVICE_REQUEST_CLIENT = None
SEARCHSERVICE_REQUEST_HEADERS = None
SEARCHSERVICE_BASE = os.environ.get('SEARCHSERVICE_BASE',
......@@ -57,8 +68,12 @@ class LocalConfig(Config):
AUTH_USER_METHOD = None # type: Optional[function]
GET_PROFILE_URL = None
MAIL_CLIENT = None
class TestConfig(LocalConfig):
AUTH_USER_METHOD = get_test_user
NOTIFICATIONS_ENABLED = True
class TestNotificationsDisabledConfig(LocalConfig):
AUTH_USER_METHOD = get_test_user
NOTIFICATIONS_ENABLED = False
.dropdown-menu {
box-shadow: 0 0 24px -2px rgba(0, 0, 0, .2);
border-radius: 5px;
border-style: none;
padding: 0;
overflow: hidden;
li {
&:hover {
background-color: $gray-lightest;
}
a {
padding: 8px;
&:hover {
background-color: inherit;
}
}
}
}
......@@ -110,6 +110,11 @@ img.icon {
-webkit-mask-image: url('/static/images/icons/users.svg');
mask-image: url('/static/images/icons/users.svg');
}
&.icon-more {
-webkit-mask-image: url('/static/images/icons/More.svg');
mask-image: url('/static/images/icons/More.svg');
}
}
.disabled,
......
......@@ -86,3 +86,9 @@ $list-group-border-radius: 0 !default;
// Labels
$label-primary-bg: $brand-color-3 !default;
// Spacing
$spacer-size: 8px;
$spacer-1: $spacer-size;
$spacer-2: $spacer-size * 2;
$spacer-3: $spacer-size * 3;
......@@ -2,6 +2,7 @@
@import 'avatars';
@import 'buttons';
@import 'dropdowns';
@import 'fonts';
@import 'icons';
@import 'list-group';
......
......@@ -2,7 +2,7 @@ import * as React from 'react';
import LoadingSpinner from 'components/common/LoadingSpinner';
// TODO: Use css-modules instead of 'import'
import './styles.scss';
import '../styles.scss';
import { ResetFeedbackRequest, SubmitFeedbackRequest } from 'ducks/feedback/types';
import { SendingState } from 'interfaces';
......
@import 'variables';
.submit {
float: right;
margin-top: 16px;
}
.submit:hover {
background-color: $gray-lighter;
}
.radio-set {
display: flex;
margin-top: 8px;
}
.radio-set-item {
cursor: pointer;
}
.radio-set .radio-set-item:not(:first-child) {
margin-left: 12px;
}
.radio-set-item label {
width: 100%;
text-align: center;
}
.nps-label {
font-family: $font-family-header;
font-weight: $font-weight-header-regular;
margin-bottom: 15px;
width: 65px;
}
.status-message {
font-family: $font-family-header;
font-weight: $font-weight-header-regular;
text-align: center;
position: absolute;
font-size: 20px;
color: $text-medium;
/* for centering when parent has automatic height */
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
}
input[type="radio"] {
margin: 5px;
}
input[type="text"] {
color: $text-medium !important;
}
textarea {
width: 100%;
color: $text-medium !important;
border: 1px solid $gray-lighter;
border-radius: 5px;
padding: 10px;
}
......@@ -58,4 +58,68 @@
.btn-group {
margin: 8px auto 16px;
}
.submit {
float: right;
margin-top: 16px;
}
.submit:hover {
background-color: $gray-lighter;
}
.radio-set {
display: flex;
margin-top: 8px;
}
.radio-set-item {
cursor: pointer;
}
.radio-set .radio-set-item:not(:first-child) {
margin-left: 12px;
}
.radio-set-item label {
width: 100%;
text-align: center;
}
.nps-label {
font-family: $font-family-header;
font-weight: $font-weight-header-regular;
margin-bottom: 15px;
width: 65px;
}
.status-message {
font-family: $font-family-header;
font-weight: $font-weight-header-regular;
text-align: center;
position: absolute;
font-size: 20px;
color: $text-medium;
/* for centering when parent has automatic height */
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
}
input[type="radio"] {
margin: 5px;
}
input[type="text"] {
color: $text-medium !important;
}
textarea {
width: 100%;
color: $text-medium !important;
border: 1px solid $gray-lighter;
border-radius: 5px;
padding: 10px;
}
}
......@@ -7,6 +7,7 @@ import AppConfig from 'config/config';
import { LinkConfig } from 'config/config-types';
import { GlobalState } from 'ducks/rootReducer';
import { logClick } from 'ducks/utilMethods';
import { Dropdown } from 'react-bootstrap';
import { LoggedInUser } from 'interfaces';
......@@ -53,11 +54,22 @@ export class NavBar extends React.Component<NavBarProps> {
{this.generateNavLinks(AppConfig.navLinks)}
{
this.props.loggedInUser && AppConfig.indexUsers.enabled &&
<Link id="nav-bar-avatar-link" to={`/user/${this.props.loggedInUser.user_id}?source=navbar`}>
<div id="nav-bar-avatar">
<Dropdown id='user-dropdown' pullRight={true}>
<Dropdown.Toggle noCaret={true} className="avatar-dropdown">
<Avatar name={this.props.loggedInUser.display_name} size={32} round={true} />
</div>
</Link>
</Dropdown.Toggle>
<Dropdown.Menu className='profile-menu'>
<div className='profile-menu-header'>
<div className='title-2'>{this.props.loggedInUser.display_name}</div>
<div>{this.props.loggedInUser.email}</div>
</div>
<li>
<Link id="nav-bar-avatar-link" to={`/user/${this.props.loggedInUser.user_id}?source=navbar`}>
My Profile
</Link>
</li>
</Dropdown.Menu>
</Dropdown>
}
{
this.props.loggedInUser && !AppConfig.indexUsers.enabled &&
......
......@@ -54,3 +54,26 @@
max-width: 144px;
margin-right: 20px;
}
.avatar-dropdown {
border-style: none;
padding: 0 !important;
border-radius: 50%;
}
.profile-menu {
$profile-menu-width: 200px;
width: $profile-menu-width;
.profile-menu-header {
padding: 16px 16px 0 16px;
}
li {
padding: 16px;
a {
padding: 0;
}
}
}
......@@ -2,6 +2,7 @@ import * as React from 'react';
import * as Avatar from 'react-avatar';
import { shallow } from 'enzyme';
import { Dropdown } from 'react-bootstrap';
import { Link, NavLink } from 'react-router-dom';
import { NavBar, NavBarProps, mapStateToProps } from '../';
......@@ -107,27 +108,35 @@ describe('NavBar', () => {
expect(spy).toHaveBeenCalledWith(AppConfig.navLinks);
});
it('renders Avatar for loggedInUser', () => {
expect(wrapper.find(Avatar).props()).toMatchObject({
name: props.loggedInUser.display_name,
size: 32,
round: true,
})
});
describe('if indexUsers is enabled', () => {
it('renders Avatar for loggedInUser inside of user dropdown', () => {
expect(wrapper.find(Dropdown).find(Dropdown.Toggle).find(Avatar).props()).toMatchObject({
name: props.loggedInUser.display_name,
size: 32,
round: true,
})
});
it('renders a Link to the user profile if `indexUsers` is enabled', () => {
expect(wrapper.find('#nav-bar-avatar-link').exists()).toBe(true)
it('renders user dropdown header', () => {
element = wrapper.find(Dropdown).find(Dropdown.Menu).find('.profile-menu-header');
expect(element.children().at(0).text()).toEqual(props.loggedInUser.display_name);
expect(element.children().at(1).text()).toEqual(props.loggedInUser.email);
});
expect(wrapper.find('#nav-bar-avatar-link').props()).toMatchObject({
to: `/user/${props.loggedInUser.user_id}?source=navbar`
it('renders My Profile link correctly inside of user dropdown', () => {
element = wrapper.find(Dropdown).find(Dropdown.Menu).find(Link).at(0);
expect(element.children().text()).toEqual('My Profile');
expect(element.props().to).toEqual('/user/test0?source=navbar');
});
});
it('does not render a Link to the user profile if `indexUsers` is disabled', () => {
AppConfig.indexUsers.enabled = false;
const { wrapper } = setup();
expect(wrapper.find('#nav-bar-avatar-link').exists()).toBe(false)
});
describe('if indexUsers is disabled', () => {
it('does not render a Link to the user profile', () => {
AppConfig.indexUsers.enabled = false;
const { wrapper } = setup();
expect(wrapper.find('#nav-bar-avatar-link').exists()).toBe(false)
});
})
});
});
......
import * as React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
export type PreferenceGroupProps = {
onClick: (value: string) => any;
preferenceValue: string;
selected: boolean;
title: string;
subtitle: string;
}
export class PreferenceGroup extends React.Component<PreferenceGroupProps> {
public static defaultProps: Partial<PreferenceGroupProps> = {
selected: false,
title: '',
subtitle: '',
};
constructor(props) {
super(props);
}
onClick = () => {
this.props.onClick(this.props.preferenceValue)
}
// TODO: Consolidate with future common RadioButton component.
render() {
return (
<label className="preference-group" onClick={this.onClick}>
<input defaultChecked={ this.props.selected } type="radio" className="preference-radio" name="notification-preference"/>
<div className="preference-text">
<div className="title-2">{ this.props.title }</div>
<div className="body-secondary-3">{ this.props.subtitle }</div>
</div>
</label>
);
}
}
export default PreferenceGroup;
/* TODO: harcoded string that should be translatable/customizable */
export const NOTIFICATION_PREFERENCES_TITLE = 'Notification Preferences';
export const ALL_NOTIFICATIONS_TITLE = 'All Notifications';
export const ALL_NOTIFICATIONS_SUBTITLE = 'You will get notified via email regarding any activity on tables you own.';
export const MINIMUM_NOTIFICATIONS_TITLE = 'Minimum Notifications Only';
export const MINIMUM_NOTIFICATIONS_SUBTITLE = "You will only be notified when you're being added as an owner, removed as an owner, or receive a description request on any table you own.";
export const ALL_PREFERENCE = 'all-preference'
export const MINIMUM_PREFERENCE = 'minimum-preference'
import * as React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { PreferenceGroup } from './PreferenceGroup';
// TODO: Use css-modules instead of 'import'
import './styles.scss';
import {
ALL_NOTIFICATIONS_SUBTITLE,
ALL_NOTIFICATIONS_TITLE,
ALL_PREFERENCE,
MINIMUM_NOTIFICATIONS_SUBTITLE,
MINIMUM_NOTIFICATIONS_TITLE,
MINIMUM_PREFERENCE,
NOTIFICATION_PREFERENCES_TITLE
} from './constants';
// TODO: Implement tests before component is exposed
interface PreferencesPageState {
selectedPreference: string;
}
export interface DispatchFromProps {
}
export type PreferencesPageProps = DispatchFromProps;
export class PreferencesPage extends React.Component<PreferencesPageProps, PreferencesPageState> {
constructor(props) {
super(props);
this.changePreference = this.changePreference.bind(this);
this.state = {
selectedPreference: ALL_PREFERENCE,
}
}
changePreference = (newPreference) => {
this.setState({
selectedPreference: newPreference,
})
}
render() {
return (
<div className="container">
<div className="row">
<div className="col-xs-12 col-md-offset-1 col-md-10">
<h1 className="preferences-title">{NOTIFICATION_PREFERENCES_TITLE}</h1>
<PreferenceGroup
onClick={this.changePreference}
preferenceValue={ALL_PREFERENCE}
selected={this.state.selectedPreference === ALL_PREFERENCE ? true : false}
title={ALL_NOTIFICATIONS_TITLE}
subtitle={ALL_NOTIFICATIONS_SUBTITLE}
/>
<PreferenceGroup
onClick={this.changePreference}
preferenceValue={MINIMUM_PREFERENCE}
selected={this.state.selectedPreference === MINIMUM_PREFERENCE ? true : false}
title={MINIMUM_NOTIFICATIONS_TITLE}
subtitle={MINIMUM_NOTIFICATIONS_SUBTITLE}
/>
</div>
</div>
</div>
);
}
}
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({}, dispatch);
};
export default connect<DispatchFromProps>(null, mapDispatchToProps)(PreferencesPage);
@import 'variables';
.preferences-title {
margin-bottom: 72px;
}
.preference-group {
display: flex;
margin-bottom: 32px;
}
.preference-radio {
}
.preference-text {
margin-left: 16px;
}
\ No newline at end of file
import * as React from 'react';
import { OverlayTrigger, Popover } from 'react-bootstrap';
import moment from 'moment-timezone';
import { Dropdown, MenuItem, OverlayTrigger, Popover } from 'react-bootstrap';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { notificationsEnabled } from 'config/config-utils';
import AppConfig from 'config/config';
import ColumnDescEditableText from 'components/TableDetail/ColumnDescEditableText';
import { GlobalState } from 'ducks/rootReducer';
import { logClick } from 'ducks/utilMethods';
import { ToggleRequestAction } from 'ducks/notification/types';
import { openRequestDescriptionDialog } from 'ducks/notification/reducer';
import { TableColumn } from 'interfaces';
// TODO: Use css-modules instead of 'import'
import './styles.scss';
interface DetailListItemProps {
interface DispatchFromProps {
openRequestDescriptionDialog: () => ToggleRequestAction;
}
interface OwnProps {
data?: TableColumn;
index: number;
}
export type DetailListItemProps = DispatchFromProps & OwnProps;
interface DetailListItemState {
isExpanded: boolean;
}
class DetailListItem extends React.Component<DetailListItemProps, DetailListItemState> {
public static defaultProps: DetailListItemProps = {
public static defaultProps: Partial<DetailListItemProps> = {
data: {} as TableColumn,
index: null,
};
......@@ -32,6 +46,10 @@ class DetailListItem extends React.Component<DetailListItemProps, DetailListItem
};
}
openRequest = () => {
this.props.openRequestDescriptionDialog();
}
onClick = (e) => {
if (!this.state.isExpanded) {
const metadata = this.props.data;
......@@ -127,13 +145,26 @@ class DetailListItem extends React.Component<DetailListItemProps, DetailListItem
<img className={'icon ' + (this.state.isExpanded ? 'icon-up' : 'icon-down')}/>
}
</div>
<div className={'body-secondary-3 description ' + (isExpandable && !this.state.isExpanded ? 'truncated' : '')}>
<ColumnDescEditableText
columnIndex={this.props.index}
editable={metadata.is_editable}
value={metadata.description}
maxLength={AppConfig.editableText.columnDescLength}
/>
<div className='description-container'>
<div className={'body-secondary-3 description ' + (isExpandable && !this.state.isExpanded ? 'truncated' : '')}>
<ColumnDescEditableText
columnIndex={this.props.index}
editable={metadata.is_editable}
value={metadata.description}
maxLength={AppConfig.editableText.columnDescLength}
/>
</div>
{
notificationsEnabled() &&
<Dropdown id={`detail-list-item-dropdown:${this.props.index}`} pullRight={true}>
<Dropdown.Toggle noCaret={true} className="dropdown-icon-more">
<img className="icon icon-more"/>
</Dropdown.Toggle>
<Dropdown.Menu>
<MenuItem onClick={this.openRequest}>Request Column Description</MenuItem>
</Dropdown.Menu>
</Dropdown>
}
</div>
{
this.state.isExpanded &&
......@@ -163,4 +194,8 @@ class DetailListItem extends React.Component<DetailListItemProps, DetailListItem
}
}
export default DetailListItem;
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ openRequestDescriptionDialog } , dispatch);
};
export default connect<{}, DispatchFromProps, OwnProps>(null, mapDispatchToProps)(DetailListItem);
......@@ -14,7 +14,6 @@
.description {
max-width: 100%;
min-width: 0;
padding-right: 32px;
}
.truncated .editable-text,
......@@ -24,6 +23,11 @@
text-overflow: ellipsis;
}
.description-container {
display: flex;
justify-content: space-between;
}
.column-info {
display: flex;
flex-direction: row;
......@@ -57,7 +61,7 @@
cursor: pointer;
&:hover {
background-image: linear-gradient($gray-lighter, $gray-lighter, white);
background-image: linear-gradient($gray-lightest, $gray-lightest, white);
.icon {
background-color: $brand-color-4;
......@@ -85,4 +89,34 @@
margin-top: 4px;
}
}
.open {
.dropdown-icon-more {
box-shadow: none;
visibility: visible;
}
}
.dropdown-icon-more {
border-style: none;
border-radius: 4px;
height: 22px;
width: 22px;
padding: 4px;
margin-right: 5px;
.icon {
background-color: $gray-light;
height: 14px;
-webkit-mask-size: 14px;
mask-size: 14px;
width: 14px;
margin: 0;
}
&:hover,
&:focus {
background-color: $gray-lightest;
.icon {
background-color: $gray-base;
}
}
}
}
......@@ -8,7 +8,7 @@ import serialize from 'form-serialize';
import AvatarLabel, { AvatarLabelProps } from 'components/common/AvatarLabel';
import LoadingSpinner from 'components/common/LoadingSpinner';
import { Modal } from 'react-bootstrap';
import { UpdateMethod } from 'interfaces';
import { UpdateMethod, UpdateOwnerPayload } from 'interfaces';
// TODO: Use css-modules instead of 'import'
import './styles.scss';
......@@ -19,7 +19,7 @@ import { GlobalState } from 'ducks/rootReducer';
import { updateTableOwner } from 'ducks/tableMetadata/owners/reducer';
export interface DispatchFromProps {
onUpdateList: (updateArray: { method: UpdateMethod; id: string; }[], onSuccess?: () => any, onFailure?: () => any) => void;
onUpdateList: (updateArray: UpdateOwnerPayload[], onSuccess?: () => any, onFailure?: () => any) => void;
}
export interface ComponentProps {
......
......@@ -11,6 +11,10 @@
label {
width: 100%;
}
.status-message {
font-weight: normal;
}
}
.owner-editor-modal {
......@@ -79,12 +83,6 @@
}
}
.status-message {
margin-left: 4px;
margin-bottom: 4px;
font-weight: normal;
}
.component-list {
list-style-type: none;
padding: 0;
......
import * as React from 'react';
import './styles.scss';
import { GlobalState } from 'ducks/rootReducer';
import { connect } from 'react-redux';
import { ToggleRequestAction } from 'ducks/notification/types';
import { openRequestDescriptionDialog } from 'ducks/notification/reducer';
import { bindActionCreators } from 'redux';
import { REQUEST_DESCRIPTION } from './constants';
export interface DispatchFromProps {
openRequestDescriptionDialog: () => ToggleRequestAction;
}
export type RequestDescriptionTextProps = DispatchFromProps;
interface RequestDescriptionTextState {}
export class RequestDescriptionText extends React.Component<RequestDescriptionTextProps, RequestDescriptionTextState> {
public static defaultProps: Partial<RequestDescriptionTextProps> = {};
constructor(props) {
super(props);
}
openRequest = () => {
this.props.openRequestDescriptionDialog();
}
render() {
return (
<a className="request-description"
href="JavaScript:void(0)"
onClick={ this.openRequest }
>
{ REQUEST_DESCRIPTION }
</a>
);
}
}
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ openRequestDescriptionDialog } , dispatch);
};
export default connect<{}, DispatchFromProps>(null, mapDispatchToProps)(RequestDescriptionText);
@import 'variables';
.request-description {
font-size: 16px;
text-decoration: none;
&:hover,
&:visited,
&:active,
&:link {
text-decoration: none;
}
}
import * as React from 'react';
import { shallow } from 'enzyme';
import { RequestDescriptionText, mapDispatchToProps, RequestDescriptionTextProps } from '../';
import globalState from 'fixtures/globalState';
import { REQUEST_DESCRIPTION } from '../constants';
describe('RequestDescriptionText', () => {
const setup = (propOverrides?: Partial<RequestDescriptionTextProps>) => {
const props: RequestDescriptionTextProps = {
openRequestDescriptionDialog: jest.fn(),
...propOverrides,
};
const wrapper = shallow<RequestDescriptionText>(<RequestDescriptionText {...props} />)
return {props, wrapper}
};
describe('openRequest', () => {
it('calls openRequestDescriptionDialog', () => {
const { props, wrapper } = setup();
const openRequestDescriptionDialogSpy = jest.spyOn(props, 'openRequestDescriptionDialog');
wrapper.instance().openRequest();
expect(openRequestDescriptionDialogSpy).toHaveBeenCalled();
});
});
describe('render', () => {
it('renders Request Description button with correct text', () => {
const { props, wrapper } = setup();
wrapper.instance().render();
expect(wrapper.find('.request-description').text()).toEqual(REQUEST_DESCRIPTION);
});
});
describe('mapDispatchToProps', () => {
let dispatch;
let result;
beforeAll(() => {
dispatch = jest.fn(() => Promise.resolve());
result = mapDispatchToProps(dispatch);
});
it('sets openRequestDescriptionDialog on the props', () => {
expect(result.openRequestDescriptionDialog).toBeInstanceOf(Function);
});
});
});
/* TODO: harcoded string that should be translatable/customizable */
export const TITLE_TEXT = 'Amundsen Resource Request';
export const FROM_LABEL = 'From';
export const TO_LABEL = 'To';
export const REQUEST_TYPE = 'Request Type';
export const TABLE_DESCRIPTION = 'Table Description';
export const COLUMN_DESCRIPTIONS = 'Column Descriptions';
export const ADDITIONAL_DETAILS = 'Additional Details';
export const SEND_BUTTON = 'Send Request';
export const SEND_INPROGRESS_MESSAGE = 'Your request is being sent...';
export const SEND_FAILURE_MESSAGE = 'Your request was not successfully sent, please try again';
export const SEND_SUCCESS_MESSAGE = 'Your request has been successfully sent';
import * as React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import './styles.scss';
import { NotificationType, SendNotificationOptions, SendingState } from 'interfaces';
import FlashMessage from 'components/common/FlashMessage'
import { GlobalState } from 'ducks/rootReducer';
import {
TITLE_TEXT,
FROM_LABEL,
TO_LABEL,
REQUEST_TYPE,
TABLE_DESCRIPTION,
COLUMN_DESCRIPTIONS,
ADDITIONAL_DETAILS,
SEND_BUTTON,
SEND_FAILURE_MESSAGE,
SEND_INPROGRESS_MESSAGE,
SEND_SUCCESS_MESSAGE,
} from './constants'
import { ToggleRequestAction, SubmitNotificationRequest } from 'ducks/notification/types';
import { closeRequestDescriptionDialog, submitNotification } from 'ducks/notification/reducer';
interface StateFromProps {
userEmail: string;
displayName: string;
tableOwners: Array<string>;
requestIsOpen: boolean;
sendState: SendingState;
}
export interface DispatchFromProps {
submitNotification: (
recipients: Array<string>,
sender: string,
notificationType: NotificationType,
options?: SendNotificationOptions
) => SubmitNotificationRequest;
closeRequestDescriptionDialog: () => ToggleRequestAction;
}
export type RequestMetadataProps = StateFromProps & DispatchFromProps;
interface RequestMetadataState {}
export class RequestMetadataForm extends React.Component<RequestMetadataProps, RequestMetadataState> {
public static defaultProps: Partial<RequestMetadataProps> = {};
constructor(props) {
super(props);
}
componentWillUnmount = () => {
this.props.closeRequestDescriptionDialog();
}
closeDialog = () => {
this.props.closeRequestDescriptionDialog();
}
getFlashMessageString = (): string => {
switch(this.props.sendState) {
case SendingState.COMPLETE:
return SEND_SUCCESS_MESSAGE;
case SendingState.ERROR:
return SEND_FAILURE_MESSAGE;
case SendingState.WAITING:
return SEND_INPROGRESS_MESSAGE;
default:
return '';
}
};
renderFlashMessage = () => {
return (
<FlashMessage
iconClass='icon-mail'
message={this.getFlashMessageString()}
onClose={this.closeDialog}
/>
)
}
submitNotification = (event) => {
event.preventDefault();
const form = document.getElementById("RequestForm") as HTMLFormElement;
const formData = new FormData(form);
const recipientString = formData.get('recipients') as string
const recipients = recipientString.split(",")
const sender = formData.get('sender') as string;
const descriptionRequested = formData.get('table-description') === "on";
const fieldsRequested = formData.get('column-description') === "on";
const comment = formData.get('comment') as string;
this.props.submitNotification(
recipients,
sender,
NotificationType.METADATA_REQUESTED,
{
comment,
resource_name: this.props.displayName,
resource_url: window.location.href,
description_requested: descriptionRequested,
fields_requested: fieldsRequested,
}
)
};
render() {
if (this.props.sendState !== SendingState.IDLE) {
return (
<div className="request-component">
{this.renderFlashMessage()}
</div>
);
}
if (!this.props.requestIsOpen) {
return (null);
}
return (
<div className="request-component expanded">
<div id="request-metadata-title" className="form-group request-header">
<h3 className="title">{TITLE_TEXT}</h3>
<button type="button" className="btn btn-close" aria-label={"Close"} onClick={this.closeDialog}/>
</div>
<form onSubmit={ this.submitNotification } id="RequestForm">
<div id="sender-form-group" className="form-group">
<label>{FROM_LABEL}</label>
<input type="email" name="sender" className="form-control" required={true} value={this.props.userEmail} readOnly={true}/>
</div>
<div id="recipients-form-group" className="form-group">
<label>{TO_LABEL}</label>
<input type="email" name="recipients" className="form-control" required={true} multiple={true} defaultValue={this.props.tableOwners.join(",")}/>
</div>
<div id="request-type-form-group" className="form-group">
<label>{REQUEST_TYPE}</label>
<label className="select-label"><input type="checkbox" name="table-description"/>{TABLE_DESCRIPTION}</label>
<label className="select-label"><input type="checkbox" name="column-description"/>{COLUMN_DESCRIPTIONS}</label>
</div>
<div id="additional-comments-form-group" className="form-group">
<label>{ADDITIONAL_DETAILS}</label>
<textarea className="form-control" name="comment" rows={ 8 } maxLength={ 2000 } />
</div>
<button id="submit-request-button" className="btn btn-primary" type="submit">
{SEND_BUTTON}
</button>
</form>
</div>
);
}
}
export const mapStateToProps = (state: GlobalState) => {
const userEmail = state.user.loggedInUser.email;
const displayName = `${state.tableMetadata.tableData.schema}.${state.tableMetadata.tableData.table_name}`;
const ownerObj = state.tableMetadata.tableOwners.owners;
const { requestIsOpen, sendState } = state.notification;
return {
userEmail,
displayName,
requestIsOpen,
sendState,
tableOwners: Object.keys(ownerObj),
};
};
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ submitNotification, closeRequestDescriptionDialog } , dispatch);
};
export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(RequestMetadataForm);
@import 'variables';
input[type="email"] {
color: $text-medium !important;
}
.request-component {
box-shadow: 0 0 24px -2px rgba(0, 0, 0, .2);
border-radius: 6px;
bottom: 75px;
display: block;
left: 25px;
position: fixed;
z-index: 6;
&.expanded {
background-color: white;
height: auto;
min-height: 450px;
padding: 32px;
width: 400px;
.title {
flex-grow: 1;
}
.request-header {
display: flex;
}
.select-label {
display: block;
font-weight: $font-weight-body-regular;
}
input[type="checkbox"] {
margin-right: 8px;
}
}
}
import * as React from 'react';
import { shallow } from 'enzyme';
import FlashMessage from 'components/common/FlashMessage';
import globalState from 'fixtures/globalState';
import { NotificationType, SendingState } from 'interfaces';
import { RequestMetadataForm, mapDispatchToProps, mapStateToProps, RequestMetadataProps } from '../';
import {
TITLE_TEXT,
FROM_LABEL,
TO_LABEL,
REQUEST_TYPE,
TABLE_DESCRIPTION,
COLUMN_DESCRIPTIONS,
ADDITIONAL_DETAILS,
SEND_BUTTON,
SEND_FAILURE_MESSAGE,
SEND_INPROGRESS_MESSAGE,
SEND_SUCCESS_MESSAGE,
} from '../constants'
const mockFormData = {
'recipients': 'test1@test.com,test2@test.com',
'sender': 'test@test.com',
'table-description': 'on',
'fields-requested': 'off',
'comment': 'test',
get: jest.fn(),
}
mockFormData.get.mockImplementation((val) => {
return mockFormData[val];
})
// @ts-ignore: How to mock FormData without TypeScript error?
global.FormData = () => (mockFormData);
describe('RequestMetadataForm', () => {
const setup = (propOverrides?: Partial<RequestMetadataProps>) => {
const props: RequestMetadataProps = {
userEmail: 'test0@lyft.com',
displayName: '',
tableOwners: ['test1@lyft.com', 'test2@lyft.com'],
submitNotification: jest.fn(),
requestIsOpen: true,
sendState: SendingState.IDLE,
closeRequestDescriptionDialog: jest.fn(),
...propOverrides,
};
const wrapper = shallow<RequestMetadataForm>(<RequestMetadataForm {...props} />);
return {props, wrapper}
};
describe('componentWillUnmount', () => {
it('calls closeRequestDescriptionDialog', () => {
const { props, wrapper } = setup();
const closeRequestDescriptionDialogSpy = jest.spyOn(props, 'closeRequestDescriptionDialog');
wrapper.instance().componentWillUnmount();
expect(closeRequestDescriptionDialogSpy).toHaveBeenCalled();
});
});
describe('closeDialog', () => {
it('calls closeRequestDescriptionDialog', () => {
const { props, wrapper } = setup();
const closeRequestDescriptionDialogSpy = jest.spyOn(props, 'closeRequestDescriptionDialog');
wrapper.instance().closeDialog();
expect(closeRequestDescriptionDialogSpy).toHaveBeenCalled();
});
});
describe('getFlashMessageString', () => {
it('returns SEND_SUCCESS_MESSAGE if SendingState.COMPLETE', () => {
const wrapper = setup({ sendState: SendingState.COMPLETE }).wrapper;
expect(wrapper.instance().getFlashMessageString()).toEqual(SEND_SUCCESS_MESSAGE);
});
it('returns SEND_FAILURE_MESSAGE if SendingState.ERROR', () => {
const wrapper = setup({ sendState: SendingState.ERROR }).wrapper;
expect(wrapper.instance().getFlashMessageString()).toEqual(SEND_FAILURE_MESSAGE);
});
it('returns SEND_INPROGRESS_MESSAGE if SendingState.WAITING', () => {
const wrapper = setup({ sendState: SendingState.WAITING }).wrapper;
expect(wrapper.instance().getFlashMessageString()).toEqual(SEND_INPROGRESS_MESSAGE);
});
it('returns empty striong if sending state not handled', () => {
const wrapper = setup({ sendState: SendingState.IDLE }).wrapper;
expect(wrapper.instance().getFlashMessageString()).toEqual('');
});
});
describe('renderFlashMessage', () => {
let wrapper;
let mockString;
let getFlashMessageStringMock;
beforeAll(() => {
wrapper = setup().wrapper;
mockString = 'I am the message'
getFlashMessageStringMock = jest.spyOn(wrapper.instance(), 'getFlashMessageString').mockImplementation(() => {
return mockString;
});
});
it('renders a FlashMessage with correct props', () => {
const element = wrapper.instance().renderFlashMessage();
expect(element.props.iconClass).toEqual('icon-mail');
expect(element.props.message).toBe(mockString);
expect(element.props.onClose).toEqual(wrapper.instance().closeDialog);
});
});
describe('submitNotification', () => {
it('calls submitNotification', () => {
const { props, wrapper } = setup();
const submitNotificationSpy = jest.spyOn(props, 'submitNotification');
wrapper.instance().submitNotification({ preventDefault: jest.fn() });
expect(submitNotificationSpy).toHaveBeenCalledWith(
mockFormData['recipients'].split(','),
mockFormData['sender'],
NotificationType.METADATA_REQUESTED,
{
comment: mockFormData['comment'],
resource_name: props.displayName,
resource_url: window.location.href,
description_requested: true,
fields_requested: false,
}
);
});
});
describe('render', () => {
let props;
let wrapper;
let element;
describe('when this.props.requestIsOpen', () => {
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
});
it('renders header title', () => {
element = wrapper.find('#request-metadata-title');
expect(element.find('h3').text()).toEqual(TITLE_TEXT);
});
it('renders close button', () => {
element = wrapper.find('#request-metadata-title');
expect(element.find('button').exists()).toEqual(true);
});
it('renders from input with current user', () => {
element = wrapper.find('#sender-form-group');
expect(element.find('input').props().value).toEqual('test0@lyft.com');
});
it('renders from label', () => {
element = wrapper.find('#sender-form-group');
expect(element.find('label').text()).toEqual(FROM_LABEL);
});
it('renders from input with current user', () => {
element = wrapper.find('#sender-form-group');
expect(element.find('input').props().value).toEqual('test0@lyft.com');
});
it('renders to label', () => {
element = wrapper.find('#recipients-form-group');
expect(element.find('label').text()).toEqual(TO_LABEL);
});
it('renders to input with correct recipients', () => {
element = wrapper.find('#recipients-form-group');
expect(element.find('input').props().defaultValue).toEqual('test1@lyft.com,test2@lyft.com');
});
it('renders request type label', () => {
element = wrapper.find('#request-type-form-group');
expect(element.find('label').at(0).text()).toEqual(REQUEST_TYPE);
});
it('renders table description checkbox', () => {
element = wrapper.find('#request-type-form-group');
expect(element.find('label').at(1).text()).toEqual(TABLE_DESCRIPTION);
});
it('renders column descriptions checkbox', () => {
element = wrapper.find('#request-type-form-group');
expect(element.find('label').at(2).text()).toEqual(COLUMN_DESCRIPTIONS);
});
it('renders additional details label', () => {
element = wrapper.find('#additional-comments-form-group');
expect(element.find('label').text()).toEqual(ADDITIONAL_DETAILS);
});
it('renders empty textarea', () => {
element = wrapper.find('#additional-comments-form-group');
expect(element.find('textarea').text()).toEqual('');
});
it('renders submit button with correct text', () => {
element = wrapper.find('#submit-request-button');
expect(element.text()).toEqual(SEND_BUTTON);
});
});
describe('when !this.props.requestIsOpen', () => {
beforeAll(() => {
const setupResult = setup({ requestIsOpen: false });
props = setupResult.props;
wrapper = setupResult.wrapper;
});
it('renders nothing', () => {
expect(wrapper).toEqual({});
});
});
describe('when sendState is not SendingState.IDLE', () => {
let wrapper;
let renderFlashMessageMock;
beforeAll(() => {
wrapper = setup({ sendState: SendingState.WAITING, requestIsOpen: false }).wrapper;
renderFlashMessageMock = jest.spyOn(wrapper.instance(), 'renderFlashMessage');
});
it('renders results of renderFlashMessage() within component', () => {
wrapper.instance().render();
expect(wrapper.props().className).toEqual('request-component');
expect(renderFlashMessageMock).toHaveBeenCalledTimes(1);
});
});
});
describe('mapStateToProps', () => {
let result;
beforeAll(() => {
result = mapStateToProps(globalState);
});
it('sets userEmail on the props', () => {
expect(result.userEmail).toEqual(globalState.user.loggedInUser.email);
});
it('sets displayName on the props', () => {
expect(result.displayName).toEqual(globalState.tableMetadata.tableData.schema + '.' + globalState.tableMetadata.tableData.table_name);
});
it('sets ownerObj on the props', () => {
expect(result.tableOwners).toEqual(Object.keys(globalState.tableMetadata.tableOwners.owners));
});
it('sets requestIsOpen on the props', () => {
expect(result.requestIsOpen).toEqual(globalState.notification.requestIsOpen);
});
it('sets sendState on the props', () => {
expect(result.sendState).toEqual(globalState.notification.sendState);
});
});
describe('mapDispatchToProps', () => {
let dispatch;
let result;
beforeAll(() => {
dispatch = jest.fn(() => Promise.resolve());
result = mapDispatchToProps(dispatch);
});
it('sets submitNotification on the props', () => {
expect(result.submitNotification).toBeInstanceOf(Function);
});
it('sets closeRequestDescriptionDialog on the props', () => {
expect(result.closeRequestDescriptionDialog).toBeInstanceOf(Function);
});
});
});
......@@ -12,6 +12,7 @@ import { getPreviewData, getTableData } from 'ducks/tableMetadata/reducer';
import { GetTableDataRequest } from 'ducks/tableMetadata/types';
import AppConfig from 'config/config';
import { notificationsEnabled } from 'config/config-utils';
import AvatarLabel from 'components/common/AvatarLabel';
import Breadcrumb from 'components/common/Breadcrumb';
import EntityCard from 'components/common/EntityCard';
......@@ -22,6 +23,7 @@ import TagInput from 'components/Tags/TagInput';
import DataPreviewButton from './DataPreviewButton';
import DetailList from './DetailList';
import RequestDescriptionText from './RequestDescriptionText';
import OwnerEditor from './OwnerEditor';
import TableDescEditableText from './TableDescEditableText';
import WatermarkLabel from "./WatermarkLabel";
......@@ -35,6 +37,7 @@ import { PreviewQueryParams, TableMetadata, User } from 'interfaces';
// TODO: Use css-modules instead of 'import'
import './styles.scss';
import BookmarkIcon from "components/common/Bookmark/BookmarkIcon";
import RequestMetadataForm from './RequestMetadataForm';
export interface StateFromProps {
isLoading: boolean;
......@@ -341,6 +344,10 @@ export class TableDetail extends React.Component<TableDetailProps & RouteCompone
innerContent = (
<div className="container table-detail">
<Breadcrumb />
{
notificationsEnabled() &&
<RequestMetadataForm />
}
<div className="row">
<div className="detail-header col-xs-12 col-md-7 col-lg-8">
<h1 className="detail-header-text">
......@@ -358,6 +365,7 @@ export class TableDetail extends React.Component<TableDetailProps & RouteCompone
editable={ data.is_editable }
maxLength={ AppConfig.editableText.tableDescLength }
/>
{ !data.table_description && notificationsEnabled() && <RequestDescriptionText/> }
</div>
<div className="col-xs-12 col-md-5 float-md-right col-lg-4">
<EntityCard sections={ this.createEntityCardSections() }/>
......
import * as React from 'react';
import './styles.scss';
export interface FlashMessageProps {
iconClass?: string | null;
message: string;
onClose: (event: React.MouseEvent<HTMLButtonElement>) => void;
}
const FlashMessage: React.SFC<FlashMessageProps> = ({ iconClass, message, onClose }) => {
return (
<div className="flash-message">
{
iconClass &&
<img className={`icon ${iconClass}`}/>
}
<div className="message">
{ message }
</div>
<button type="button" className="btn btn-close" aria-label={"Close"} onClick={onClose}/>
</div>
);
};
FlashMessage.defaultProps = {
iconClass: null,
};
export default FlashMessage;
@import 'variables';
.flash-message {
background-color: #292936; // $gray100
border-radius: 6px;
display: flex;
color: white;
height: 56px;
padding: $spacer-2 $spacer-1;
.message {
margin-right: $spacer-2;
line-height: 24px;
}
.icon,
.btn-close {
margin: auto $spacer-1;
}
}
import * as React from 'react';
import { shallow } from 'enzyme';
import FlashMessage, { FlashMessageProps } from '../';
describe('FlashMessage', () => {
const setup = (propOverrides?: Partial<FlashMessageProps>) => {
const props: FlashMessageProps = {
iconClass: null,
message: 'Test',
onClose: jest.fn(),
...propOverrides
};
const wrapper = shallow(<FlashMessage {...props} />);
return { props, wrapper };
};
describe('render', () => {
let props: FlashMessageProps;
let wrapper;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
});
describe('iconClass logic', () => {
it('if no iconClass, does not render img', () => {
expect(wrapper.find('img').exists()).toBe(false);
});
it('if iconClass, renders img with correct className', () => {
const testClass = 'icon-mail'
const wrapper = setup({iconClass: testClass}).wrapper;
expect(wrapper.find('img').props()).toMatchObject({
className: `icon ${testClass}`
});
});
});
it('renders correct message text', () => {
expect(wrapper.find('div.message').text()).toBe(props.message);
});
it('renders correct button', () => {
expect(wrapper.find('button.btn.btn-close').props().onClick).toBe(props.onClose);
});
});
});
......@@ -12,6 +12,10 @@ const configCustom: AppConfigCustom = {
key: 'default-key',
sampleRate: 100,
},
mailClientFeatures: {
feedbackEnabled: false,
notificationsEnabled: false,
},
indexUsers: {
enabled: false,
}
......
......@@ -32,6 +32,10 @@ const configDefault: AppConfig = {
use_router: true,
}
],
mailClientFeatures: {
feedbackEnabled: false,
notificationsEnabled: false,
},
tableLineage: {
iconPath: 'PATH_TO_ICON',
isBeta: false,
......
......@@ -10,6 +10,7 @@ export interface AppConfig {
google: GoogleAnalyticsConfig;
indexUsers: IndexUsersConfig;
logoPath: string | null;
mailClientFeatures: MailClientFeaturesConfig;
navLinks: Array<LinkConfig>;
tableLineage: TableLineageConfig;
tableProfile: TableProfileConfig;
......@@ -21,6 +22,7 @@ export interface AppConfigCustom {
google?: GoogleAnalyticsConfig
indexUsers?: IndexUsersConfig;
logoPath?: string;
mailClientFeatures: MailClientFeaturesConfig;
navLinks?: Array<LinkConfig>;
tableLineage?: TableLineageConfig;
tableProfile?: TableProfileConfig;
......@@ -49,6 +51,18 @@ interface BrowseConfig {
showAllTags: boolean;
}
/**
* MailClientFeaturesConfig - Enable/disable UI features with a dependency on
* configuring a custom mail client.
*
* feedbackEnabled - Enables the feedback feature UI
* notificationsEnabled - Enables any UI related to sending notifications to users
*/
interface MailClientFeaturesConfig {
feedbackEnabled: boolean;
notificationsEnabled: boolean;
}
/**
* TableProfileConfig - Customize the "Table Profile" section of the "Table Details" page.
*
......
import AppConfig from 'config/config';
export function feedbackEnabled(): boolean {
return AppConfig.mailClientFeatures.feedbackEnabled;
}
export function notificationsEnabled(): boolean {
return AppConfig.mailClientFeatures.notificationsEnabled;
}
import AppConfig from 'config/config';
import * as ConfigUtils from 'config/config-utils';
describe('feedbackEnabled', () => {
it('returns whether or not the feaadback feature is enabled', () => {
expect(ConfigUtils.feedbackEnabled()).toBe(AppConfig.mailClientFeatures.feedbackEnabled);
});
});
describe('notificationsEnabled', () => {
it('returns whether or not the notifications feature is enabled', () => {
expect(ConfigUtils.notificationsEnabled()).toBe(AppConfig.mailClientFeatures.notificationsEnabled);
});
});
import axios from 'axios';
import * as API from '../v0';
import { NotificationType } from 'interfaces';
jest.mock('axios');
describe('sendNotification', () => {
it('calls axios with the correct params', async () => {
const testRecipients = ['user1@test.com'];
const testSender = 'user2@test.com';
const testNotificationType = NotificationType.OWNER_ADDED;
const testOptions = {
resource_name: 'testResource',
resource_url: 'https://testResource.com',
description_requested: false,
fields_requested: false,
};
API.sendNotification(
testRecipients,
testSender,
testNotificationType,
testOptions,
)
expect(axios).toHaveBeenCalledWith({
data: {
notificationType: testNotificationType,
options: testOptions,
recipients: testRecipients,
sender: testSender,
},
method: 'post',
url: `/api/mail/v0/notification`,
});
});
});
import axios from 'axios';
import { NotificationType, SendNotificationOptions } from 'interfaces'
export function sendNotification(recipients: Array<string>, sender: string, notificationType: NotificationType, options?: SendNotificationOptions) {
return axios({
data: {
notificationType,
options,
recipients,
sender,
},
method: 'post',
url: `/api/mail/v0/notification`,
});
};
import { NotificationType, SendNotificationOptions, SendingState } from 'interfaces'
import {
SubmitNotification,
SubmitNotificationRequest,
SubmitNotificationResponse,
ToggleRequest,
ToggleRequestAction,
} from './types';
/* ACTIONS */
export function submitNotification(recipients: Array<string>, sender: string, notificationType: NotificationType, options?: SendNotificationOptions): SubmitNotificationRequest {
return {
payload: {
recipients,
sender,
notificationType,
options
},
type: SubmitNotification.REQUEST,
};
};
export function submitNotificationFailure(): SubmitNotificationResponse {
return {
type: SubmitNotification.FAILURE,
};
};
export function submitNotificationSuccess(): SubmitNotificationResponse {
return {
type: SubmitNotification.SUCCESS,
};
};
export function closeRequestDescriptionDialog(): ToggleRequestAction {
return {
type: ToggleRequest.CLOSE,
};
};
export function openRequestDescriptionDialog(): ToggleRequestAction {
return {
type: ToggleRequest.OPEN,
}
}
/* REDUCER */
export interface NotificationReducerState {
requestIsOpen: boolean,
sendState: SendingState,
};
const initialState: NotificationReducerState = {
requestIsOpen: false,
sendState: SendingState.IDLE,
};
export default function reducer(state: NotificationReducerState = initialState, action): NotificationReducerState {
switch (action.type) {
case SubmitNotification.FAILURE:
return {
...state,
sendState: SendingState.ERROR,
}
case SubmitNotification.SUCCESS:
return {
...state,
sendState: SendingState.COMPLETE,
}
case SubmitNotification.REQUEST:
return {
requestIsOpen: false,
sendState: SendingState.WAITING,
}
case ToggleRequest.CLOSE:
return {
requestIsOpen: false,
sendState: SendingState.IDLE,
}
case ToggleRequest.OPEN:
return {
requestIsOpen: true,
sendState: SendingState.IDLE,
}
default:
return state;
}
};
import { SagaIterator } from 'redux-saga';
import { call, put, takeEvery } from 'redux-saga/effects';
import { sendNotification } from './api/v0';
import { submitNotificationFailure, submitNotificationSuccess } from './reducer';
import { SubmitNotification, SubmitNotificationRequest, } from './types';
export function* submitNotificationWorker(action: SubmitNotificationRequest): SagaIterator {
try {
const { notificationType, options, recipients, sender } = action.payload;
yield call(sendNotification, recipients, sender, notificationType, options);
yield put(submitNotificationSuccess());
} catch(error) {
yield put(submitNotificationFailure());
}
}
export function* submitNotificationWatcher(): SagaIterator {
yield takeEvery(SubmitNotification.REQUEST, submitNotificationWorker);
}
import { testSaga } from 'redux-saga-test-plan';
import { NotificationType, SendingState } from 'interfaces';
import * as API from '../api/v0';
import reducer, {
submitNotification,
submitNotificationFailure,
submitNotificationSuccess,
closeRequestDescriptionDialog,
openRequestDescriptionDialog,
NotificationReducerState,
} from '../reducer';
import {
submitNotificationWatcher, submitNotificationWorker
} from '../sagas';
import {
SubmitNotification, ToggleRequest
} from '../types';
const testRecipients = ['user1@test.com'];
const testSender = 'user2@test.com';
const testNotificationType = NotificationType.OWNER_ADDED;
const testOptions = {
resource_name: 'testResource',
resource_url: 'https://testResource.com',
description_requested: false,
fields_requested: false,
};
describe('notifications ducks', () => {
describe('actions', () => {
it('submitNotification - returns the action to submit a notification', () => {
const action = submitNotification(testRecipients, testSender, testNotificationType, testOptions);
const { payload } = action;
expect(action.type).toBe(SubmitNotification.REQUEST);
expect(payload.recipients).toBe(testRecipients);
expect(payload.sender).toBe(testSender);
expect(payload.notificationType).toBe(testNotificationType);
expect(payload.options).toBe(testOptions);
});
it('submitNotificationFailure - returns the action to process failure', () => {
const action = submitNotificationFailure();
expect(action.type).toBe(SubmitNotification.FAILURE);
});
it('submitNotificationSuccess - returns the action to process success', () => {
const action = submitNotificationSuccess();
expect(action.type).toBe(SubmitNotification.SUCCESS);
});
it('closeRequestDescriptionDialog - returns the action to trigger the request description to close', () => {
const action = closeRequestDescriptionDialog();
expect(action.type).toBe(ToggleRequest.CLOSE);
});
it('openRequestDescriptionDialog - returns the action to trigger the request description to opem', () => {
const action = openRequestDescriptionDialog();
expect(action.type).toBe(ToggleRequest.OPEN);
});
});
describe('reducer', () => {
let testState: NotificationReducerState;
beforeAll(() => {
testState = {
requestIsOpen: true,
sendState: SendingState.IDLE,
};
});
it('should return the existing state if action is not handled', () => {
expect(reducer(testState, { type: 'INVALID.ACTION' })).toEqual(testState);
});
it('should handle ToggleRequest.OPEN', () => {
expect(reducer(testState, openRequestDescriptionDialog())).toEqual({
requestIsOpen: true,
sendState: SendingState.IDLE,
});
});
it('should handle ToggleRequest.CLOSE', () => {
expect(reducer(testState, closeRequestDescriptionDialog())).toEqual({
requestIsOpen: false,
sendState: SendingState.IDLE,
});
});
it('should handle SubmitNotification.FAILURE', () => {
expect(reducer(testState, submitNotificationFailure())).toEqual({
...testState,
sendState: SendingState.ERROR,
});
});
it('should handle SubmitNotification.REQUEST', () => {
const action = submitNotification(testRecipients, testSender, testNotificationType, testOptions);
expect(reducer(testState, action)).toEqual({
requestIsOpen: false,
sendState: SendingState.WAITING,
});
});
it('should handle SubmitNotification.SUCCESS', () => {
expect(reducer(testState, submitNotificationSuccess())).toEqual({
...testState,
sendState: SendingState.COMPLETE,
});
});
});
describe('sagas', () => {
describe('submitNotificationWatcher', () => {
it('takes every SubmitNotification.REQUEST with submitNotificationWorker', () => {
testSaga(submitNotificationWatcher)
.next().takeEvery(SubmitNotification.REQUEST, submitNotificationWorker)
.next().isDone();
});
});
describe('submitNotificationWorker', () => {
it('executes flow for submitting notification', () => {
testSaga(submitNotificationWorker, submitNotification(testRecipients, testSender, testNotificationType, testOptions))
.next().call(API.sendNotification, testRecipients, testSender, testNotificationType, testOptions)
.next().put(submitNotificationSuccess())
.next().isDone();
});
it('handles request error', () => {
testSaga(submitNotificationWorker, null)
.next().put(submitNotificationFailure())
.next().isDone();
});
});
});
});
import { NotificationType, SendNotificationOptions } from 'interfaces'
export enum SubmitNotification {
REQUEST = 'amundsen/notification/SUBMIT_NOTIFICATION_REQUEST',
SUCCESS = 'amundsen/notification/SUBMIT_NOTIFICATION_SUCCESS',
FAILURE = 'amundsen/notification/SUBMIT_NOTIFICATION_FAILURE',
};
export interface SubmitNotificationRequest {
type: SubmitNotification.REQUEST;
payload: {
recipients: Array<string>,
sender: string,
notificationType: NotificationType,
options?: SendNotificationOptions
};
};
export interface SubmitNotificationResponse {
type: SubmitNotification.SUCCESS | SubmitNotification.FAILURE;
};
export enum ToggleRequest {
OPEN = 'open',
CLOSE = 'close',
};
export interface ToggleRequestAction {
type: ToggleRequest.OPEN | ToggleRequest.CLOSE;
};
......@@ -8,11 +8,13 @@ import tableMetadata, { TableMetadataReducerState } from './tableMetadata/reduce
import allTags, { AllTagsReducerState } from './allTags/reducer';
import user, { UserReducerState } from './user/reducer';
import bookmarks, { BookmarkReducerState } from "./bookmark/reducer";
import notification, { NotificationReducerState } from './notification/reducer';
export interface GlobalState {
announcements: AnnouncementsReducerState;
bookmarks: BookmarkReducerState;
feedback: FeedbackReducerState;
notification: NotificationReducerState;
popularTables: PopularTablesReducerState;
search: SearchReducerState;
tableMetadata: TableMetadataReducerState;
......@@ -24,6 +26,7 @@ export default combineReducers<GlobalState>({
announcements,
bookmarks,
feedback,
notification,
popularTables,
search,
tableMetadata,
......
......@@ -10,6 +10,9 @@ import {
removeBookmarkWatcher
} from "ducks/bookmark/sagas";
// Notifications
import { submitNotificationWatcher } from './notification/sagas';
// FeedbackForm
import { submitFeedbackWatcher } from './feedback/sagas';
// PopularTables
......@@ -53,6 +56,8 @@ export default function* rootSaga() {
getBookmarksForUserWatcher(),
getBookmarksWatcher(),
removeBookmarkWatcher(),
// Notification
submitNotificationWatcher(),
// FeedbackForm
submitFeedbackWatcher(),
// SearchPage
......
......@@ -2,7 +2,7 @@ import * as qs from 'simple-query-string';
import { filterFromObj, sortTagsAlphabetical } from 'ducks/utilMethods';
import { OwnerDict, TableMetadata, Tag, User } from 'interfaces';
import { NotificationType, OwnerDict, PeopleUser, TableMetadata, Tag, UpdateMethod, UpdateOwnerPayload, User } from 'interfaces';
import * as API from './v0';
/**
......@@ -37,3 +37,38 @@ export function getTableOwnersFromResponseData(responseData: API.TableDataAPI):
export function getTableTagsFromResponseData(responseData: API.TableDataAPI): Tag[] {
return responseData.tableData.tags.sort(sortTagsAlphabetical);
}
/**
* Creates post data for sending a notification to owners when they are added/removed
*/
export function createOwnerNotificationData(payload: UpdateOwnerPayload, resourceName: string) {
return {
notificationType: payload.method === UpdateMethod.PUT ? NotificationType.OWNER_ADDED : NotificationType.OWNER_REMOVED,
options: {
resource_name: resourceName,
resource_url: window.location.href,
},
recipients: [payload.id],
};
};
/**
* Creates axios payload for the request to update an owner
*/
export function createOwnerUpdatePayload(payload: UpdateOwnerPayload, tableKey: string) {
return {
method: payload.method,
url: `${API.API_PATH}/update_table_owner`,
data: {
key: tableKey,
owner: payload.id,
},
}
};
/**
* Workaround logic for not sending emails to alumni or teams.
*/
export function shouldSendNotification(user: PeopleUser): boolean {
return user.is_active && !!user.display_name;
}
......@@ -7,6 +7,8 @@ import * as Utils from 'ducks/utilMethods';
import globalState from 'fixtures/globalState';
import { NotificationType, UpdateMethod, UpdateOwnerPayload } from 'interfaces';
import * as API from '../v0';
const filterFromObjSpy = jest.spyOn(Utils, 'filterFromObj').mockImplementation((initialObject, rejectedKeys) => { return initialObject; });
......@@ -29,7 +31,7 @@ describe('helpers', () => {
});
describe('getTableQueryParams', () => {
it('generates table query params with a key',() => {
it('generates table query params with a key', () => {
const tableKey = 'database://cluster.schema/table';
const queryString = Helpers.getTableQueryParams(tableKey);
const params = qs.parse(queryString);
......@@ -39,7 +41,7 @@ describe('helpers', () => {
expect(params.source).toEqual(undefined);
});
it('generates query params with logging params',() => {
it('generates query params with logging params', () => {
const tableKey = 'database://cluster.schema/table';
const index = '4';
const source = 'test-source';
......@@ -52,21 +54,88 @@ describe('helpers', () => {
});
});
it('getTableDataFromResponseData',() => {
it('getTableDataFromResponseData', () => {
Helpers.getTableDataFromResponseData(mockResponseData);
expect(filterFromObjSpy).toHaveBeenCalledWith(tableResponseData, ['owners', 'tags']);
});
it('getTableOwnersFromResponseData',() => {
it('getTableOwnersFromResponseData', () => {
expect(Helpers.getTableOwnersFromResponseData(mockResponseData)).toEqual({
'test': {display_name: 'test', profile_url: 'test.io', email: 'test@test.com', user_id: 'test'}
});
});
it('getTableTagsFromResponseData',() => {
it('getTableTagsFromResponseData', () => {
expect(Helpers.getTableTagsFromResponseData(mockResponseData)).toEqual([
{tag_count: 1, tag_name: 'aname'},
{tag_count: 2, tag_name: 'zname'},
]);
});
describe('createOwnerNotificationData', () => {
it('creates correct request data for PUT', () => {
const testId = 'testId@test.com';
const testMethod = UpdateMethod.PUT;
const testName = 'schema.tableName';
expect(Helpers.createOwnerNotificationData({ method: testMethod, id: testId }, testName)).toMatchObject({
notificationType: NotificationType.OWNER_ADDED,
options: {
resource_name: testName,
resource_url: window.location.href,
},
recipients: [testId],
});
});
it('creates correct request data for DELETE', () => {
const testId = 'testId@test.com';
const testMethod = UpdateMethod.DELETE;
const testName = 'schema.tableName';
expect(Helpers.createOwnerNotificationData({ method: testMethod, id: testId }, testName)).toMatchObject({
notificationType: NotificationType.OWNER_REMOVED,
options: {
resource_name: testName,
resource_url: window.location.href,
},
recipients: [testId],
});
});
});
it('createOwnerUpdatePayload', () => {
const testId = 'testId@test.com';
const testKey = 'testKey';
const testMethod = UpdateMethod.PUT;
expect(Helpers.createOwnerUpdatePayload({ method: testMethod, id: testId }, testKey)).toMatchObject({
method: testMethod,
url: `${API.API_PATH}/update_table_owner`,
data: {
key: testKey,
owner: testId,
},
});
});
describe('shouldSendNotification', () => {
it('returns false if alumni', () => {
const testUser = {
... globalState.user.loggedInUser,
is_active: false,
}
expect(Helpers.shouldSendNotification(testUser)).toBe(false);
});
it('returns false if not a user with display_name', () => {
const testUser = {
... globalState.user.loggedInUser,
display_name: null,
}
expect(Helpers.shouldSendNotification(testUser)).toBe(false);
});
it('returns true if user is_active and has a display_name', () => {
const testUser = { ... globalState.user.loggedInUser }
expect(Helpers.shouldSendNotification(testUser)).toBe(true);
});
});
});
import axios, { AxiosResponse, AxiosError } from 'axios';
import { PreviewData, PreviewQueryParams, TableMetadata, User, Tag } from 'interfaces';
import { PreviewData, PreviewQueryParams, TableMetadata, UpdateOwnerPayload, User, Tag } from 'interfaces';
const API_PATH = '/api/metadata/v0';
export const API_PATH = '/api/metadata/v0';
// TODO: Consider created shared interfaces for ducks so we can reuse MessageAPI everywhere else
type MessageAPI = { msg: string };
......@@ -19,6 +19,7 @@ export type TableDataAPI= { tableData: TableData; } & MessageAPI;
/** HELPERS **/
import {
getTableQueryParams, getTableDataFromResponseData, getTableOwnersFromResponseData, getTableTagsFromResponseData,
createOwnerUpdatePayload, createOwnerNotificationData, shouldSendNotification
} from './helpers';
export function getTableTags(tableKey: string) {
......@@ -87,19 +88,32 @@ export function getTableOwners(tableKey: string) {
});
}
/* TODO: Typing this method generates redux-saga related type errors that need more dedicated debugging */
export function updateTableOwner(updateArray, tableKey: string) {
const updatePayloads = updateArray.map((item) => {
return {
method: item.method,
url: `${API_PATH}/update_table_owner`,
data: {
key: tableKey,
owner: item.id,
},
}
/* TODO: Typing return type generates redux-saga related type error that need more dedicated debugging */
export function generateOwnerUpdateRequests(updateArray: UpdateOwnerPayload[], tableKey: string, resourceName: string) {
const updateRequests = [];
/* Create the request for updating each owner*/
updateArray.forEach((item) => {
const updatePayload = createOwnerUpdatePayload(item, tableKey);
const notificationData = createOwnerNotificationData(item, resourceName);
/* Chain requests to send notification on success to desired users */
const request =
axios(updatePayload)
.then((response) => {
return axios.get(`/api/metadata/v0/user?user_id=${item.id}`)
})
.then((response) => {
if(shouldSendNotification(response.data.user)) {
return axios.post('/api/mail/v0/notification', notificationData);
}
});
updateRequests.push(request);
});
return updatePayloads.map(payload => { axios(payload) });
/* Return the list of requests to be executed */
return updateRequests;
}
export function getColumnDescription(columnIndex: number, tableData: TableMetadata) {
......
......@@ -12,7 +12,8 @@ export function* updateTableOwnerWorker(action: UpdateTableOwnerRequest): SagaIt
const state = yield select();
const tableData = state.tableMetadata.tableData;
try {
yield all(API.updateTableOwner(payload.updateArray, tableData.key));
const requestList = API.generateOwnerUpdateRequests(payload.updateArray, tableData.key, `${tableData.schema}.${tableData.table_name}`);
yield all(requestList);
const newOwners = yield call(API.getTableOwners, tableData.key);
yield put(updateTableOwnerSuccess(newOwners));
if (payload.onSuccess) {
......
......@@ -16,7 +16,7 @@ import { updateTableOwnerWorker, updateTableOwnerWatcher } from '../sagas';
import { GetTableData, UpdateTableOwner } from '../../types';
const updateTableOwnerSpy = jest.spyOn(API, 'updateTableOwner').mockImplementation((payload, key) => []);
const generateOwnerUpdateRequestsSpy = jest.spyOn(API, 'generateOwnerUpdateRequests').mockImplementation((payload, key) => []);
describe('tableMetadata:owners ducks', () => {
let expectedOwners: OwnerDict;
......@@ -131,7 +131,7 @@ describe('tableMetadata:owners ducks', () => {
sagaTest = (action) => {
return testSaga(updateTableOwnerWorker, action)
.next().select()
.next(globalState).all(API.updateTableOwner(updatePayload, globalState.tableMetadata.tableData.key))
.next(globalState).all(API.generateOwnerUpdateRequests(updatePayload, globalState.tableMetadata.tableData.key, globalState.tableMetadata.tableData.table_name))
.next().call(API.getTableOwners, globalState.tableMetadata.tableData.key)
.next(expectedOwners).put(updateTableOwnerSuccess(expectedOwners));
};
......
......@@ -30,7 +30,11 @@ const globalState: GlobalState = {
bookmarksForUser: [],
},
feedback: {
sendState: SendingState.IDLE,
sendState: SendingState.IDLE,
},
notification: {
requestIsOpen: false,
sendState: SendingState.IDLE,
},
popularTables: [
{
......
......@@ -9,6 +9,8 @@ import { createStore, applyMiddleware } from 'redux';
import { Router, Route, Switch } from 'react-router-dom';
import DocumentTitle from 'react-document-title';
import { feedbackEnabled } from 'config/config-utils';
import AnnouncementPage from './components/AnnouncementPage';
import BrowsePage from './components/BrowsePage';
import Feedback from './components/Feedback';
......@@ -18,7 +20,7 @@ import NavBar from './components/NavBar';
import NotFoundPage from './components/NotFoundPage';
import Preloader from 'components/common/Preloader';
import ProfilePage from './components/ProfilePage';
import SearchPage from './components/SearchPage';
import SearchPage from './components/SearchPage';
import TableDetail from './components/TableDetail';
import rootReducer from './ducks/rootReducer';
......@@ -47,7 +49,10 @@ ReactDOM.render(
<Route path="/404" component={NotFoundPage} />
<Route path="/" component={HomePage} />
</Switch>
<Feedback />
{
feedbackEnabled() &&
<Feedback />
}
<Footer />
</div>
</Router>
......
// TODO: Remove notification types that can be triggered in flask layer if necessary
export enum NotificationType {
OWNER_ADDED = 'added',
OWNER_REMOVED = 'removed',
METADATA_EDITED = 'edited',
METADATA_REQUESTED = 'requested',
}
export interface SendNotificationOptions {
resource_name: string,
resource_url: string,
description_requested: boolean,
fields_requested: boolean,
comment?: string,
};
export * from './Announcements';
export * from './Enums';
export * from './Feedback';
export * from './Notifications'
export * from './Resources';
export * from './TableMetadata';
export * from './Tags';
......
# Application configuration
This document describes how to leverage the frontend service's application configuration to configure particular features. After modifying the `AppConfigCustom` object in [config-custom.ts](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/js/config/config-custom.ts) in the ways described in this document, be sure to rebuild your application with these changes.
**NOTE: This document is a work in progress and does not include 100% of features. We welcome PRs to complete this document**
## Browse Tags Feature
_TODO: Please add doc_
## Custom Logo
1. Add your logo to the folder in `amundsen_application/static/images/`.
2. Set the the `logoPath` key on the to the location of your image.
## Google Analytics
_TODO: Please add doc_
## Mail Client Features
Amundsen has two features that leverage the custom mail client -- the feedback tool and notifications.
As these are optional features, our `MailClientFeaturesConfig` can be used to hide/display any UI related to these features:
1. Set `MailClientFeaturesConfig.feedbackEnabled` to `true` in order to display the `Feedback` component in the UI.
2. Set `MailClientFeaturesConfig.notificationsEnabled` to `true` in order to display the optional UI for users to request more information about resources on the `TableDetail` page.
For information about how to configure a custom mail
client, please see this [entry](flask_config.md#mail-client-features) in our flask configuration doc.
## Navigation Links
_TODO: Please add doc_
## Table Lineage
_TODO: Please add doc_
## Table Profile
_TODO: Please add doc*_
## Feature Flags
_TODO: Please add doc_
......@@ -3,26 +3,23 @@
## Flask
The default Flask application uses a [LocalConfig](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/config.py) that looks for the metadata and search services running on localhost. In order to use different end point, you need to create a custom config class suitable for your use case. Once the config class has been created, it can be referenced via the [environment variable](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/wsgi.py#L5): `FRONTEND_SVC_CONFIG_MODULE_CLASS`
For more information on how the configuration is being loaded and used, please reference the official Flask [documentation](http://flask.pocoo.org/docs/1.0/config/#development-production).
For more examples of how to leverage the Flask configuration for specific features, please see this [extended doc](flask_config.md).
## Authentication
Authentication can be hooked within Amundsen using either wrapper class or using proxy to secure the microservices
on the nginx/server level. Following are the ways to setup the end-to-end authentication.
- [OIDC / Keycloak](authentication/oidc.md)
For more information on Flask configurations, please reference the official Flask [documentation](http://flask.pocoo.org/docs/1.0/config/#development-production).
## React Application
## React Application
### Application Config
Certain features of the React application import variables from an [AppConfig](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/js/config/config.ts#L5) object. The configuration can be customized by modifying [config-custom.ts](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/js/config/config-custom.ts).
#### Example: Add a custom logo
1. Add your logo to the folder in `amundsen_application/static/images/`
2. Set the the `logoPath` key on the `AppConfigCustom` object in [config-custom.ts](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/js/config/config-custom.ts). For example Lyft uses the value `"/static/images/lyft-logo.svg"`
3. Rebuild/redeploy.
* `npm run build` to rebuild typescript files to reference the new logo image
* `python3 setup.py install` will updated deployed files and make the new image available on the server
For examples of how to leverage the application configuration for specific features, please see this [extended doc](application_config.md).
### Custom Fonts & Styles
Fonts and css variables can be customized by modifying [fonts-custom.scss](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/css/_fonts-custom.scss) and
[variables-custom.scss](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/css/_variables-custom.scss).
## Authentication
Authentication can be hooked within Amundsen using either wrapper class or using proxy to secure the microservices
on the nginx/server level. Following are the ways to setup the end-to-end authentication.
- [OIDC / Keycloak](authentication/oidc.md)
# Developer Guide
## Environment
Follow the installation instructions in the section [Install standalone application directly from the source](https://github.com/lyft/amundsenfrontendlibrary#install-standalone-application-directly-from-the-spource).
Follow the installation instructions in the section [Install standalone application directly from the source](https://github.com/lyft/amundsenfrontendlibrary/blob/master/docs/installation.md#install-standalone-application-directly-from-the-source).
Install the javascript development requirements:
```bash
......
# Flask configuration
After modifying any variable in [config.py](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/config.py) described in this document, be sure to rebuild your application with these changes.
**NOTE: This document is a work in progress and does not include 100% of features. We welcome PRs to complete this document**
## Mail Client Features
Amundsen has two features that leverage the custom mail client -- the feedback tool and notifications. For these features a custom implementation of [base_mail_client](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/base/base_mail_client.py) must be mapped to the `MAIL_CLIENT` configuration variable.
To fully enable these features in the UI, the application configuration variables for these features must also be set to true. Please see this [entry](application_config.md#mail-client-features) in our application configuration doc for further information.
......@@ -34,7 +34,7 @@ requirements_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'r
with open(requirements_path) as requirements_file:
requirements = requirements_file.readlines()
__version__ = '1.0.9'
__version__ = '1.1.0'
setup(
......
......@@ -88,3 +88,61 @@ class MailTest(unittest.TestCase):
'rating': '10', 'comment': 'test'
})
self.assertEqual(response.status_code, expected_code)
@unittest.mock.patch('amundsen_application.api.mail.v0.send_notification')
def test_notification_endpoint_calls_send_notification(self, send_notification_mock) -> None:
"""
Test that the endpoint calls send_notification with the correct information
from the request json
:return:
"""
test_recipients = ['test@test.com']
test_notification_type = 'added'
test_options = {}
with local_app.test_client() as test:
test.post('/api/mail/v0/notification', json={
'recipients': test_recipients,
'notificationType': test_notification_type,
'options': test_options,
})
send_notification_mock.assert_called_with(
notification_type=test_notification_type,
options=test_options,
recipients=test_recipients,
sender=local_app.config['AUTH_USER_METHOD'](local_app).email
)
@unittest.mock.patch('amundsen_application.api.mail.v0.send_notification')
def test_notification_endpoint_fails_missing_notification_type(self, send_notification_mock) -> None:
"""
Test that the endpoint fails if notificationType is not provided in the
request json
:return:
"""
test_recipients = ['test@test.com']
test_sender = 'test2@test.com'
test_options = {}
with local_app.test_client() as test:
response = test.post('/api/mail/v0/notification', json={
'recipients': test_recipients,
'sender': test_sender,
'options': test_options,
})
self.assertEquals(response.status_code, HTTPStatus.BAD_REQUEST)
self.assertFalse(send_notification_mock.called)
@unittest.mock.patch('amundsen_application.api.mail.v0.send_notification')
def test_notification_endpoint_fails_with_exception(self, send_notification_mock) -> None:
"""
Test that the endpoint returns 500 exception when error occurs
and that send_notification is not called
:return:
"""
with local_app.test_client() as test:
# generates error
response = test.post('/api/mail/v0/notification', json=None)
self.assertEquals(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
self.assertFalse(send_notification_mock.called)
......@@ -327,6 +327,26 @@ class MetadataTest(unittest.TestCase):
)
self.assertEqual(response.status_code, HTTPStatus.OK)
@responses.activate
def test_update_table_owner_propagate_failure(self) -> None:
"""
Test that any error codes from the update_table_owner request are propagated
to be returned to the React application
:return:
"""
url = local_app.config['METADATASERVICE_BASE'] + TABLE_ENDPOINT + '/db://cluster.schema/table/owner/test'
responses.add(responses.PUT, url, json={}, status=HTTPStatus.BAD_REQUEST)
with local_app.test_client() as test:
response = test.put(
'/api/metadata/v0/update_table_owner',
json={
'key': 'db://cluster.schema/table',
'owner': 'test'
}
)
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
@responses.activate
def test_get_last_indexed_success(self) -> None:
"""
......
This diff is collapsed.
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