From 523826c0e9996fa4db0ec583a1c0a26c9d7873dc Mon Sep 17 00:00:00 2001 From: Ryan Lieu Date: Wed, 2 Oct 2019 18:48:11 -0400 Subject: [PATCH] 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 --- amundsen_application/api/exceptions.py | 7 + amundsen_application/api/mail/v0.py | 62 +++- amundsen_application/api/metadata/v0.py | 35 +- .../api/utils/notification_utils.py | 184 ++++++++++ amundsen_application/config.py | 19 +- .../static/css/_dropdowns.scss | 19 ++ amundsen_application/static/css/_icons.scss | 5 + .../static/css/_variables-default.scss | 6 + amundsen_application/static/css/styles.scss | 1 + .../Feedback/FeedbackForm/index.tsx | 2 +- .../Feedback/FeedbackForm/styles.scss | 65 ---- .../static/js/components/Feedback/styles.scss | 64 ++++ .../static/js/components/NavBar/index.tsx | 20 +- .../static/js/components/NavBar/styles.scss | 23 ++ .../js/components/NavBar/tests/index.spec.tsx | 41 ++- .../PreferencesPage/PreferenceGroup/index.tsx | 42 +++ .../components/PreferencesPage/constants.ts | 11 + .../js/components/PreferencesPage/index.tsx | 76 +++++ .../js/components/PreferencesPage/styles.scss | 17 + .../DetailList/DetailListItem/index.tsx | 57 +++- .../DetailList/DetailListItem/styles.scss | 38 ++- .../TableDetail/OwnerEditor/index.tsx | 4 +- .../TableDetail/OwnerEditor/styles.scss | 10 +- .../RequestDescriptionText/constants.ts | 1 + .../RequestDescriptionText/index.tsx | 46 +++ .../RequestDescriptionText/styles.scss | 13 + .../tests/index.spec.tsx | 48 +++ .../RequestMetadataForm/constants.ts | 13 + .../TableDetail/RequestMetadataForm/index.tsx | 175 ++++++++++ .../RequestMetadataForm/styles.scss | 40 +++ .../RequestMetadataForm/tests/index.spec.tsx | 268 +++++++++++++++ .../js/components/TableDetail/index.tsx | 8 + .../components/common/FlashMessage/index.tsx | 30 ++ .../common/FlashMessage/styles.scss | 20 ++ .../common/FlashMessage/tests/index.spec.tsx | 50 +++ .../static/js/config/config-custom.ts | 4 + .../static/js/config/config-default.ts | 4 + .../static/js/config/config-types.ts | 14 + .../static/js/config/config-utils.ts | 9 + .../static/js/config/tests/index.spec.ts | 15 + .../notification/api/tests/index.spec.ts | 37 ++ .../static/js/ducks/notification/api/v0.ts | 15 + .../static/js/ducks/notification/reducer.ts | 87 +++++ .../static/js/ducks/notification/sagas.ts | 20 ++ .../js/ducks/notification/tests/index.spec.ts | 137 ++++++++ .../static/js/ducks/notification/types.ts | 29 ++ .../static/js/ducks/rootReducer.ts | 3 + .../static/js/ducks/rootSaga.ts | 5 + .../js/ducks/tableMetadata/api/helpers.ts | 37 +- .../tableMetadata/api/tests/index.spec.ts | 79 ++++- .../static/js/ducks/tableMetadata/api/v0.ts | 42 ++- .../js/ducks/tableMetadata/owners/sagas.ts | 3 +- .../tableMetadata/owners/tests/index.spec.ts | 4 +- .../static/js/fixtures/globalState.ts | 6 +- amundsen_application/static/js/index.tsx | 9 +- .../static/js/interfaces/Notifications.ts | 15 + .../static/js/interfaces/index.ts | 1 + docs/application_config.md | 44 +++ docs/configuration.md | 23 +- docs/developer_guide.md | 2 +- docs/flask_config.md | 10 + setup.py | 2 +- tests/unit/api/mail/test_v0.py | 58 ++++ tests/unit/api/metadata/test_v0.py | 20 ++ tests/unit/utils/notification/__init__.py | 0 tests/unit/utils/notification/test_v0.py | 319 ++++++++++++++++++ 66 files changed, 2398 insertions(+), 175 deletions(-) create mode 100644 amundsen_application/api/exceptions.py create mode 100644 amundsen_application/api/utils/notification_utils.py create mode 100644 amundsen_application/static/css/_dropdowns.scss delete mode 100644 amundsen_application/static/js/components/Feedback/FeedbackForm/styles.scss create mode 100644 amundsen_application/static/js/components/PreferencesPage/PreferenceGroup/index.tsx create mode 100644 amundsen_application/static/js/components/PreferencesPage/constants.ts create mode 100644 amundsen_application/static/js/components/PreferencesPage/index.tsx create mode 100644 amundsen_application/static/js/components/PreferencesPage/styles.scss create mode 100644 amundsen_application/static/js/components/TableDetail/RequestDescriptionText/constants.ts create mode 100644 amundsen_application/static/js/components/TableDetail/RequestDescriptionText/index.tsx create mode 100644 amundsen_application/static/js/components/TableDetail/RequestDescriptionText/styles.scss create mode 100644 amundsen_application/static/js/components/TableDetail/RequestDescriptionText/tests/index.spec.tsx create mode 100644 amundsen_application/static/js/components/TableDetail/RequestMetadataForm/constants.ts create mode 100644 amundsen_application/static/js/components/TableDetail/RequestMetadataForm/index.tsx create mode 100644 amundsen_application/static/js/components/TableDetail/RequestMetadataForm/styles.scss create mode 100644 amundsen_application/static/js/components/TableDetail/RequestMetadataForm/tests/index.spec.tsx create mode 100644 amundsen_application/static/js/components/common/FlashMessage/index.tsx create mode 100644 amundsen_application/static/js/components/common/FlashMessage/styles.scss create mode 100644 amundsen_application/static/js/components/common/FlashMessage/tests/index.spec.tsx create mode 100644 amundsen_application/static/js/config/config-utils.ts create mode 100644 amundsen_application/static/js/config/tests/index.spec.ts create mode 100644 amundsen_application/static/js/ducks/notification/api/tests/index.spec.ts create mode 100644 amundsen_application/static/js/ducks/notification/api/v0.ts create mode 100644 amundsen_application/static/js/ducks/notification/reducer.ts create mode 100644 amundsen_application/static/js/ducks/notification/sagas.ts create mode 100644 amundsen_application/static/js/ducks/notification/tests/index.spec.ts create mode 100644 amundsen_application/static/js/ducks/notification/types.ts create mode 100644 amundsen_application/static/js/interfaces/Notifications.ts create mode 100644 docs/application_config.md create mode 100644 docs/flask_config.md create mode 100644 tests/unit/utils/notification/__init__.py create mode 100644 tests/unit/utils/notification/test_v0.py diff --git a/amundsen_application/api/exceptions.py b/amundsen_application/api/exceptions.py new file mode 100644 index 0000000000..2e4701dca5 --- /dev/null +++ b/amundsen_application/api/exceptions.py @@ -0,0 +1,7 @@ + + +class MailClientNotImplemented(Exception): + """ + An exception when Mail Client is not implemented + """ + pass diff --git a/amundsen_application/api/mail/v0.py b/amundsen_application/api/mail/v0.py index 72a036eeb1..76561f6bf0 100644 --- a/amundsen_application/api/mail/v0.py +++ b/amundsen_application/api/mail/v0.py @@ -6,6 +6,8 @@ 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.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('
{}:
{}

'.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) diff --git a/amundsen_application/api/metadata/v0.py b/amundsen_application/api/metadata/v0.py index 09dfcdabef..8fde3adc52 100644 --- a/amundsen_application/api/metadata/v0.py +++ b/amundsen_application/api/metadata/v0.py @@ -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) diff --git a/amundsen_application/api/utils/notification_utils.py b/amundsen_application/api/utils/notification_utils.py new file mode 100644 index 0000000000..d92150e44f --- /dev/null +++ b/amundsen_application/api/utils/notification_utils.py @@ -0,0 +1,184 @@ +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': ('
What is expected of you?
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.
' + 'Make sure the metadata is correct and up to date.
'), + 'end_note': ('
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.
'), + 'notification': ('
You have been added to the owners list of the ' + '{resource_name} dataset by {sender}.
'), + }, + 'removed': { + 'comment': '', + 'end_note': ('
If you think you have been incorrectly removed as an owner, ' + 'add yourself back to the owners list.
'), + 'notification': ('
You have been removed from the owners list of the ' + '{resource_name} dataset by {sender}.
'), + }, + 'requested': { + 'comment': '', + 'end_note': '
Please visit the provided link and improve descriptions on that resource.
', + 'notification': '
{sender} is trying to use {resource_name}, ', + } +} + + +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,
' + 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 = '
Thanks,
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.
' + elif need_resource_description: + notification = notification + 'and requests an improved table description.
' + elif need_fields_descriptions: + notification = notification + 'and requests improved column descriptions.
' + else: + notification = notification + 'and requests more information about that resource.
' + + if options_comment: + comment = ('
{sender} has included the following information with their request:' + '
{comment}
').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) diff --git a/amundsen_application/config.py b/amundsen_application/config.py index 077ea41040..f21edafb7d 100644 --- a/amundsen_application/config.py +++ b/amundsen_application/config.py @@ -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 diff --git a/amundsen_application/static/css/_dropdowns.scss b/amundsen_application/static/css/_dropdowns.scss new file mode 100644 index 0000000000..f56fccf490 --- /dev/null +++ b/amundsen_application/static/css/_dropdowns.scss @@ -0,0 +1,19 @@ +.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; + } + } + } +} diff --git a/amundsen_application/static/css/_icons.scss b/amundsen_application/static/css/_icons.scss index 86627fd929..0ed3059f1f 100644 --- a/amundsen_application/static/css/_icons.scss +++ b/amundsen_application/static/css/_icons.scss @@ -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, diff --git a/amundsen_application/static/css/_variables-default.scss b/amundsen_application/static/css/_variables-default.scss index 54eaf61452..c0694ba195 100644 --- a/amundsen_application/static/css/_variables-default.scss +++ b/amundsen_application/static/css/_variables-default.scss @@ -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; diff --git a/amundsen_application/static/css/styles.scss b/amundsen_application/static/css/styles.scss index ef5efe66c4..832991284a 100644 --- a/amundsen_application/static/css/styles.scss +++ b/amundsen_application/static/css/styles.scss @@ -2,6 +2,7 @@ @import 'avatars'; @import 'buttons'; +@import 'dropdowns'; @import 'fonts'; @import 'icons'; @import 'list-group'; diff --git a/amundsen_application/static/js/components/Feedback/FeedbackForm/index.tsx b/amundsen_application/static/js/components/Feedback/FeedbackForm/index.tsx index d9726b7b6c..946d30d5a7 100644 --- a/amundsen_application/static/js/components/Feedback/FeedbackForm/index.tsx +++ b/amundsen_application/static/js/components/Feedback/FeedbackForm/index.tsx @@ -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'; diff --git a/amundsen_application/static/js/components/Feedback/FeedbackForm/styles.scss b/amundsen_application/static/js/components/Feedback/FeedbackForm/styles.scss deleted file mode 100644 index 7bb39f7d92..0000000000 --- a/amundsen_application/static/js/components/Feedback/FeedbackForm/styles.scss +++ /dev/null @@ -1,65 +0,0 @@ -@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; -} diff --git a/amundsen_application/static/js/components/Feedback/styles.scss b/amundsen_application/static/js/components/Feedback/styles.scss index 58935fa284..c818588ab8 100644 --- a/amundsen_application/static/js/components/Feedback/styles.scss +++ b/amundsen_application/static/js/components/Feedback/styles.scss @@ -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; + } } diff --git a/amundsen_application/static/js/components/NavBar/index.tsx b/amundsen_application/static/js/components/NavBar/index.tsx index 7d2e3e21ca..7b3d842244 100644 --- a/amundsen_application/static/js/components/NavBar/index.tsx +++ b/amundsen_application/static/js/components/NavBar/index.tsx @@ -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 { {this.generateNavLinks(AppConfig.navLinks)} { this.props.loggedInUser && AppConfig.indexUsers.enabled && - - - + + +
+
{this.props.loggedInUser.display_name}
+
{this.props.loggedInUser.email}
+
+
  • + + My Profile + +
  • +
    + } { this.props.loggedInUser && !AppConfig.indexUsers.enabled && diff --git a/amundsen_application/static/js/components/NavBar/styles.scss b/amundsen_application/static/js/components/NavBar/styles.scss index a8e632c266..d9cdc5d84f 100644 --- a/amundsen_application/static/js/components/NavBar/styles.scss +++ b/amundsen_application/static/js/components/NavBar/styles.scss @@ -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; + } + } +} diff --git a/amundsen_application/static/js/components/NavBar/tests/index.spec.tsx b/amundsen_application/static/js/components/NavBar/tests/index.spec.tsx index a26d0ee6e4..0ab07d1c76 100644 --- a/amundsen_application/static/js/components/NavBar/tests/index.spec.tsx +++ b/amundsen_application/static/js/components/NavBar/tests/index.spec.tsx @@ -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) + }); + }) }); }); diff --git a/amundsen_application/static/js/components/PreferencesPage/PreferenceGroup/index.tsx b/amundsen_application/static/js/components/PreferencesPage/PreferenceGroup/index.tsx new file mode 100644 index 0000000000..87b8323369 --- /dev/null +++ b/amundsen_application/static/js/components/PreferencesPage/PreferenceGroup/index.tsx @@ -0,0 +1,42 @@ +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 { + public static defaultProps: Partial = { + selected: false, + title: '', + subtitle: '', + }; + + constructor(props) { + super(props); + } + + onClick = () => { + this.props.onClick(this.props.preferenceValue) + } + + // TODO: Consolidate with future common RadioButton component. + render() { + return ( + + ); + } +} + +export default PreferenceGroup; diff --git a/amundsen_application/static/js/components/PreferencesPage/constants.ts b/amundsen_application/static/js/components/PreferencesPage/constants.ts new file mode 100644 index 0000000000..dfcfe87b62 --- /dev/null +++ b/amundsen_application/static/js/components/PreferencesPage/constants.ts @@ -0,0 +1,11 @@ +/* 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' diff --git a/amundsen_application/static/js/components/PreferencesPage/index.tsx b/amundsen_application/static/js/components/PreferencesPage/index.tsx new file mode 100644 index 0000000000..6cca68a7fd --- /dev/null +++ b/amundsen_application/static/js/components/PreferencesPage/index.tsx @@ -0,0 +1,76 @@ +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 { + constructor(props) { + super(props); + this.changePreference = this.changePreference.bind(this); + + this.state = { + selectedPreference: ALL_PREFERENCE, + } + } + changePreference = (newPreference) => { + this.setState({ + selectedPreference: newPreference, + }) + } + + + render() { + return ( +
    +
    +
    +

    {NOTIFICATION_PREFERENCES_TITLE}

    + + +
    +
    +
    + ); + } +} + +export const mapDispatchToProps = (dispatch: any) => { + return bindActionCreators({}, dispatch); +}; + +export default connect(null, mapDispatchToProps)(PreferencesPage); diff --git a/amundsen_application/static/js/components/PreferencesPage/styles.scss b/amundsen_application/static/js/components/PreferencesPage/styles.scss new file mode 100644 index 0000000000..6001a27222 --- /dev/null +++ b/amundsen_application/static/js/components/PreferencesPage/styles.scss @@ -0,0 +1,17 @@ +@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 diff --git a/amundsen_application/static/js/components/TableDetail/DetailList/DetailListItem/index.tsx b/amundsen_application/static/js/components/TableDetail/DetailList/DetailListItem/index.tsx index d18732d5ce..fb1ed8eb17 100644 --- a/amundsen_application/static/js/components/TableDetail/DetailList/DetailListItem/index.tsx +++ b/amundsen_application/static/js/components/TableDetail/DetailList/DetailListItem/index.tsx @@ -1,26 +1,40 @@ 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 { - public static defaultProps: DetailListItemProps = { + public static defaultProps: Partial = { data: {} as TableColumn, index: null, }; @@ -32,6 +46,10 @@ class DetailListItem extends React.Component { + this.props.openRequestDescriptionDialog(); + } + onClick = (e) => { if (!this.state.isExpanded) { const metadata = this.props.data; @@ -127,13 +145,26 @@ class DetailListItem extends React.Component } -
    - +
    +
    + +
    + { + notificationsEnabled() && + + + + + + Request Column Description + + + }
    { this.state.isExpanded && @@ -163,4 +194,8 @@ class DetailListItem extends React.Component { + return bindActionCreators({ openRequestDescriptionDialog } , dispatch); +}; + +export default connect<{}, DispatchFromProps, OwnProps>(null, mapDispatchToProps)(DetailListItem); diff --git a/amundsen_application/static/js/components/TableDetail/DetailList/DetailListItem/styles.scss b/amundsen_application/static/js/components/TableDetail/DetailList/DetailListItem/styles.scss index 5c131e7563..6ec0f47bac 100644 --- a/amundsen_application/static/js/components/TableDetail/DetailList/DetailListItem/styles.scss +++ b/amundsen_application/static/js/components/TableDetail/DetailList/DetailListItem/styles.scss @@ -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; + } + } + } } diff --git a/amundsen_application/static/js/components/TableDetail/OwnerEditor/index.tsx b/amundsen_application/static/js/components/TableDetail/OwnerEditor/index.tsx index 2d7a066cc8..404f2c1519 100644 --- a/amundsen_application/static/js/components/TableDetail/OwnerEditor/index.tsx +++ b/amundsen_application/static/js/components/TableDetail/OwnerEditor/index.tsx @@ -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 { diff --git a/amundsen_application/static/js/components/TableDetail/OwnerEditor/styles.scss b/amundsen_application/static/js/components/TableDetail/OwnerEditor/styles.scss index 5f8ef643a8..78811bc313 100644 --- a/amundsen_application/static/js/components/TableDetail/OwnerEditor/styles.scss +++ b/amundsen_application/static/js/components/TableDetail/OwnerEditor/styles.scss @@ -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; diff --git a/amundsen_application/static/js/components/TableDetail/RequestDescriptionText/constants.ts b/amundsen_application/static/js/components/TableDetail/RequestDescriptionText/constants.ts new file mode 100644 index 0000000000..8cb8132040 --- /dev/null +++ b/amundsen_application/static/js/components/TableDetail/RequestDescriptionText/constants.ts @@ -0,0 +1 @@ +export const REQUEST_DESCRIPTION = 'Request Description'; diff --git a/amundsen_application/static/js/components/TableDetail/RequestDescriptionText/index.tsx b/amundsen_application/static/js/components/TableDetail/RequestDescriptionText/index.tsx new file mode 100644 index 0000000000..25cac09e86 --- /dev/null +++ b/amundsen_application/static/js/components/TableDetail/RequestDescriptionText/index.tsx @@ -0,0 +1,46 @@ +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 { + public static defaultProps: Partial = {}; + + constructor(props) { + super(props); + } + + openRequest = () => { + this.props.openRequestDescriptionDialog(); + } + + render() { + return ( + + { REQUEST_DESCRIPTION } + + ); + } +} + +export const mapDispatchToProps = (dispatch: any) => { + return bindActionCreators({ openRequestDescriptionDialog } , dispatch); +}; + +export default connect<{}, DispatchFromProps>(null, mapDispatchToProps)(RequestDescriptionText); diff --git a/amundsen_application/static/js/components/TableDetail/RequestDescriptionText/styles.scss b/amundsen_application/static/js/components/TableDetail/RequestDescriptionText/styles.scss new file mode 100644 index 0000000000..1c837f4c33 --- /dev/null +++ b/amundsen_application/static/js/components/TableDetail/RequestDescriptionText/styles.scss @@ -0,0 +1,13 @@ +@import 'variables'; + +.request-description { + font-size: 16px; + text-decoration: none; + + &:hover, + &:visited, + &:active, + &:link { + text-decoration: none; + } +} diff --git a/amundsen_application/static/js/components/TableDetail/RequestDescriptionText/tests/index.spec.tsx b/amundsen_application/static/js/components/TableDetail/RequestDescriptionText/tests/index.spec.tsx new file mode 100644 index 0000000000..a15764f48c --- /dev/null +++ b/amundsen_application/static/js/components/TableDetail/RequestDescriptionText/tests/index.spec.tsx @@ -0,0 +1,48 @@ +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) => { + const props: RequestDescriptionTextProps = { + openRequestDescriptionDialog: jest.fn(), + ...propOverrides, + }; + const wrapper = shallow() + 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); + }); + }); +}); diff --git a/amundsen_application/static/js/components/TableDetail/RequestMetadataForm/constants.ts b/amundsen_application/static/js/components/TableDetail/RequestMetadataForm/constants.ts new file mode 100644 index 0000000000..42c228b216 --- /dev/null +++ b/amundsen_application/static/js/components/TableDetail/RequestMetadataForm/constants.ts @@ -0,0 +1,13 @@ +/* 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'; diff --git a/amundsen_application/static/js/components/TableDetail/RequestMetadataForm/index.tsx b/amundsen_application/static/js/components/TableDetail/RequestMetadataForm/index.tsx new file mode 100644 index 0000000000..2142590244 --- /dev/null +++ b/amundsen_application/static/js/components/TableDetail/RequestMetadataForm/index.tsx @@ -0,0 +1,175 @@ +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; + requestIsOpen: boolean; + sendState: SendingState; +} + +export interface DispatchFromProps { + submitNotification: ( + recipients: Array, + sender: string, + notificationType: NotificationType, + options?: SendNotificationOptions + ) => SubmitNotificationRequest; + closeRequestDescriptionDialog: () => ToggleRequestAction; +} + +export type RequestMetadataProps = StateFromProps & DispatchFromProps; + +interface RequestMetadataState {} + +export class RequestMetadataForm extends React.Component { + public static defaultProps: Partial = {}; + + 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 ( + + ) + } + + 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 ( +
    + {this.renderFlashMessage()} +
    + ); + } + if (!this.props.requestIsOpen) { + return (null); + } + return ( +
    +
    +

    {TITLE_TEXT}

    +
    +
    +
    + + +
    +
    + + +
    +
    + + + +
    +
    + +