Unverified Commit a89bfaea authored by christina stead's avatar christina stead Committed by GitHub

Allow users to create JIRA tickets associated with a table (#389)

* add jira connection

* update location of report data issue

* add some reducers

* add to global state

* fix saga/redux flow for the jira issues view

* use redux to handle reporting a data issue
:

* fix build

* add jira sdk to requirements.txt, run fix lint errors for js

* add comment

* add data quality icon

* move constants into a separate file

* add tests for reducer and api

* add some more tests

* add more tests, do some refactoring

* still working on tests

* fix unit tests that don't quite work

* fix lint errors

* add some python tests

* add more tests for jira client

* remove unused imports

* remove unused import

* fix lint errors

* for some reason i have to add types?

* re-fix tests

* update comments

* address some comments

* respond to comments

* respond to comments

* remove jira project id requirement:

* add base class for issue tracking

* add resource for issue endpoint

* interim commit while refactoring into more generic

* do some refactor, add a generic base client

* refactor name from jira -> issue

* fix spec per changes in the api

* respond to some pr comments, add some error handling if no jira component is enabled

* update per pr comments

* remove utils for trucation, use ellipsis

* add custom exception class, make sure it returns config error on api call

* fix tests, refactor

* fix lint

* hopefully fix build

* css fix

* try removing jira from requirements

* update tests, add total count of remaining results to api call

* add comment, see if this fixes the build

* update tests

* update build steps

* try making the extras a string like in examples

* try to follow example exactly

* try without quotes

* update tests, make method to generate url

* try string again

* try using jira as a string

* remove references to extras and just install the package

* mock urllib

* update tests

* add tests for rending extra issues

* update text on issues view

* fix some lint issues

* respond to pr comments

* update per pr comments

* respond to pr comments

* fix some lint errors

* respond to comments, some lint fixes

* fix more lint

* respond to pr comments

* remove todo

* add definition of api calls in fe

* hide issue integration by default

* fix tests, increase test coverage a bit

* update some text

* update per some product feedback

* update test

* Apply suggestions from code review
Co-Authored-By: 's avatarTamika Tannis <ttannis@lyft.com>

* update per pr comments
Co-authored-by: 's avatarTamika Tannis <ttannis@alum.mit.edu>
parent 994eb88c
...@@ -4,7 +4,8 @@ import logging ...@@ -4,7 +4,8 @@ import logging
import logging.config import logging.config
import os import os
from flask import Flask from flask import Flask, Blueprint
from flask_restful import Api
from amundsen_application.api import init_routes from amundsen_application.api import init_routes
from amundsen_application.api.v0 import blueprint from amundsen_application.api.v0 import blueprint
...@@ -14,6 +15,8 @@ from amundsen_application.api.mail.v0 import mail_blueprint ...@@ -14,6 +15,8 @@ from amundsen_application.api.mail.v0 import mail_blueprint
from amundsen_application.api.metadata.v0 import metadata_blueprint from amundsen_application.api.metadata.v0 import metadata_blueprint
from amundsen_application.api.preview.v0 import preview_blueprint from amundsen_application.api.preview.v0 import preview_blueprint
from amundsen_application.api.search.v0 import search_blueprint from amundsen_application.api.search.v0 import search_blueprint
from amundsen_application.api.issue.issue import IssueAPI, IssuesAPI
app_wrapper_class = Flask app_wrapper_class = Flask
...@@ -52,6 +55,14 @@ def create_app(config_module_class: str, template_folder: str = None) -> Flask: ...@@ -52,6 +55,14 @@ def create_app(config_module_class: str, template_folder: str = None) -> Flask:
logging.info('Using metadata service at {}'.format(app.config.get('METADATASERVICE_BASE'))) logging.info('Using metadata service at {}'.format(app.config.get('METADATASERVICE_BASE')))
logging.info('Using search service at {}'.format(app.config.get('SEARCHSERVICE_BASE'))) logging.info('Using search service at {}'.format(app.config.get('SEARCHSERVICE_BASE')))
api_bp = Blueprint('api', __name__)
api = Api(api_bp)
api.add_resource(IssuesAPI,
'/api/issue/issues', endpoint='issues')
api.add_resource(IssueAPI,
'/api/issue/issue', endpoint='issue')
app.register_blueprint(blueprint) app.register_blueprint(blueprint)
app.register_blueprint(announcements_blueprint) app.register_blueprint(announcements_blueprint)
app.register_blueprint(log_blueprint) app.register_blueprint(log_blueprint)
...@@ -59,6 +70,7 @@ def create_app(config_module_class: str, template_folder: str = None) -> Flask: ...@@ -59,6 +70,7 @@ def create_app(config_module_class: str, template_folder: str = None) -> Flask:
app.register_blueprint(metadata_blueprint) app.register_blueprint(metadata_blueprint)
app.register_blueprint(preview_blueprint) app.register_blueprint(preview_blueprint)
app.register_blueprint(search_blueprint) app.register_blueprint(search_blueprint)
app.register_blueprint(api_bp)
init_routes(app) init_routes(app)
init_custom_routes = app.config.get('INIT_CUSTOM_ROUTES') init_custom_routes = app.config.get('INIT_CUSTOM_ROUTES')
......
from flask import current_app as app
from flask import jsonify, make_response, Response
from flask_restful import Resource, reqparse
from http import HTTPStatus
import logging
from amundsen_application.base.base_issue_tracker_client import BaseIssueTrackerClient
from amundsen_application.proxy.issue_tracker_clients import get_issue_tracker_client
from amundsen_application.proxy.issue_tracker_clients.issue_exceptions import IssueConfigurationException
LOGGER = logging.getLogger(__name__)
class IssuesAPI(Resource):
def __init__(self) -> None:
self.reqparse = reqparse.RequestParser()
self.client: BaseIssueTrackerClient
def get(self) -> Response:
"""
Given a table key, returns all tickets containing that key. Returns an empty array if none exist
:return: List of tickets
"""
try:
if not app.config['ISSUE_TRACKER_CLIENT_ENABLED']:
message = 'Issue tracking is not enabled. Request was accepted but no issue will be returned.'
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.ACCEPTED)
self.client = get_issue_tracker_client()
self.reqparse.add_argument('key', 'Request requires a key', location='args')
args = self.reqparse.parse_args()
response = self.client.get_issues(args['key'])
return make_response(jsonify({'issues': response.serialize()}), HTTPStatus.OK)
except IssueConfigurationException as e:
message = 'Encountered exception: ' + str(e)
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.NOT_IMPLEMENTED)
except Exception as e:
message = 'Encountered exception: ' + str(e)
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.INTERNAL_SERVER_ERROR)
class IssueAPI(Resource):
def __init__(self) -> None:
self.reqparse = reqparse.RequestParser()
self.client: BaseIssueTrackerClient
super(IssueAPI, self).__init__()
def post(self) -> Response:
try:
if not app.config['ISSUE_TRACKER_CLIENT_ENABLED']:
message = 'Issue tracking is not enabled. Request was accepted but no issue will be created.'
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.ACCEPTED)
self.client = get_issue_tracker_client()
self.reqparse.add_argument('title', type=str, location='form')
self.reqparse.add_argument('key', type=str, location='form')
self.reqparse.add_argument('description', type=str, location='form')
args = self.reqparse.parse_args()
response = self.client.create_issue(description=args['description'],
table_uri=args['key'],
title=args['title'])
return make_response(jsonify({'issue': response.serialize()}), HTTPStatus.OK)
except IssueConfigurationException as e:
message = 'Encountered exception: ' + str(e)
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.NOT_IMPLEMENTED)
except Exception as e:
message = 'Encountered exception: ' + str(e)
logging.exception(message)
return make_response(jsonify({'msg': message}), HTTPStatus.INTERNAL_SERVER_ERROR)
...@@ -13,7 +13,7 @@ from amundsen_application.models.user import load_user, dump_user ...@@ -13,7 +13,7 @@ from amundsen_application.models.user import load_user, dump_user
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
blueprint = Blueprint('api', __name__, url_prefix='/api') blueprint = Blueprint('main', __name__, url_prefix='/api')
@blueprint.route('/auth_user', methods=['GET']) @blueprint.route('/auth_user', methods=['GET'])
......
import abc
from amundsen_application.models.data_issue import DataIssue
from amundsen_application.models.issue_results import IssueResults
class BaseIssueTrackerClient(abc.ABC):
@abc.abstractmethod
def __init__(self) -> None:
pass # pragma: no cover
@abc.abstractmethod
def get_issues(self, table_uri: str) -> IssueResults:
"""
Gets issues from the issue tracker
:param table_uri: Table Uri ie databasetype://database/table
:return:
"""
raise NotImplementedError # pragma: no cover
@abc.abstractmethod
def create_issue(self, table_uri: str, title: str, description: str) -> DataIssue:
"""
Given a title, description, and table key, creates a ticket in the configured project
Automatically places the table_uri in the description of the ticket.
Returns the ticket information, including URL.
:param description: user provided description for the jira ticket
:param table_uri: Table URI ie databasetype://database/table
:param title: Title of the ticket
:return: A single ticket
"""
raise NotImplementedError # pragma: no cover
...@@ -35,6 +35,17 @@ class Config: ...@@ -35,6 +35,17 @@ class Config:
# Initialize custom routes # Initialize custom routes
INIT_CUSTOM_ROUTES = None # type: Callable[[Flask], None] INIT_CUSTOM_ROUTES = None # type: Callable[[Flask], None]
# Settings for Issue tracker integration
ISSUE_TRACKER_URL = None # type: str
ISSUE_TRACKER_USER = None # type: str
ISSUE_TRACKER_PASSWORD = None # type: str
ISSUE_TRACKER_PROJECT_ID = None # type: int
# Maps to a class path and name
ISSUE_TRACKER_CLIENT = None # type: str
ISSUE_TRACKER_CLIENT_ENABLED = False # type: bool
# Max issues to display at a time
ISSUE_TRACKER_MAX_RESULTS = None # type: int
class LocalConfig(Config): class LocalConfig(Config):
DEBUG = False DEBUG = False
...@@ -85,6 +96,12 @@ class LocalConfig(Config): ...@@ -85,6 +96,12 @@ class LocalConfig(Config):
class TestConfig(LocalConfig): class TestConfig(LocalConfig):
AUTH_USER_METHOD = get_test_user AUTH_USER_METHOD = get_test_user
NOTIFICATIONS_ENABLED = True NOTIFICATIONS_ENABLED = True
ISSUE_TRACKER_URL = 'test_url'
ISSUE_TRACKER_USER = 'test_user'
ISSUE_TRACKER_PASSWORD = 'test_password'
ISSUE_TRACKER_PROJECT_ID = 1
ISSUE_TRACKER_CLIENT_ENABLED = True
ISSUE_TRACKER_MAX_RESULTS = 3
class TestNotificationsDisabledConfig(LocalConfig): class TestNotificationsDisabledConfig(LocalConfig):
......
class DataIssue:
def __init__(self,
issue_key: str,
title: str,
url: str) -> None:
self.issue_key = issue_key
self.title = title
self.url = url
def serialize(self) -> dict:
return {'issue_key': self.issue_key,
'title': self.title,
'url': self.url}
from amundsen_application.models.data_issue import DataIssue
from typing import List, Dict
class IssueResults:
def __init__(self,
issues: List[DataIssue],
remaining: int,
remaining_url: str) -> None:
"""
Returns an object representing results from an issue tracker.
:param issues: Issues in the issue tracker matching the requested table
:param remaining: How many issues remain in the issue tracker and are not displayed
:param remaining_url: url to the remaining issues in the issue tracker
"""
self.issues = issues
self.remaining = remaining
self.remaining_url = remaining_url
def serialize(self) -> Dict:
return {'issues': [issue.serialize() for issue in self.issues],
'remaining': self.remaining,
'remaining_url': self.remaining_url}
from flask import current_app as app
from threading import Lock
from werkzeug.utils import import_string
from amundsen_application.base.base_issue_tracker_client import BaseIssueTrackerClient
_issue_tracker_client = None
_issue_tracker_client_lock = Lock()
def get_issue_tracker_client() -> BaseIssueTrackerClient:
"""
Provides singleton proxy client based on the config
:return: Proxy instance of any subclass of BaseProxy
"""
global _issue_tracker_client
if _issue_tracker_client:
return _issue_tracker_client
with _issue_tracker_client_lock:
if _issue_tracker_client:
return _issue_tracker_client
else:
# Gather all the configuration to create an IssueTrackerClient
if app.config['ISSUE_TRACKER_CLIENT_ENABLED']:
url = app.config['ISSUE_TRACKER_URL']
user = app.config['ISSUE_TRACKER_USER']
password = app.config['ISSUE_TRACKER_PASSWORD']
project_id = app.config['ISSUE_TRACKER_PROJECT_ID']
max_results = app.config['ISSUE_TRACKER_MAX_RESULTS']
if app.config['ISSUE_TRACKER_CLIENT']:
client = import_string(app.config['ISSUE_TRACKER_CLIENT'])
_issue_tracker_client = client(issue_tracker_url=url,
issue_tracker_user=user,
issue_tracker_password=password,
issue_tracker_project_id=project_id,
issue_tracker_max_results=max_results)
return _issue_tracker_client
class IssueConfigurationException(Exception):
"""
Raised when there are missing configuration settings
"""
pass
from jira import JIRA, JIRAError, Issue
from typing import List
from amundsen_application.base.base_issue_tracker_client import BaseIssueTrackerClient
from amundsen_application.proxy.issue_tracker_clients.issue_exceptions import IssueConfigurationException
from amundsen_application.models.data_issue import DataIssue
from amundsen_application.models.issue_results import IssueResults
import urllib.parse
import logging
SEARCH_STUB = 'text ~ "{table_key}" AND resolution = Unresolved order by createdDate DESC'
# this is provided by jira as the type of a bug
ISSUE_TYPE_ID = 1
ISSUE_TYPE_NAME = 'Bug'
class JiraClient(BaseIssueTrackerClient):
def __init__(self, issue_tracker_url: str,
issue_tracker_user: str,
issue_tracker_password: str,
issue_tracker_project_id: int,
issue_tracker_max_results: int) -> None:
self.jira_url = issue_tracker_url
self.jira_user = issue_tracker_user
self.jira_password = issue_tracker_password
self.jira_project_id = issue_tracker_project_id
self.jira_max_results = issue_tracker_max_results
self._validate_jira_configuration()
self.jira_client = self.get_client()
def get_client(self) -> JIRA:
"""
Get the Jira client properly formatted prepared for hitting JIRA
:return: A Jira client.
"""
return JIRA(
server=self.jira_url,
basic_auth=(self.jira_user, self.jira_password)
)
def get_issues(self, table_uri: str) -> IssueResults:
"""
Runs a query against a given Jira project for tickets matching the key
Returns open issues sorted by most recently created.
:param table_uri: Table Uri ie databasetype://database/table
:return: Metadata of matching issues
"""
try:
issues = self.jira_client.search_issues(SEARCH_STUB.format(
table_key=table_uri),
maxResults=self.jira_max_results)
returned_issues = [self._get_issue_properties(issue=issue) for issue in issues]
return IssueResults(issues=returned_issues,
remaining=self._get_remaining_issues(total=issues.total),
remaining_url=self._generate_remaining_issues_url(table_uri, returned_issues))
except JIRAError as e:
logging.exception(str(e))
raise e
def create_issue(self, table_uri: str, title: str, description: str) -> DataIssue:
"""
Creates an issue in Jira
:param description: Description of the Jira issue
:param table_uri: Table Uri ie databasetype://database/table
:param title: Title of the Jira ticket
:return: Metadata about the newly created issue
"""
try:
issue = self.jira_client.create_issue(fields=dict(project={
'id': self.jira_project_id
}, issuetype={
'id': ISSUE_TYPE_ID,
'name': ISSUE_TYPE_NAME,
}, summary=title, description=f'{description} \n Table Key: {table_uri} [PLEASE DO NOT REMOVE]'))
return self._get_issue_properties(issue=issue)
except JIRAError as e:
logging.exception(str(e))
raise e
def _validate_jira_configuration(self) -> None:
"""
Validates that all properties for jira configuration are set. Returns a list of missing properties
to return if they are missing
:return: String representing missing Jira properties, or an empty string.
"""
missing_fields = []
if not self.jira_url:
missing_fields.append('ISSUE_TRACKER_URL')
if not self.jira_user:
missing_fields.append('ISSUE_TRACKER_USER')
if not self.jira_password:
missing_fields.append('ISSUE_TRACKER_PASSWORD')
if not self.jira_project_id:
missing_fields.append('ISSUE_TRACKER_PROJECT_ID')
if not self.jira_max_results:
missing_fields.append('ISSUE_TRACKER_MAX_RESULTS')
if missing_fields:
raise IssueConfigurationException(
f'The following config settings must be set for Jira: {", ".join(missing_fields)} ')
@staticmethod
def _get_issue_properties(issue: Issue) -> DataIssue:
"""
Maps the jira issue object to properties we want in the UI
:param issue: Jira issue to map
:return: JiraIssue
"""
return DataIssue(issue_key=issue.key,
title=issue.fields.summary,
url=issue.permalink())
def _get_remaining_issues(self, total: int) -> int:
"""
Calculates how many issues are not being displayed, so the FE can determine whether to
display a message about issues remaining
:param total: number from the result set representing how many issues were found in all
:return: int - 0, or how many issues remain
"""
return 0 if total < self.jira_max_results else total - self.jira_max_results
def _generate_remaining_issues_url(self, table_uri: str, issues: List[DataIssue]) -> str:
"""
Way to get the full list of jira tickets
SDK doesn't return a query
:param table_uri: table uri from the ui
:param issues: list of jira issues, only needed to grab a ticket name
:return: url to the full list of issues in jira
"""
if not issues or len(issues) == 0:
return ''
# jira expects a ticket key in the query to default to, so pick the first one
first_issue_key = issues[0].issue_key
search_query = urllib.parse.quote(SEARCH_STUB.format(table_key=table_uri))
return f'{self.jira_url}/browse/{first_issue_key}?jql={search_query}'
...@@ -61,6 +61,11 @@ img.icon { ...@@ -61,6 +61,11 @@ img.icon {
mask-image: url('/static/images/icons/Database.svg'); mask-image: url('/static/images/icons/Database.svg');
} }
&.icon-red-triangle-warning {
-webkit-mask-image: url('/static/images/icons/DataQualityWarning.svg');
mask-image: url('/static/images/icons/DataQualityWarning.svg');
}
&.icon-down { &.icon-down {
-webkit-mask-image: url('/static/images/icons/Down.svg'); -webkit-mask-image: url('/static/images/icons/Down.svg');
mask-image: url('/static/images/icons/Down.svg'); mask-image: url('/static/images/icons/Down.svg');
......
...@@ -83,7 +83,7 @@ $icon-bg: $gray20 !default; ...@@ -83,7 +83,7 @@ $icon-bg: $gray20 !default;
$icon-bg-brand: $brand-color-3 !default; $icon-bg-brand: $brand-color-3 !default;
$icon-bg-dark: $gray60 !default; $icon-bg-dark: $gray60 !default;
$icon-bg-disabled: $gray20 !default; $icon-bg-disabled: $gray20 !default;
$red-triangle-warning: $sunset60;
// Header, Body, & Footer // Header, Body, & Footer
$nav-bar-color: $indigo100; $nav-bar-color: $indigo100;
......
<svg width="30" height="32" viewBox="0 0 30 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.3864 22.667C15.3404 22.667 16.1294 21.961 16.1294 20.986C16.1294 20.012 15.3404 19.325 14.3864 19.325C13.4324 19.325 12.6434 20.012 12.6434 20.986C12.6434 21.961 13.4324 22.667 14.3864 22.667ZM13.0384 17.688H15.6314L15.6514 17.251C15.6514 16.588 16.1504 16.111 16.8964 15.488C17.8104 14.741 18.9704 13.87 18.9704 11.983C18.9704 9.556 17.1664 8 14.6764 8C12.2704 8 10.3634 9.431 10.3634 12.396H12.8934C12.9134 11.152 13.6394 10.593 14.5944 10.593C15.6314 10.593 16.1924 11.257 16.1924 12.086C16.2124 12.958 15.7764 13.331 15.0924 13.87C13.9094 14.762 13.0384 15.53 13.0384 17.211V17.688ZM29.3334 5.097V12.934C29.3334 21.148 23.1664 29.662 14.6664 32C6.16639 29.662 -0.000610352 21.148 -0.000610352 12.934V5.097L14.6664 0L29.3334 5.097Z" fill="#DB3615"/>
</svg>
export const REPORT_DATA_ISSUE_TEXT = "Report an issue";
\ No newline at end of file
import * as React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { GlobalState } from 'ducks/rootReducer';
import LoadingSpinner from 'components/common/LoadingSpinner';
import { createIssue } from 'ducks/issue/reducer';
import { CreateIssueRequest } from 'ducks/issue/types';
import './styles.scss';
import { REPORT_DATA_ISSUE_TEXT } from './constants';
import { logClick } from 'ducks/utilMethods';
import { notificationsEnabled, issueTrackingEnabled } from 'config/config-utils';
export interface ComponentProps {
tableKey: string;
tableName: string;
}
export interface DispatchFromProps {
createIssue: (data: FormData) => CreateIssueRequest;
}
export interface StateFromProps {
isLoading: boolean;
}
interface ReportTableIssueState {
isOpen: boolean;
}
export type ReportTableIssueProps = StateFromProps & DispatchFromProps & ComponentProps
export class ReportTableIssue extends React.Component<ReportTableIssueProps, ReportTableIssueState> {
constructor(props) {
super(props);
this.state = { isOpen: false };
}
submitForm = (event) => {
logClick(event);
event.preventDefault();
const form = document.getElementById("report-table-issue-form") as HTMLFormElement;
const formData = new FormData(form);
this.props.createIssue(formData);
this.setState({isOpen: false});
};
toggle = () => {
this.setState({ isOpen: !this.state.isOpen });
};
renderPipe = () => {
if (notificationsEnabled()) {
return ' | ';
}
return '';
}
render() {
if (!issueTrackingEnabled()) {
return '';
}
if (this.props.isLoading) {
return <LoadingSpinner />;
}
return (
<>
{this.renderPipe()}
<a href="javascript:void(0)"
className="report-table-issue-link"
onClick={this.toggle}
>
{ REPORT_DATA_ISSUE_TEXT }
</a>
{
this.state.isOpen &&
<div className="report-table-issue-modal">
<h3 className="data-issue-header">
{ REPORT_DATA_ISSUE_TEXT }
</h3>
<button type="button" className="btn btn-close" aria-label={"close"} onClick={this.toggle} />
<form id="report-table-issue-form" onSubmit={ this.submitForm }>
<input type="hidden" name="key" value={ this.props.tableKey }/>
<div className="form-group">
<label>Title</label>
<input name="title" className="form-control" required={true} maxLength={200} />
</div>
<div className="form-group">
<label>Description</label>
<textarea name="description" className="form-control" rows={5} required={true} maxLength={2000}/>
</div>
<button className="btn btn-primary submit" type="submit" >Submit</button>
</form>
</div>
}
</>
);
}
}
export const mapStateToProps = (state: GlobalState) => {
return {
isLoading: state.issue.isLoading
};
};
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ createIssue } , dispatch);
};
export default connect<StateFromProps, DispatchFromProps, ComponentProps>(mapStateToProps, mapDispatchToProps)(ReportTableIssue);
@import 'variables';
.report-table-issue-link {
font-size: 16px;
text-decoration: none;
&:hover,
&:visited,
&:active,
&:link {
text-decoration: none;
}
}
.report-table-issue-modal {
background-color: $white;
height: auto;
min-height: 300px;
padding: 32px;
width: 400px;
box-shadow: 0 0 24px -2px rgba(0, 0, 0, .2);
border-radius: 6px;
bottom: 100px;
display: block;
left: 25px;
position: fixed;
z-index: 6;
.btn-close {
position: absolute;
top: 32px;
right: 32px;
}
}
import * as React from 'react';
import { shallow } from 'enzyme';
import AppConfig from 'config/config';
import globalState from 'fixtures/globalState';
import {
ComponentProps,
ReportTableIssue,
ReportTableIssueProps,
mapDispatchToProps,
mapStateToProps,
} from '..';
const mockFormData = { key1: 'val1', key2: 'val2' };
// @ts-ignore: How to mock FormData without TypeScript error?
global.FormData = () => (mockFormData);
describe('ReportTableIssue', () => {
const setStateSpy = jest.spyOn(ReportTableIssue.prototype, 'setState');
const setup = (propOverrides?: Partial<ReportTableIssueProps>) => {
const props: ReportTableIssueProps = {
isLoading: false,
createIssue: jest.fn(),
tableKey: 'key',
tableName: 'name',
...propOverrides
};
const wrapper = shallow<ReportTableIssue>(<ReportTableIssue {...props} />);
return { props, wrapper };
}
describe('render', () => {
beforeAll(() => {
AppConfig.issueTracking.enabled = true;
});
it('renders nothing if issueTracking not enabled', () => {
AppConfig.issueTracking.enabled = false;
const { props, wrapper } = setup({ isLoading: false });
expect(wrapper.html()).toBeFalsy();
});
it('Renders loading spinner if not ready', () => {
const { props, wrapper } = setup();
expect(wrapper.find('.loading-spinner')).toBeTruthy();
});
it('Renders modal if open', () => {
const { props, wrapper } = setup({isLoading: false});
wrapper.setState({isOpen: true});
expect(wrapper.find('.report-table-issue-modal')).toBeTruthy();
});
describe('toggle', () => {
it('calls setState with negation of state.isOpen', () => {
setStateSpy.mockClear();
const { props, wrapper } = setup();
const previsOpenState = wrapper.state().isOpen;
wrapper.instance().toggle();
expect(setStateSpy).toHaveBeenCalledWith({ isOpen: !previsOpenState });
});
});
describe('submitForm', () => {
it ('calls createIssue with mocked form data', () => {
const { props, wrapper } = setup();
// @ts-ignore: mocked events throw type errors
wrapper.instance().submitForm({ preventDefault: jest.fn(),
currentTarget: {id: 'id', nodeName: 'button'} });
expect(props.createIssue).toHaveBeenCalledWith(mockFormData);
expect(wrapper.state().isOpen).toBe(false);
});
it ('calls sets isOpen to false', () => {
const { props, wrapper } = setup();
// @ts-ignore: mocked events throw type errors
wrapper.instance().submitForm({ preventDefault: jest.fn(),
currentTarget: {id: 'id', nodeName: 'button'} });
expect(wrapper.state().isOpen).toBe(false);
});
});
describe('mapDispatchToProps', () => {
let dispatch;
let props;
beforeAll(() => {
dispatch = jest.fn(() => Promise.resolve());
props = mapDispatchToProps(dispatch);
});
it('sets getIssues on the props', () => {
expect(props.createIssue).toBeInstanceOf(Function);
});
});
describe('mapStateToProps', () => {
let result;
beforeAll(() => {
result = mapStateToProps(globalState);
});
it('sets isLoading on the props', () => {
expect(result.isLoading).toEqual(globalState.issue.isLoading);
});
});
});
});
export const SEE_ADDITIONAL_ISSUES_TEXT = "See additional issues associated with this table";
\ No newline at end of file
import * as React from 'react';
import { GlobalState } from 'ducks/rootReducer';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { Issue } from 'interfaces';
import { getIssues } from 'ducks/issue/reducer';
import { logClick } from 'ducks/utilMethods';
import { GetIssuesRequest } from 'ducks/issue/types';
import './styles.scss';
import { issueTrackingEnabled } from 'config/config-utils';
import { SEE_ADDITIONAL_ISSUES_TEXT } from './constants';
export interface StateFromProps {
issues: Issue[];
remainingIssues: number;
remainingIssuesUrl: string;
}
export interface DispatchFromProps {
getIssues: (key: string) => GetIssuesRequest;
}
export interface ComponentProps {
tableKey: string;
}
export type TableIssueProps = StateFromProps & DispatchFromProps & ComponentProps;
export class TableIssues extends React.Component<TableIssueProps> {
constructor(props) {
super(props);
}
componentDidMount() {
if (issueTrackingEnabled()) {
this.props.getIssues(this.props.tableKey);
}
}
renderIssue = (issue: Issue, index: number) => {
return (
<div className="issue-banner" key={`issue-${index}`}>
<a id={`table-issue-link-${index}`} className="table-issue-link" target="_blank" href={issue.url} onClick={logClick}>
<img className="icon icon-red-triangle-warning "/>
{ issue.issue_key }
</a>
<span className="issue-title-display-text">
<span className="issue-title-name">
"{ issue.title }
</span>"
</span>
</div>
);
}
renderMoreIssuesMessage = (count: number, url: string) => {
if (count === 0) {
return '';
}
return (
<div className="issue-banner" key="more-issue-link">
<img className="icon icon-red-triangle-warning "/>
<a id="more-issues-link" className="table-issue-more-issues" target="_blank" href={url} onClick={logClick}>
{ SEE_ADDITIONAL_ISSUES_TEXT }
</a>
</div>
);
}
render() {
if (!issueTrackingEnabled()) {
return '';
}
if (this.props.issues.length === 0) {
return null;
}
return (
<div className="table-issues">
{ this.props.issues.map(this.renderIssue)}
{ this.renderMoreIssuesMessage(this.props.remainingIssues, this.props.remainingIssuesUrl)}
</div>
);
}
}
export const mapStateToProps = (state: GlobalState) => {
return {
issues: state.issue.issues,
remainingIssues: state.issue.remainingIssues,
remainingIssuesUrl: state.issue.remainingIssuesUrl
};
};
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ getIssues }, dispatch);
};
export default connect<StateFromProps, DispatchFromProps, ComponentProps>(mapStateToProps, mapDispatchToProps)(TableIssues);
@import 'variables';
.table-issues {
.issue-banner {
border-radius: 4px;
box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.12), 0px 2px 3px 0px rgba(0, 0, 0, 0.16);
height: 40px;
margin: 8px 0;
padding: 8px 16px;
&:first-child {
margin-top: 24px;
}
}
.icon-red-triangle-warning {
background: $red-triangle-warning;
margin: 0px 5px 0px 0px;
}
.table-issue-link {
margin: 0px 5px 0px 0px;
}
.issue-title-name {
max-width: 390px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display:inline-block;
}
.issue-title-display-text {
display: inline-flex;
}
.table-issue-more-issues {
margin: 0px 2px 0px 2px;
}
}
import * as React from 'react';
import { shallow } from 'enzyme';
import AppConfig from 'config/config';
import globalState from 'fixtures/globalState';
import {
TableIssues,
TableIssueProps,
mapStateToProps,
mapDispatchToProps
} from '..';
import { SEE_ADDITIONAL_ISSUES_TEXT } from '../constants';
describe ('TableIssues', ()=> {
const setStateSpy = jest.spyOn(TableIssues.prototype, 'setState');
const setup = (propOverrides?: Partial<TableIssueProps>) => {
const props: TableIssueProps = {
issues: [],
tableKey: 'key',
remainingIssues: 0,
remainingIssuesUrl: 'testUrl',
getIssues: jest.fn(),
...propOverrides
};
const wrapper = shallow<TableIssues>(<TableIssues {...props} />);
return { props, wrapper };
}
describe('render', () => {
beforeAll(() => {
AppConfig.issueTracking.enabled = true;
});
it('renders nothing if no issues', () => {
const { props, wrapper } = setup({ issues: [] });
expect(wrapper.html()).toBeFalsy();
});
it('renders nothing if issueTracking not enabled', () => {
AppConfig.issueTracking.enabled = false;
const { props, wrapper } = setup({ issues: [] });
expect(wrapper.html()).toBeFalsy();
});
it('renders issues if they exist', () => {
AppConfig.issueTracking.enabled = true;
const { props, wrapper } = setup({ issues: [{
issue_key: 'issue_key',
title: 'title',
url: 'http://url'
}]});
expect(wrapper.find('.table-issue-link').text()).toEqual('issue_key');
expect(wrapper.find('.issue-title-name').text()).toContain('title');
});
it('renders no extra notice if no remaining issues', () => {
const { props, wrapper } = setup({ issues: [{
issue_key: 'issue_key',
title: 'title',
url: 'http://url'
}],
remainingIssues: 0,
remainingIssuesUrl: null
});
expect(wrapper.find('.table-issue-more-issues').length).toEqual(0);
});
it('renders extra notice if remaining issues', () => {
const { props, wrapper } = setup({ issues: [{
issue_key: 'issue_key',
title: 'title',
url: 'http://url'
}],
remainingIssues: 1,
remainingIssuesUrl: 'url'
});
expect(wrapper.find('.table-issue-more-issues').text()).toEqual(SEE_ADDITIONAL_ISSUES_TEXT);
});
});
describe('mapDispatchToProps', () => {
let dispatch;
let props;
beforeAll(() => {
dispatch = jest.fn(() => Promise.resolve());
props = mapDispatchToProps(dispatch);
});
it('sets getIssues on the props', () => {
expect(props.getIssues).toBeInstanceOf(Function);
});
});
describe('mapStateToProps', () => {
let result;
beforeAll(() => {
result = mapStateToProps(globalState);
});
it('sets issues on the props', () => {
expect(result.issues).toEqual(globalState.issue.issues);
});
});
});
\ No newline at end of file
...@@ -21,15 +21,17 @@ import FrequentUsers from 'components/TableDetail/FrequentUsers'; ...@@ -21,15 +21,17 @@ import FrequentUsers from 'components/TableDetail/FrequentUsers';
import LoadingSpinner from 'components/common/LoadingSpinner'; import LoadingSpinner from 'components/common/LoadingSpinner';
import LineageLink from 'components/TableDetail/LineageLink'; import LineageLink from 'components/TableDetail/LineageLink';
import OwnerEditor from 'components/TableDetail/OwnerEditor'; import OwnerEditor from 'components/TableDetail/OwnerEditor';
import ReportTableIssue from 'components/TableDetail/ReportTableIssue';
import SourceLink from 'components/TableDetail/SourceLink'; import SourceLink from 'components/TableDetail/SourceLink';
import TableDescEditableText from 'components/TableDetail/TableDescEditableText'; import TableDescEditableText from 'components/TableDetail/TableDescEditableText';
import TableIssues from 'components/TableDetail/TableIssues';
import WatermarkLabel from 'components/TableDetail/WatermarkLabel'; import WatermarkLabel from 'components/TableDetail/WatermarkLabel';
import WriterLink from 'components/TableDetail/WriterLink'; import WriterLink from 'components/TableDetail/WriterLink';
import TagInput from 'components/Tags/TagInput'; import TagInput from 'components/Tags/TagInput';
import { TableMetadata } from 'interfaces/TableMetadata'; import { TableMetadata } from 'interfaces/TableMetadata';
import { EditableSection } from 'components/TableDetail/EditableSection'; import { EditableSection } from 'components/TableDetail/EditableSection';
import { getDatabaseDisplayName, getDatabaseIconClass, notificationsEnabled } from 'config/config-utils'; import { getDatabaseDisplayName, getDatabaseIconClass, notificationsEnabled, issueTrackingEnabled } from 'config/config-utils';
import { formatDateTimeShort } from 'utils/dateUtils'; import { formatDateTimeShort } from 'utils/dateUtils';
import './styles'; import './styles';
...@@ -164,10 +166,9 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps ...@@ -164,10 +166,9 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps
</header> </header>
<main className="column-layout-1"> <main className="column-layout-1">
<section className="left-panel"> <section className="left-panel">
{/* {}
TODO - Add a banner here if necessary <TableIssues tableKey={ this.key }/>
<section className="banner">optional banner</section>
*/}
<EditableSection title="Description"> <EditableSection title="Description">
<TableDescEditableText <TableDescEditableText
maxLength={ AppConfig.editableText.tableDescLength } maxLength={ AppConfig.editableText.tableDescLength }
...@@ -175,7 +176,10 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps ...@@ -175,7 +176,10 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps
editable={ data.is_editable } editable={ data.is_editable }
/> />
</EditableSection> </EditableSection>
<span>
{ notificationsEnabled() && <RequestDescriptionText/> } { notificationsEnabled() && <RequestDescriptionText/> }
{ issueTrackingEnabled() && <ReportTableIssue tableKey={ this.key } tableName={ this.getDisplayName() } />}
</span>
<section className="column-layout-2"> <section className="column-layout-2">
<section className="left-panel"> <section className="left-panel">
{ {
......
...@@ -18,6 +18,9 @@ const configCustom: AppConfigCustom = { ...@@ -18,6 +18,9 @@ const configCustom: AppConfigCustom = {
}, },
indexUsers: { indexUsers: {
enabled: false, enabled: false,
},
issueTracking: {
enabled: false
} }
}; };
......
...@@ -23,6 +23,9 @@ const configDefault: AppConfig = { ...@@ -23,6 +23,9 @@ const configDefault: AppConfig = {
indexUsers: { indexUsers: {
enabled: false, enabled: false,
}, },
issueTracking: {
enabled: false
},
logoPath: null, logoPath: null,
mailClientFeatures: { mailClientFeatures: {
feedbackEnabled: false, feedbackEnabled: false,
......
...@@ -11,6 +11,7 @@ export interface AppConfig { ...@@ -11,6 +11,7 @@ export interface AppConfig {
editableText: EditableTextConfig; editableText: EditableTextConfig;
google: GoogleAnalyticsConfig; google: GoogleAnalyticsConfig;
indexUsers: IndexUsersConfig; indexUsers: IndexUsersConfig;
issueTracking: IssueTrackingConfig;
logoPath: string | null; logoPath: string | null;
mailClientFeatures: MailClientFeaturesConfig; mailClientFeatures: MailClientFeaturesConfig;
navLinks: Array<LinkConfig>; navLinks: Array<LinkConfig>;
...@@ -26,6 +27,7 @@ export interface AppConfigCustom { ...@@ -26,6 +27,7 @@ export interface AppConfigCustom {
editableText?: EditableTextConfig; editableText?: EditableTextConfig;
google?: GoogleAnalyticsConfig google?: GoogleAnalyticsConfig
indexUsers?: IndexUsersConfig; indexUsers?: IndexUsersConfig;
issueTracking?: IssueTrackingConfig;
logoPath?: string; logoPath?: string;
mailClientFeatures?: MailClientFeaturesConfig; mailClientFeatures?: MailClientFeaturesConfig;
navLinks?: Array<LinkConfig>; navLinks?: Array<LinkConfig>;
...@@ -177,3 +179,11 @@ interface EditableTextConfig { ...@@ -177,3 +179,11 @@ interface EditableTextConfig {
tableDescLength: number; tableDescLength: number;
columnDescLength: number; columnDescLength: number;
} }
/**
* IssueTrackingConfig - configures whether to display the issue tracking feature
* that allows users to display tickets associated with a table and create ones
* linked to a table
*/
interface IssueTrackingConfig {
enabled: boolean;
}
\ No newline at end of file
...@@ -62,6 +62,13 @@ export function indexUsersEnabled(): boolean { ...@@ -62,6 +62,13 @@ export function indexUsersEnabled(): boolean {
return AppConfig.indexUsers.enabled; return AppConfig.indexUsers.enabled;
} }
/**
* Returns whether or not the issue tracking feature should be shown
*/
export function issueTrackingEnabled(): boolean {
return AppConfig.issueTracking.enabled;
}
/** /**
* Returns whether or not notification features should be enabled * Returns whether or not notification features should be enabled
*/ */
......
import axios, { AxiosResponse } from 'axios';
import * as API from '../v0';
jest.mock('axios');
describe('getIssues', () => {
let mockGetResponse;
let axiosMock;
beforeAll(() => {
mockGetResponse = {
data: {
issues: [],
msg: 'Success'
},
status: 200,
statusText: '',
headers: {},
config: {}
};
axiosMock = jest.spyOn(axios, 'get').mockImplementation(() => Promise.resolve(mockGetResponse));
});
it('calls axios with correct parameters if tableKey provided', async () => {
expect.assertions(1);
await API.getIssues('tableKey').then(data => {
expect(axiosMock).toHaveBeenCalledWith(`${API.API_PATH}/issues?key=tableKey`);
});
});
it('returns response data', async () => {
expect.assertions(1);
await API.getIssues('tableKey').then(data => {
expect(data).toEqual(mockGetResponse.data.issues);
});
});
afterAll(() => {
axiosMock.mockClear();
});
});
describe('createIssue', () => {
let mockGetResponse;
let axiosMock;
let formData;
const issueResult = { issue_key: 'key' };
beforeAll(() => {
mockGetResponse = {
data: {
issue: issueResult,
msg: 'Success'
},
status: 200,
statusText: '',
headers: {},
config: {}
};
formData = new FormData();
axiosMock = jest.spyOn(axios, 'post').mockImplementation(() => Promise.resolve(mockGetResponse));
});
it('calls expected endpoint with headers', async () => {
expect.assertions(1);
await API.createIssue(formData).then(data => {
expect(axiosMock).toHaveBeenCalledWith(
`${API.API_PATH}/issue`,
formData, {
headers: {'Content-Type': 'multipart/form-data'}
});
});
});
it('returns response data', async () => {
expect.assertions(1);
await API.createIssue(formData).then(data => {
expect(data).toEqual(issueResult);
});
});
afterAll(() => {
axiosMock.mockClear();
});
});
import axios, { AxiosResponse } from 'axios';
import { Issue } from 'interfaces';
export const API_PATH = '/api/issue';
export type IssuesAPI = {
issues: {
issues: Issue[];
remaining: number;
remaining_url: string;
}
}
export type IssueApi = {
issue: Issue;
}
export function getIssues(tableKey: string) {
return axios.get(`${API_PATH}/issues?key=${tableKey}`)
.then((response: AxiosResponse<IssuesAPI>) => {
return response.data.issues;
});
}
export function createIssue(data: FormData) {
const headers = {'Content-Type': 'multipart/form-data' };
return axios.post(`${API_PATH}/issue`, data, { headers }
).then((response: AxiosResponse<IssueApi>) => {
return response.data.issue;
});
}
import { Issue } from "interfaces";
import {
GetIssues,
CreateIssue,
GetIssuesResponse,
CreateIssueRequest,
GetIssuesRequest,
CreateIssueResponse}
from './types';
/* ACTIONS */
export function createIssue(formData: FormData): CreateIssueRequest {
return {
payload: {
data: formData,
},
type: CreateIssue.REQUEST,
};
};
export function createIssueSuccess(issue: Issue): CreateIssueResponse {
return {
type: CreateIssue.SUCCESS,
payload: {
issue
}
};
};
export function createIssueFailure(issue: Issue): CreateIssueResponse {
return {
type: CreateIssue.FAILURE,
payload: {
issue
}
};
};
export function getIssues(tableKey: string): GetIssuesRequest {
return {
type: GetIssues.REQUEST,
payload: {
key: tableKey
}
};
}
export function getIssuesSuccess(issues: Issue[], remainingIssues?: number, remainingIssuesUrl?: string): GetIssuesResponse {
return {
type: GetIssues.SUCCESS,
payload: {
issues,
remainingIssues,
remainingIssuesUrl
}
}
}
export function getIssuesFailure(issues: Issue[], remainingIssues?: number, remainingIssuesUrl?: string): GetIssuesResponse {
return {
type: GetIssues.FAILURE,
payload: {
issues,
remainingIssues,
remainingIssuesUrl
}
}
}
/* REDUCER */
export interface IssueReducerState {
issues: Issue[],
remainingIssuesUrl: string,
remainingIssues: number,
isLoading: boolean
};
export const initialIssuestate: IssueReducerState = {
issues: [],
remainingIssuesUrl: null,
remainingIssues: 0,
isLoading: false,
};
export default function reducer(state: IssueReducerState = initialIssuestate, action): IssueReducerState {
switch (action.type) {
case GetIssues.REQUEST:
return {
...initialIssuestate,
isLoading: true
}
case GetIssues.FAILURE:
return { ...initialIssuestate };
case GetIssues.SUCCESS:
return {...state,
...(<GetIssuesResponse> action).payload,
isLoading: false}
case CreateIssue.REQUEST:
return {...state, isLoading: true};
case CreateIssue.FAILURE:
return {...state,
isLoading: false
};
case CreateIssue.SUCCESS:
return {...state,
issues: [(<CreateIssueResponse> action).payload.issue, ...state.issues],
isLoading: false }
default:
return state;
}
}
import { SagaIterator } from 'redux-saga';
import { call, put, takeEvery } from 'redux-saga/effects';
import { getIssuesSuccess, getIssuesFailure, createIssueSuccess, createIssueFailure } from './reducer';
import { GetIssues, GetIssuesRequest, CreateIssue, CreateIssueRequest } from './types';
import * as API from './api/v0';
/** maybe just reload the issues content when there is a new issue created?*/
export function* getIssuesWorker(action: GetIssuesRequest): SagaIterator {
const { key } = action.payload;
let response;
try {
response = yield call(API.getIssues, key);
yield put(getIssuesSuccess(response.issues, response.remaining, response.remaining_url));
} catch(e) {
yield put(getIssuesFailure([], 0, null));
}
}
export function* getIssuesWatcher(): SagaIterator {
yield takeEvery(GetIssues.REQUEST, getIssuesWorker);
}
export function* createIssueWorker(action: CreateIssueRequest): SagaIterator {
try {
let response;
response = yield call(API.createIssue, action.payload.data);
yield put((createIssueSuccess(response)));
} catch(error) {
yield put(createIssueFailure(null));
}
}
export function* createIssueWatcher(): SagaIterator {
yield takeEvery(CreateIssue.REQUEST, createIssueWorker)
}
import { testSaga, expectSaga } from 'redux-saga-test-plan';
import * as matchers from 'redux-saga-test-plan/matchers';
import globalState from 'fixtures/globalState';
import * as API from '../api/v0';
import reducer, {
createIssue,
createIssueSuccess,
createIssueFailure,
getIssues,
getIssuesSuccess,
getIssuesFailure,
IssueReducerState
} from '../reducer';
import {
CreateIssue,
GetIssues,
GetIssuesRequest,
CreateIssueRequest
} from '../types';
import { Issue } from 'interfaces';
import { getIssuesWatcher, getIssuesWorker, createIssueWatcher, createIssueWorker } from '../sagas';
import { throwError } from 'redux-saga-test-plan/providers';
describe('issue ducks', () => {
let formData: FormData;
let tableKey: string;
let issue: Issue;
let issues: Issue[];
let remaining: number;
let remainingUrl: string;
beforeAll(() => {
tableKey = 'key';
const testData = {
key: 'table',
title: 'stuff',
description: 'This is a test'
};
formData = new FormData();
Object.keys(testData).forEach(key => formData.append(key, testData[key]));
issue = {
issue_key: 'issue_key',
title: 'title',
url: 'http://url'
};
issues = [issue];
remaining = 0;
remainingUrl = 'testurl';
});
describe('actions', () => {
it('getIssues - returns the action to submit feedback', () => {
const action = getIssues(tableKey);
const { payload } = action;
expect(action.type).toBe(GetIssues.REQUEST);
expect(payload.key).toBe(tableKey);
});
it('getIssuesSuccess - returns the action to process success', () => {
const action = getIssuesSuccess(issues, remaining, remainingUrl);
expect(action.type).toBe(GetIssues.SUCCESS);
});
it('getIssuesFailure - returns the action to process failure', () => {
const action = getIssuesFailure(null);
expect(action.type).toBe(GetIssues.FAILURE);
});
it('createIssue - returns the action to create items', () => {
const action = createIssue(formData);
const { payload } = action;
expect(action.type).toBe(CreateIssue.REQUEST);
expect(payload.data).toBe(formData);
});
it('createIssueFailure - returns the action to process failure', () => {
const action = createIssueFailure(null);
const { payload } = action;
expect(action.type).toBe(CreateIssue.FAILURE);
expect(payload.issue).toBe(null);
});
it('createIssueSuccess - returns the action to process success', () => {
const action = createIssueSuccess(issue);
const { payload } = action;
expect(action.type).toBe(CreateIssue.SUCCESS);
expect(payload.issue).toBe(issue);
});
});
describe('reducer', () => {
let testState: IssueReducerState;
let remainingUrl: string;
let remaining: number;
beforeAll(() => {
const stateIssues: Issue[]=[];
remaining = 0;
remainingUrl = 'testUrl';
testState = {
isLoading: false,
issues: stateIssues,
remainingIssues: remaining,
remainingIssuesUrl: remainingUrl
};
});
it('should return the existing state if action is not handled', () => {
expect(reducer(testState, { type: 'INVALID.ACTION' })).toEqual(testState);
});
it('should handle GetIssues.REQUEST', () => {
expect(reducer(testState, getIssues(tableKey))).toEqual({
issues: [],
isLoading: true,
remainingIssuesUrl: null,
remainingIssues: 0
});
});
it('should handle GetIssues.SUCCESS', () => {
expect(reducer(testState, getIssuesSuccess(issues, remaining, remainingUrl))).toEqual({
issues,
isLoading: false,
remainingIssues: remaining,
remainingIssuesUrl: remainingUrl
});
});
it('should handle GetIssues.FAILURE', () => {
expect(reducer(testState, getIssuesFailure([], 0, null))).toEqual({
issues: [],
isLoading: false,
remainingIssuesUrl: null,
remainingIssues: remaining
});
});
it('should handle CreateIssue.REQUEST', () => {
expect(reducer(testState, createIssue(formData))).toEqual({
issues: [],
isLoading: true,
remainingIssuesUrl: remainingUrl,
remainingIssues: remaining
});
});
it('should handle CreateIssue.SUCCESS', () => {
expect(reducer(testState, createIssueSuccess(issue))).toEqual({
...testState, issues: [issue], isLoading: false });
});
it('should handle CreateIssue.FAILURE', () => {
expect(reducer(testState, createIssueFailure(null))).toEqual({ issues: [],
isLoading: false,
remainingIssuesUrl: remainingUrl,
remainingIssues: remaining
});
});
});
describe('sagas', () => {
describe('getIssuesWatcher', () => {
it('takes every getIssues.REQUEST with getIssuesWatcher', () => {
testSaga(getIssuesWatcher)
.next().takeEvery(GetIssues.REQUEST, getIssuesWorker)
.next().isDone();
});
});
describe('getIssuesWorker', () => {
let action: GetIssuesRequest;
let remainingIssuesUrl: string;
let remainingIssues: number;
beforeAll(() => {
action = getIssues(tableKey);
issues = globalState.issue.issues;
remainingIssues = globalState.issue.remainingIssues;
remainingIssuesUrl = globalState.issue.remainingIssuesUrl;
});
it('gets issues', () => {
return expectSaga(getIssuesWorker, action)
.provide([
[matchers.call.fn(API.getIssues), {issues, remainingIssues, remainingIssuesUrl}],
])
.put(getIssuesSuccess(issues))
.run();
});
it('handles request error', () => {
return expectSaga(getIssuesWorker, action)
.provide([
[matchers.call.fn(API.getIssues), throwError(new Error())],
])
.put(getIssuesFailure([], 0, null))
.run();
});
});
describe('createIssueWatcher', () => {
it('takes every createIssue.REQUEST with getIssuesWatcher', () => {
testSaga(createIssueWatcher)
.next().takeEvery(CreateIssue.REQUEST, createIssueWorker)
.next().isDone();
});
});
describe('createIssuesWorker', () => {
let action: CreateIssueRequest;
beforeAll(() => {
action = createIssue(formData);
issues = [issue];
});
it('creates a issue', () => {
return expectSaga(createIssueWorker, action)
.provide([
[matchers.call.fn(API.createIssue), issue],
])
.put(createIssueSuccess(issue))
.run();
});
it('handles request error', () => {
return expectSaga(createIssueWorker, action)
.provide([
[matchers.call.fn(API.createIssue), throwError(new Error())],
])
.put(createIssueFailure(null))
.run();
});
});
});
});
import { Issue } from "interfaces";
export enum GetIssues {
REQUEST = 'amundsen/issue/GET_ISSUES_REQUEST',
SUCCESS = 'amundsen/issue/GET_ISSUES_SUCCESS',
FAILURE = 'amundsen/issue/GET_ISSUES_FAILURE',
};
export enum CreateIssue {
REQUEST = 'amundsen/issue/CREATE_ISSUE_REQUEST',
SUCCESS = 'amundsen/issue/CREATE_ISSUE_SUCCESS',
FAILURE = 'amundsen/issue/CREATE_ISSUE_FAILURE',
};
export interface GetIssuesRequest {
type: GetIssues.REQUEST;
payload: {
key: string
}
};
export interface CreateIssueRequest {
type: CreateIssue.REQUEST;
payload: {
data: FormData
}
};
export interface GetIssuesResponse {
type: GetIssues.SUCCESS | GetIssues.FAILURE;
payload: {
issues: Issue[];
remainingIssues: number;
remainingIssuesUrl: string;
}
};
export interface CreateIssueResponse {
type: CreateIssue.SUCCESS | CreateIssue.FAILURE;
payload: {
issue: Issue
}
};
\ No newline at end of file
...@@ -9,11 +9,13 @@ import allTags, { AllTagsReducerState } from './allTags/reducer'; ...@@ -9,11 +9,13 @@ import allTags, { AllTagsReducerState } from './allTags/reducer';
import user, { UserReducerState } from './user/reducer'; import user, { UserReducerState } from './user/reducer';
import bookmarks, { BookmarkReducerState } from "./bookmark/reducer"; import bookmarks, { BookmarkReducerState } from "./bookmark/reducer";
import notification, { NotificationReducerState } from './notification/reducer'; import notification, { NotificationReducerState } from './notification/reducer';
import issue, { IssueReducerState } from './issue/reducer';
export interface GlobalState { export interface GlobalState {
announcements: AnnouncementsReducerState; announcements: AnnouncementsReducerState;
bookmarks: BookmarkReducerState; bookmarks: BookmarkReducerState;
feedback: FeedbackReducerState; feedback: FeedbackReducerState;
issue: IssueReducerState;
notification: NotificationReducerState; notification: NotificationReducerState;
popularTables: PopularTablesReducerState; popularTables: PopularTablesReducerState;
search: SearchReducerState; search: SearchReducerState;
...@@ -26,6 +28,7 @@ export default combineReducers<GlobalState>({ ...@@ -26,6 +28,7 @@ export default combineReducers<GlobalState>({
announcements, announcements,
bookmarks, bookmarks,
feedback, feedback,
issue,
notification, notification,
popularTables, popularTables,
search, search,
......
...@@ -15,6 +15,10 @@ import { submitNotificationWatcher } from './notification/sagas'; ...@@ -15,6 +15,10 @@ import { submitNotificationWatcher } from './notification/sagas';
// FeedbackForm // FeedbackForm
import { submitFeedbackWatcher } from './feedback/sagas'; import { submitFeedbackWatcher } from './feedback/sagas';
// Issues
import { createIssueWatcher, getIssuesWatcher } from './issue/sagas';
// PopularTables // PopularTables
import { getPopularTablesWatcher } from './popularTables/sagas'; import { getPopularTablesWatcher } from './popularTables/sagas';
// Search // Search
...@@ -63,6 +67,9 @@ export default function* rootSaga() { ...@@ -63,6 +67,9 @@ export default function* rootSaga() {
submitNotificationWatcher(), submitNotificationWatcher(),
// FeedbackForm // FeedbackForm
submitFeedbackWatcher(), submitFeedbackWatcher(),
// Issues
getIssuesWatcher(),
createIssueWatcher(),
// Search // Search
inlineSearchWatcher(), inlineSearchWatcher(),
inlineSearchWatcherDebounce(), inlineSearchWatcherDebounce(),
......
...@@ -32,6 +32,12 @@ const globalState: GlobalState = { ...@@ -32,6 +32,12 @@ const globalState: GlobalState = {
feedback: { feedback: {
sendState: SendingState.IDLE, sendState: SendingState.IDLE,
}, },
issue: {
issues: [],
remainingIssuesUrl: null,
remainingIssues: 0,
isLoading: true
},
notification: { notification: {
requestIsOpen: false, requestIsOpen: false,
sendState: SendingState.IDLE, sendState: SendingState.IDLE,
......
export interface Issue {
issue_key: string;
title: string;
url: string;
};
\ No newline at end of file
...@@ -6,3 +6,4 @@ export * from './Resources'; ...@@ -6,3 +6,4 @@ export * from './Resources';
export * from './TableMetadata'; export * from './TableMetadata';
export * from './Tags'; export * from './Tags';
export * from './User'; export * from './User';
export * from './Issue';
\ No newline at end of file
...@@ -69,3 +69,10 @@ marshmallow-annotations==2.4.0 ...@@ -69,3 +69,10 @@ marshmallow-annotations==2.4.0
responses==0.9.0 responses==0.9.0
amundsen-common==0.2.2 amundsen-common==0.2.2
# Library for rest endpoints with Flask
# Upstream url: https://github.com/flask-restful/flask-restful
flask-restful==0.3.7
# SDK for JIRA
jira==2.0.0
import json
import unittest
from http import HTTPStatus
from amundsen_application import create_app
from amundsen_application.proxy.issue_tracker_clients.issue_exceptions import IssueConfigurationException
from amundsen_application.models.data_issue import DataIssue
from amundsen_application.models.issue_results import IssueResults
local_app = create_app('amundsen_application.config.TestConfig', 'tests/templates')
class IssueTest(unittest.TestCase):
def setUp(self) -> None:
local_app.config['ISSUE_TRACKER_URL'] = 'url'
local_app.config['ISSUE_TRACKER_CLIENT_ENABLED'] = True
self.mock_issue = {
'issue_key': 'key',
'title': 'some title',
'url': 'http://somewhere',
}
self.mock_issues = {
'issues': [self.mock_issue]
}
self.mock_data_issue = DataIssue(issue_key='key',
title='title',
url='http://somewhere')
self.expected_issues = IssueResults(issues=[self.mock_data_issue],
remaining=0,
remaining_url="http://moredata")
# ----- Jira API Tests ---- #
def test_get_issues_not_enabled(self) -> None:
"""
Test request sends ACCEPTED if not enabled
:return:
"""
local_app.config['ISSUE_TRACKER_CLIENT_ENABLED'] = False
with local_app.test_client() as test:
response = test.get('/api/issue/issues', query_string=dict(key='table_key'))
self.assertEqual(response.status_code, HTTPStatus.ACCEPTED)
@unittest.mock.patch('amundsen_application.api.issue.issue.get_issue_tracker_client')
def test_get_jira_issues_missing_config(self, mock_issue_tracker_client) -> None:
"""
Test request failure if config settings are missing
:return:
"""
local_app.config['ISSUE_TRACKER_URL'] = None
mock_issue_tracker_client.return_value.get_issues.side_effect = IssueConfigurationException
with local_app.test_client() as test:
response = test.get('/api/issue/issues', query_string=dict(key='table_key'))
self.assertEqual(response.status_code, HTTPStatus.NOT_IMPLEMENTED)
def test_get_jira_issues_no_key(self) -> None:
"""
Test request failure if table key is missing
:return:
"""
with local_app.test_client() as test:
response = test.get('/api/issue/issues', query_string=dict(some_key='value'))
self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
@unittest.mock.patch('amundsen_application.api.issue.issue.get_issue_tracker_client')
def test_get_jira_issues_success(self, mock_issue_tracker_client) -> None:
"""
Tests successful get request
:return:
"""
mock_issue_tracker_client.return_value.get_issues.return_value = self.expected_issues
with local_app.test_client() as test:
response = test.get('/api/issue/issues', query_string=dict(key='table_key'))
data = json.loads(response.data)
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertEqual(data['issues']['issues'][0]['issue_key'],
self.expected_issues.issues[0].issue_key)
self.assertEqual(data['issues']['remaining'],
self.expected_issues.remaining)
self.assertEqual(data['issues']['remaining_url'],
self.expected_issues.remaining_url)
mock_issue_tracker_client.return_value.get_issues.assert_called_with('table_key')
def test_create_issue_not_enabled(self) -> None:
"""
Test request sends ACCEPTED if not enabled
:return:
"""
local_app.config['ISSUE_TRACKER_CLIENT_ENABLED'] = False
with local_app.test_client() as test:
response = test.post('/api/issue/issue', data={
'description': 'test description',
'title': 'test title',
'key': 'key'
})
self.assertEqual(response.status_code, HTTPStatus.ACCEPTED)
@unittest.mock.patch('amundsen_application.api.issue.issue.get_issue_tracker_client')
def test_create_jira_issue_missing_config(self, mock_issue_tracker_client) -> None:
"""
Test request failure if config settings are missing
:return:
"""
mock_issue_tracker_client.side_effect = IssueConfigurationException
local_app.config['ISSUE_TRACKER_URL'] = None
with local_app.test_client() as test:
response = test.post('/api/issue/issue', data={
'description': 'test description',
'title': 'test title',
'key': 'key'
})
self.assertEqual(response.status_code, HTTPStatus.NOT_IMPLEMENTED)
def test_create_jira_issue_no_description(self) -> None:
"""
Test request failure if table key is missing
:return:
"""
with local_app.test_client() as test:
response = test.post('/api/issue/issue', data={
'key': 'table_key',
'title': 'test title',
})
self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
def test_create_jira_issue_no_key(self) -> None:
"""
Test request failure if table key is missing
:return:
"""
with local_app.test_client() as test:
response = test.post('/api/issue/issue', data={
'description': 'test description',
'title': 'test title'
})
self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
def test_create_jira_issue_no_title(self) -> None:
"""
Test request failure if table key is missing
:return:
"""
with local_app.test_client() as test:
response = test.post('/api/issue/issue', data={
'description': 'test description',
'key': 'table_key',
})
self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
@unittest.mock.patch('amundsen_application.api.issue.issue.get_issue_tracker_client')
def test_create_jira_issue_success(self, mock_issue_tracker_client) -> None:
"""
Test request returns success and expected outcome
:return:
"""
mock_issue_tracker_client.return_value.create_issue.return_value = self.mock_data_issue
with local_app.test_client() as test:
response = test.post('/api/issue/issue',
content_type='multipart/form-data',
data={
'description': 'test description',
'title': 'title',
'key': 'key'
})
data = json.loads(response.data)
self.assertEqual(response.status_code, HTTPStatus.OK)
mock_issue_tracker_client.assert_called
mock_issue_tracker_client.return_value.create_issue.assert_called
self.assertEqual(data['issue'].get('title'), 'title')
self.assertEqual(data['issue'].get('issue_key'), 'key')
import unittest
import flask
from amundsen_application.base.base_issue_tracker_client import BaseIssueTrackerClient
app = flask.Flask(__name__)
app.config.from_object('amundsen_application.config.TestConfig')
class IssueTrackerClientTest(unittest.TestCase):
def setUp(self) -> None:
BaseIssueTrackerClient.__abstractmethods__ = frozenset()
self.client = BaseIssueTrackerClient()
def tearDown(self) -> None:
pass
def test_cover_get_issues(self) -> None:
with app.test_request_context():
try:
self.client.get_issues(table_uri='test')
except NotImplementedError:
self.assertTrue(True)
else:
self.assertTrue(False)
def test_cover_create_issue(self) -> None:
with app.test_request_context():
try:
self.client.create_issue(table_uri='test', title='title', description='description')
except NotImplementedError:
self.assertTrue(True)
else:
self.assertTrue(False)
from unittest.mock import Mock
import flask
import unittest
from amundsen_application.proxy.issue_tracker_clients.issue_exceptions import IssueConfigurationException
from amundsen_application.proxy.issue_tracker_clients.jira_client import JiraClient
from amundsen_application.models.data_issue import DataIssue
from jira import JIRAError
from typing import Dict, List
app = flask.Flask(__name__)
app.config.from_object('amundsen_application.config.TestConfig')
class MockJiraResultList(list):
def __init__(self, iterable: Dict[str, str], _total: int) -> None:
if iterable is not None:
list.__init__(self, iterable)
else:
list.__init__(self)
self.total = _total
class JiraClientTest(unittest.TestCase):
def setUp(self) -> None:
self.mock_issue = {
'issue_key': 'key',
'title': 'some title',
'url': 'http://somewhere'
}
result_list = MockJiraResultList(iterable=self.mock_issue, _total=0)
self.mock_jira_issues = result_list
self.mock_issue_instance = DataIssue(issue_key='key',
title='some title',
url='http://somewhere')
@unittest.mock.patch('amundsen_application.proxy.issue_tracker_clients.jira_client.JIRA')
def test_create_JiraClient_validates_config(self, mock_JIRA_client: Mock) -> None:
with app.test_request_context():
try:
JiraClient(issue_tracker_url='',
issue_tracker_user='',
issue_tracker_password='',
issue_tracker_project_id=-1,
issue_tracker_max_results=-1)
except IssueConfigurationException as e:
self.assertTrue(type(e), type(IssueConfigurationException))
self.assertTrue(e, 'The following config settings must be set for Jira: '
'ISSUE_TRACKER_URL, ISSUE_TRACKER_USER, ISSUE_TRACKER_PASSWORD, '
'ISSUE_TRACKER_PROJECT_ID')
@unittest.mock.patch('amundsen_application.proxy.issue_tracker_clients.jira_client.JIRA')
@unittest.mock.patch('amundsen_application.proxy.issue_tracker_clients.jira_client.'
'JiraClient._get_remaining_issues')
def test_get_issues_returns_JIRAError(self, mock_remaining_issues: Mock, mock_JIRA_client: Mock) -> None:
mock_JIRA_client.return_value.get_issues.side_effect = JIRAError('Some exception')
mock_remaining_issues.return_value = 0
with app.test_request_context():
try:
jira_client = JiraClient(issue_tracker_url=app.config['ISSUE_TRACKER_URL'],
issue_tracker_user=app.config['ISSUE_TRACKER_USER'],
issue_tracker_password=app.config['ISSUE_TRACKER_PASSWORD'],
issue_tracker_project_id=app.config['ISSUE_TRACKER_PROJECT_ID'],
issue_tracker_max_results=app.config['ISSUE_TRACKER_MAX_RESULTS'])
jira_client.get_issues('key')
except JIRAError as e:
self.assertTrue(type(e), type(JIRAError))
self.assertTrue(e, 'Some exception')
@unittest.mock.patch('amundsen_application.proxy.issue_tracker_clients.jira_client.JIRA')
@unittest.mock.patch('amundsen_application.proxy.issue_tracker_clients.jira_client.'
'JiraClient._get_issue_properties')
@unittest.mock.patch('amundsen_application.proxy.issue_tracker_clients.'
'jira_client.JiraClient._generate_remaining_issues_url')
def test_get_issues_returns_issues(self,
mock_get_url: Mock,
mock_get_issue_properties: Mock,
mock_JIRA_client: Mock) -> None:
mock_JIRA_client.return_value.search_issues.return_value = self.mock_jira_issues
mock_get_issue_properties.return_value = self.mock_issue
mock_get_url.return_value = 'url'
with app.test_request_context():
jira_client = JiraClient(issue_tracker_url=app.config['ISSUE_TRACKER_URL'],
issue_tracker_user=app.config['ISSUE_TRACKER_USER'],
issue_tracker_password=app.config['ISSUE_TRACKER_PASSWORD'],
issue_tracker_project_id=app.config['ISSUE_TRACKER_PROJECT_ID'],
issue_tracker_max_results=app.config['ISSUE_TRACKER_MAX_RESULTS'])
results = jira_client.get_issues(table_uri='key')
mock_JIRA_client.assert_called
self.assertEqual(results.issues[0], self.mock_issue)
self.assertEqual(results.remaining, self.mock_jira_issues.total)
mock_JIRA_client.return_value.search_issues.assert_called_with(
'text ~ "key" AND resolution = Unresolved order by createdDate DESC',
maxResults=3)
@unittest.mock.patch('amundsen_application.proxy.issue_tracker_clients.jira_client.JIRA')
@unittest.mock.patch('amundsen_application.proxy.issue_tracker_clients.jira_client.urllib.parse.quote')
def test__generate_remaining_issues_url(self, mock_url_lib: Mock, mock_JIRA_client: Mock) -> None:
mock_url_lib.return_value = 'test'
with app.test_request_context():
jira_client = JiraClient(issue_tracker_url=app.config['ISSUE_TRACKER_URL'],
issue_tracker_user=app.config['ISSUE_TRACKER_USER'],
issue_tracker_password=app.config['ISSUE_TRACKER_PASSWORD'],
issue_tracker_project_id=app.config['ISSUE_TRACKER_PROJECT_ID'],
issue_tracker_max_results=app.config['ISSUE_TRACKER_MAX_RESULTS'])
issues = [DataIssue(issue_key='key', title='title', url='url')]
url = jira_client._generate_remaining_issues_url(table_uri="table", issues=issues)
self.assertEqual(url, 'test_url/browse/key?jql=test')
@unittest.mock.patch('amundsen_application.proxy.issue_tracker_clients.jira_client.JIRA')
def test__generate_remaining_issues_url_no_issues(self, mock_JIRA_client: Mock) -> None:
with app.test_request_context():
jira_client = JiraClient(issue_tracker_url=app.config['ISSUE_TRACKER_URL'],
issue_tracker_user=app.config['ISSUE_TRACKER_USER'],
issue_tracker_password=app.config['ISSUE_TRACKER_PASSWORD'],
issue_tracker_project_id=app.config['ISSUE_TRACKER_PROJECT_ID'],
issue_tracker_max_results=app.config['ISSUE_TRACKER_MAX_RESULTS'])
issues: List[DataIssue]
issues = []
url = jira_client._generate_remaining_issues_url(table_uri="table", issues=issues)
self.assertEqual(url, '')
@unittest.mock.patch('amundsen_application.proxy.issue_tracker_clients.jira_client.JIRA')
def test_create_returns_JIRAError(self, mock_JIRA_client: Mock) -> None:
mock_JIRA_client.return_value.create_issue.side_effect = JIRAError('Some exception')
with app.test_request_context():
try:
jira_client = JiraClient(issue_tracker_url=app.config['ISSUE_TRACKER_URL'],
issue_tracker_user=app.config['ISSUE_TRACKER_USER'],
issue_tracker_password=app.config['ISSUE_TRACKER_PASSWORD'],
issue_tracker_project_id=app.config['ISSUE_TRACKER_PROJECT_ID'],
issue_tracker_max_results=app.config['ISSUE_TRACKER_MAX_RESULTS'])
jira_client.create_issue(description='desc', table_uri='key', title='title')
except JIRAError as e:
self.assertTrue(type(e), type(JIRAError))
self.assertTrue(e, 'Some exception')
@unittest.mock.patch('amundsen_application.proxy.issue_tracker_clients.jira_client.JIRA')
@unittest.mock.patch('amundsen_application.proxy.issue_tracker_clients.jira_client.'
'JiraClient._get_issue_properties')
def test_create_issue(self, mock_get_issue_properties: Mock, mock_JIRA_client: Mock) -> None:
mock_JIRA_client.return_value.create_issue.return_value = self.mock_issue
mock_get_issue_properties.return_value = self.mock_issue_instance
with app.test_request_context():
jira_client = JiraClient(issue_tracker_url=app.config['ISSUE_TRACKER_URL'],
issue_tracker_user=app.config['ISSUE_TRACKER_USER'],
issue_tracker_password=app.config['ISSUE_TRACKER_PASSWORD'],
issue_tracker_project_id=app.config['ISSUE_TRACKER_PROJECT_ID'],
issue_tracker_max_results=app.config['ISSUE_TRACKER_MAX_RESULTS'])
results = jira_client.create_issue(description='desc', table_uri='key', title='title')
mock_JIRA_client.assert_called
self.assertEqual(results, self.mock_issue_instance)
mock_JIRA_client.return_value.create_issue.assert_called_with(fields=dict(project={
'id': app.config["ISSUE_TRACKER_PROJECT_ID"]
}, issuetype={
'id': 1,
'name': 'Bug',
}, summary='title', description='desc' + ' \n Table Key: ' + 'key [PLEASE DO NOT REMOVE]'))
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