Unverified Commit cf22e336 authored by Marcos Iglesias's avatar Marcos Iglesias Committed by GitHub

feat: Shows Query code block within Dashboard Query tab (#474)

* Creates QueryResource and updates data definitions

* Mades it a functional component with hooks, added link to dashboard

* Right button behavior (almost)

* Fixes code block styling conflict

* Lazy loads Codeblock for improved performance

* Adds tooltip to mode button

* Cleaning up styles

* Cleaning query_names from the frontend code

* Fixes warnings and mock type issue

* Fixes Typescript warning

* TEst cleanup

* Cleans up webpack flags and extends loading message

* Adds shimmer loader, extract animations include and moves tooltip to top

* Tamika comments

* Making the shimmer a set of lines

* Adds an extra line on the loading screen

* Delete eslint cache file

* Adds comment
parent b4274486
@import 'variables';
$loading-duration: 1s;
$loading-curve: cubic-bezier(0.45, 0, 0.15, 1);
@keyframes shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
.is-shimmer-animated {
animation: $loading-duration shimmer $loading-curve infinite;
background-image: linear-gradient(
to right,
$gray10 0%,
$gray10 33%,
$gray5 50%,
$gray10 67%,
$gray10 100%
);
background-repeat: no-repeat;
background-size: 300% 100%;
}
......@@ -12,7 +12,9 @@ $icon-font-path: '/static/fonts/bootstrap/';
// Core CSS
@import '~bootstrap-sass/assets/stylesheets/bootstrap/scaffolding';
@import '~bootstrap-sass/assets/stylesheets/bootstrap/code';
// Commenting out as we use a specific component for code highlight
// that collides with these styles
// @import '~bootstrap-sass/assets/stylesheets/bootstrap/code';
@import '~bootstrap-sass/assets/stylesheets/bootstrap/grid';
@import '~bootstrap-sass/assets/stylesheets/bootstrap/tables';
@import '~bootstrap-sass/assets/stylesheets/bootstrap/forms';
......
......@@ -168,3 +168,29 @@ $resource-header-height: 84px;
flex-direction: column;
}
}
// Main Layout
#main {
min-width: $body-min-width;
}
#main > .container {
margin: 96px auto 48px;
}
@media (max-width: $screen-md-max) {
#main > .container {
margin: 64px auto 48px;
}
}
@media (max-width: $screen-sm-max) {
#main > .container {
margin: 32px auto 48px;
}
}
.my-auto {
margin-bottom: auto;
margin-top: auto;
}
......@@ -153,3 +153,9 @@ body {
.text-primary{
color: $text-primary;
}
.truncated {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@import 'bootstrap-custom';
@import 'animations';
@import 'avatars';
@import 'buttons';
@import 'dropdowns';
......@@ -13,29 +14,6 @@
@import 'popovers';
@import 'typography';
// TODO - Move to separate files
// Layout
#main {
min-width: $body-min-width;
}
#main > .container {
margin: 96px auto 48px;
}
@media (max-width: $screen-md-max) {
#main > .container {
margin: 64px auto 48px;
}
}
@media (max-width: $screen-sm-max) {
#main > .container {
margin: 32px auto 48px;
}
}
// Misc
td {
white-space: nowrap;
......@@ -45,14 +23,3 @@ td {
form {
margin-bottom: 0;
}
.truncated {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.my-auto {
margin-bottom: auto;
margin-top: auto;
}
import * as React from 'react';
import { shallow } from 'enzyme';
import QueryList, { QueryListProps } from './';
import QueryListItem from '../QueryListItem';
describe('QueryList', () => {
const setup = (propOverrides?: Partial<QueryListProps>) => {
const props = {
queries: [],
...propOverrides,
};
const wrapper = shallow<QueryList>(<QueryList {...props} />)
return { props, wrapper };
import { ResourceType } from 'interfaces';
const setup = (propOverrides?: Partial<QueryListProps>) => {
const props = {
queries: [],
...propOverrides,
};
const wrapper = shallow<QueryList>(<QueryList {...props} />);
return { props, wrapper };
};
describe('QueryList', () => {
describe('render', () => {
it('returns null if no queries', () => {
const { props, wrapper } = setup({ queries: [] });
expect(wrapper.type()).toEqual(null);
it('returns a list item for each query', () => {
const { props, wrapper } = setup({
queries: [
{
"type": ResourceType.query,
"name": "2022-02-22 TEST QUERY NAME",
"query_text": "WITH\n\ncolumnName AS (\nSELECT split_part(columnName, 'TEST', 2) as parameter,\n SUM(amount) as vct_spend\nFROM hive.core.fact_passenger_spends\nWHERE ds >= '2020-02-26'\n AND ds < '2020-03-04'\n AND coupon_code like '%VCT%'\n AND coupon_code like 'USAPAXENG%'\n AND coupon_code NOT like '%PASS%' -- EXCLUDING RIDE PASSES DUE TO 5-WEEK SPEND\n AND coupon_code NOT like '%10CAP1X75AD%' -- NOT SURE WHY THIS COUPON WAS BEING DEPLOYED...\n AND coupon_code NOT like '%QAR%' -- Q1 QAR test\nGROUP BY 1\n)\n\nSELECT offer,\n vct_spend\nFROM offer_spends\nUNION\nSELECT 'TOTAL' as offer,\n SUM(vct_spend) as vct_spend\nFROM offer_spends\nUNION \nSELECT 'AVG_SPEND' as offer,\n SUM(vct_spend) / COUNT(offer) * 1.0 as vct_spend\nFROM offer_spends\nORDER BY 1",
"url": "https://app.mode.com/company/reports/testID/queries/testQuery"
},
{
"type": ResourceType.query,
"name": "2022-02-23 TEST QUERY NAME TWO",
"query_text": "WITH\n\ncolumnName2 AS (\nSELECT split_part(columnName2, 'TEST', 2) as parameter,\n SUM(amount) as vct_spend\nFROM hive.core.fact_passenger_spends\nWHERE ds >= '2020-02-26'\n AND ds < '2020-03-04'\n AND coupon_code like '%VCT%'\n AND coupon_code like 'USAPAXENG%'\n AND coupon_code NOT like '%PASS%' -- EXCLUDING RIDE PASSES DUE TO 5-WEEK SPEND\n AND coupon_code NOT like '%10CAP1X75AD%' -- NOT SURE WHY THIS COUPON WAS BEING DEPLOYED...\n AND coupon_code NOT like '%QAR%' -- Q1 QAR test\nGROUP BY 1\n)\n\nSELECT offer,\n vct_spend\nFROM offer_spends\nUNION\nSELECT 'TOTAL' as offer,\n SUM(vct_spend) as vct_spend\nFROM offer_spends\nUNION \nSELECT 'AVG_SPEND' as offer,\n SUM(vct_spend) / COUNT(offer) * 1.0 as vct_spend\nFROM offer_spends\nORDER BY 1",
"url": "https://app.mode.com/company/reports/testID2/queries/testQuery2"
}
]
});
const expected = props.queries.length;
const actual = wrapper.find(QueryListItem).length;
expect(actual).toEqual(expected);
});
it('returns a list item for each query', () => {
const { props, wrapper } = setup({ queries: ['query1', 'query2'] });
props.queries.forEach((item, index) => {
expect(wrapper.find('li').at(index).text()).toBe(item);
describe('when no queries available', () => {
it('returns null', () => {
const { wrapper } = setup();
const expected = 0;
const actual = wrapper.find(QueryListItem).length;
expect(actual).toEqual(expected);
});
});
});
......
import * as React from 'react';
import QueryListItem from '../QueryListItem';
import { QueryResource } from 'interfaces';
import "./styles.scss";
export interface QueryListProps {
queries: string[];
queries: QueryResource[];
}
class QueryList extends React.Component<QueryListProps> {
render() {
const queries = this.props.queries;
const { queries } = this.props;
if (queries.length === 0) {
return null;
}
const queryList = queries.map(({name, query_text, url}) => (
<QueryListItem
key={`key:${name}`}
text={query_text}
url={url}
name={name}
/>
));
return (
<ul className="query-list list-group">
{
queries.map((query, index)=>
(
<li key={index} className="query-list-item list-group-item">
<div className="title-2 truncated">
{ query }
</div>
</li>
)
)
}
{ queryList }
</ul>
)
);
}
}
......
@import 'variables';
$min-item-height: 54px;
.query-list.list-group {
margin-bottom: 0;
.query-list-item.list-group-item {
}
height: 56px;
padding: 16px 24px;
}
.query-list-item.list-group-item {
min-height: $min-item-height;
padding: 0 $spacer-3;
}
import * as React from 'react';
import { CopyBlock, atomOneLight } from 'react-code-blocks';
const LANGUAGE = "sql";
const CodeBlock = ({text}) => {
return (
<CopyBlock
text={text}
language={LANGUAGE}
theme={atomOneLight}
showLineNumbers={false}
wrapLines={true}
codeBlock={true}
/>
);
}
export default CodeBlock;
import * as React from 'react';
import { mount } from 'enzyme';
import QueryListItem, { QueryListItemProps } from './';
const setup = (propOverrides?: Partial<QueryListItemProps>) => {
const props: QueryListItemProps = {
text: "testQuery",
url: "http://test.url",
name: "testName",
...propOverrides,
};
const wrapper = mount(<QueryListItem {...props} />);
return { props, wrapper };
};
describe('QueryListItem', () => {
describe('render', () => {
it('should render without errors', () => {
expect(() => {
setup();
}).not.toThrow();
});
it('should render one query list item', () => {
const { wrapper} = setup();
const expected = 1;
const actual = wrapper.find('.query-list-item').length;
expect(actual).toEqual(expected);
});
it('should render the query name', () => {
const { wrapper, props } = setup();
const expected = props.name;
const actual = wrapper.find('.query-list-item-name').text();
expect(actual).toEqual(expected);
});
it('should not render the expanded content', () => {
const { wrapper } = setup();
const expected = 0;
const actual = wrapper.find('.query-list-expanded-content').length;
expect(actual).toEqual(expected);
});
describe('when item is expanded', () => {
let wrapper;
beforeAll(() => {
({ wrapper } = setup());
wrapper.find('.query-list-header').simulate('click');
});
it('should show the query label', () => {
const expected = 1;
const actual = wrapper.find('.query-list-query-label').length;
expect(actual).toEqual(expected);
});
it('should show the query content', () => {
const expected = 1;
const actual = wrapper.find('.query-list-query-content').length;
expect(actual).toEqual(expected);
});
it('should show the go to dashboard button', () => {
const expected = 1;
const actual = wrapper.find('.query-list-query-link').length;
expect(actual).toEqual(expected);
});
});
});
describe('lifetime', () => {
describe('when clicked on the item', () => {
it('should render the expanded content', () => {
const { wrapper, props } = setup();
const expected = 1;
let actual;
wrapper.find('.query-list-header').simulate('click');
actual = wrapper.find('.query-list-expanded-content').length;
expect(actual).toEqual(expected);
});
describe('when clicking again', () => {
it('should hide the expanded content', () => {
const { wrapper, props } = setup();
const expected = 0;
let actual;
wrapper.find('.query-list-header').simulate('click');
wrapper.find('.query-list-header').simulate('click');
actual = wrapper.find('.query-list-expanded-content').length;
expect(actual).toEqual(expected);
});
});
});
});
});
import * as React from "react";
import { OverlayTrigger, Popover } from 'react-bootstrap';
import "./styles.scss";
export interface QueryListItemProps {
text: string;
url: string;
name: string;
}
type GoToDashboardLinkProps = {
url: string;
};
const QUERY_LABEL = "Query";
const MODE_LINK_TOOLTIP_TEXT = "View in Mode";
const LOADING_QUERY_MESSAGE = "Loading Query Component, please wait...";
const LazyComponent = React.lazy(() => import("./CodeBlock"));
const GoToDashboardLink = ({ url }: GoToDashboardLinkProps) => {
const popoverHoverFocus = (<Popover id="popover-trigger-hover-focus">{MODE_LINK_TOOLTIP_TEXT}</Popover>);
return (
<OverlayTrigger
trigger={["hover", "focus"]}
placement="top"
overlay={popoverHoverFocus}
>
<a
className="query-list-query-link"
href={url}
target="_blank"
rel="noopener noreferrer"
>
<svg className="icon" fill="none" viewBox="0 0 24 24">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9 3v2.75H5.75v12.5h12.5V15H21v6H3V3h6zm12 0v9.75h-2.75V7.892L10.544 15.6 8.4 13.456l7.707-7.707-4.607.001V3H21z"
/>
</svg>
</a>
</OverlayTrigger>
);
};
const QueryBlockShimmer = () => {
return (
<div className="shimmer-block">
<div className="shimmer-line shimmer-line--1 is-shimmer-animated" />
<div className="shimmer-line shimmer-line--2 is-shimmer-animated" />
<div className="shimmer-line shimmer-line--3 is-shimmer-animated" />
<div className="shimmer-line shimmer-line--4 is-shimmer-animated" />
<div className="shimmer-line shimmer-line--5 is-shimmer-animated" />
<div className="shimmer-line shimmer-line--6 is-shimmer-animated" />
</div>
);
}
const QueryListItem = ({ name, text, url }: QueryListItemProps) => {
const [isExpanded, setExpanded] = React.useState(false);
const toggleExpand = () => {
setExpanded(!isExpanded);
};
const key = `key:${name}`;
return (
<li
className="list-group-item query-list-item clickable"
role="tab"
id={key}
>
<a
className="query-list-header"
aria-expanded={isExpanded}
aria-controls={key}
role="button"
href="#"
onClick={toggleExpand}
>
<p className="query-list-item-name column-name">{name}</p>
</a>
{isExpanded && (
<div className="query-list-expanded-content">
<label className="query-list-query-label section-title">
{QUERY_LABEL}:
<div className="query-list-query-content">
<GoToDashboardLink url={url} />
<React.Suspense fallback={<QueryBlockShimmer />} >
<LazyComponent text={text} />
</React.Suspense>
</div>
</label>
</div>
)}
</li>
);
};
export default QueryListItem;
@import 'variables';
$min-item-height: 54px;
$query-content-padding: 12px;
$query-content-border-radius: $spacer-1/2;
$code-block-button-size: 32px;
$code-block-button-radius: 4px;
$code-block-button-icon-size: 16px;
$code-block-button-spacer: 12px;
$code-block-button-hover-opacity: 1;
$code-block-max-height: 400px;
$shimmer-loader-height: 200px;
$shimmer-loader-lines: 1, 2, 3, 4, 5, 6;
@mixin code-block-action-button-style {
max-width: $code-block-button-size;
width: $code-block-button-size;
max-height: $code-block-button-size;
height: $code-block-button-size;
background: $body-bg;
border: 2px solid $stroke;
box-sizing: border-box;
border-radius: $code-block-button-radius;
color: $text-secondary;
svg {
fill: $text-secondary;
}
&:hover {
opacity: $code-block-button-hover-opacity;
text-decoration: none;
}
}
.query-list-header {
display: block;
&:hover,
&:focus {
text-decoration: none;
}
}
.query-list-item-name {
margin: 0;
line-height: $min-item-height;
}
.query-list-expanded-content {
padding-bottom: $spacer-3;
}
.query-list-query-label {
color: $text-tertiary;
display: block;
}
.query-list-query-content {
border-radius: $query-content-border-radius;
border: 1px solid $stroke;
position: relative;
margin-top: $spacer-1/2;
> div {
padding: 0;
> span {
width: 100%;
> code {
width: 100%;
max-height: $code-block-max-height;
overflow-y: scroll;
padding: $query-content-padding !important;
}
}
}
button {
@include code-block-action-button-style;
display: none;
padding: 6px;
margin: $code-block-button-spacer (2 * $code-block-button-spacer) 0 0;
}
svg.icon {
width: $code-block-button-icon-size;
height: $code-block-button-icon-size;
}
&:hover {
button,
.query-list-query-link {
display: block;
}
}
.query-list-query-link {
@include code-block-action-button-style;
display: none;
line-height: $code-block-button-size;
text-align: center;
position: absolute;
top: $code-block-button-spacer;
z-index: 99999;
right: (3 * $code-block-button-spacer) + $code-block-button-size;
.icon {
margin-top: 6px;
}
}
}
.shimmer-block {
height: $shimmer-loader-height;
margin: $spacer-1;
.shimmer-line {
margin-bottom: $spacer-1;
height: ($shimmer-loader-height/6 - $spacer-1);
}
@each $line in $shimmer-loader-lines {
.shimmer-line--#{$line} {
width: percentage(random(100) / 100);
}
}
}
@import "variables";
$loading-duration: 1s;
$loading-curve: cubic-bezier(0.45, 0, 0.15, 1);
$shimmer-border-color: $gray20;
$shimmer-height: 22vh;
$shimmer-block-spacing: 16px;
@keyframes shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
.is-shimmer-animated {
animation: $loading-duration shimmer $loading-curve infinite;
background-image: linear-gradient(
to right,
$gray10 0%,
$gray10 33%,
$gray5 50%,
$gray10 67%,
$gray10 100%
);
background-repeat: no-repeat;
background-size: 300% 100%;
}
.shimmer-loader {
width: 100%;
border: 1px solid $shimmer-border-color;
......
......@@ -123,9 +123,9 @@ export class DashboardPage extends React.Component<DashboardPageProps, Dashboard
};
tabInfo.push({
content: <QueryList queries={ this.props.dashboard.query_names }/>,
content: <QueryList queries={ this.props.dashboard.queries }/>,
key: 'queries',
title: `Queries (${this.props.dashboard.query_names.length})`,
title: `Queries (${this.props.dashboard.queries.length})`,
});
return <TabsComponent tabs={ tabInfo } defaultTab={ "tables" } />;
......
......@@ -12,51 +12,71 @@ import {
SUBMIT_SUCCESS_MESSAGE,
} from '../constants';
const mockFormData = { key1: 'val1', key2: 'val2' };
// @ts-ignore: How to mock FormData without TypeScript error?
global.FormData = () => (mockFormData);
const globalAny:any = global;
describe('FeedbackForm', () => {
const setup = (propOverrides?: Partial<FeedbackFormProps>) => {
const props: FeedbackFormProps = {
sendState: SendingState.IDLE,
submitFeedback: jest.fn(),
resetFeedback: jest.fn(),
...propOverrides
};
const wrapper = shallow<RatingFeedbackForm>(<RatingFeedbackForm {...props} />);
return { props, wrapper };
const mockFormData = {
key1: 'val1',
key2: 'val2',
get: jest.fn(),
}
mockFormData.get.mockImplementation((val) => {
return mockFormData[val];
});
function formDataMock() {
this.append = jest.fn();
return mockFormData;
}
globalAny.FormData = formDataMock;
const setup = (propOverrides?: Partial<FeedbackFormProps>) => {
const props: FeedbackFormProps = {
sendState: SendingState.IDLE,
submitFeedback: jest.fn(),
resetFeedback: jest.fn(),
...propOverrides
};
const wrapper = shallow<RatingFeedbackForm>(<RatingFeedbackForm {...props} />);
return { props, wrapper };
};
describe('FeedbackForm', () => {
describe('submitForm', () => {
it('calls submitFeedback with formData', () => {
const { props, wrapper } = setup();
// @ts-ignore: mocked events throw type errors
wrapper.instance().submitForm({ preventDefault: jest.fn() });
expect(props.submitFeedback).toHaveBeenCalledWith(mockFormData);
});
});
describe('render', () => {
it('calls renderCustom if sendState is not WAITING or COMPLETE', () => {
const { props, wrapper } = setup();
const { wrapper } = setup();
const renderCustomSpy = jest.spyOn(wrapper.instance(), 'renderCustom');
wrapper.instance().render();
expect(renderCustomSpy).toHaveBeenCalled();
});
it('renders LoadingSpinner if sendState is WAITING', () => {
const { props, wrapper } = setup({sendState: SendingState.WAITING});
expect(wrapper.find('LoadingSpinner').exists()).toBeTruthy();
const { wrapper } = setup({sendState: SendingState.WAITING});
expect(wrapper.find(LoadingSpinner).exists()).toBeTruthy();
});
it('renders confirmation status message if sendState is COMPLETE', () => {
const { props, wrapper } = setup({sendState: SendingState.COMPLETE});
const { wrapper } = setup({sendState: SendingState.COMPLETE});
expect(wrapper.find('div.status-message').text()).toEqual(SUBMIT_SUCCESS_MESSAGE);
});
it('renders failure status message if sendState is ERROR', () => {
const { props, wrapper } = setup({sendState: SendingState.ERROR});
const { wrapper } = setup({sendState: SendingState.ERROR});
expect(wrapper.find('div.status-message').text()).toEqual(SUBMIT_FAILURE_MESSAGE);
});
});
......
......@@ -13,6 +13,8 @@ import {
} from '.';
import { NotificationType } from 'interfaces';
const globalAny:any = global;
const mockFormData = {
'key': 'val1',
'title': 'title',
......@@ -20,10 +22,16 @@ const mockFormData = {
'resource_name': 'resource name',
'resource_path': 'path',
'owners': 'test@test.com',
get: (key: string) => {
return mockFormData[key];
}
get: jest.fn(),
};
mockFormData.get.mockImplementation((val) => {
return mockFormData[val];
});
function formDataMock() {
this.append = jest.fn();
return mockFormData;
}
globalAny.FormData = formDataMock;
const mockCreateIssuePayload = {
key: 'key',
......@@ -41,9 +49,6 @@ const mockNotificationPayload = {
sender: 'user@email'
}
// @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>) => {
......@@ -66,23 +71,28 @@ describe('ReportTableIssue', () => {
describe('render', () => {
it('Renders loading spinner if not ready', () => {
const { props, wrapper } = setup();
const { wrapper } = setup();
expect(wrapper.find('.loading-spinner')).toBeTruthy();
});
it('Renders modal if open', () => {
const { props, wrapper } = setup();
const { wrapper } = setup();
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 { wrapper } = setup();
const previsOpenState = wrapper.state().isOpen;
wrapper.instance().toggle({currentTarget: {id: 'id',
nodeName: 'button' } });
expect(setStateSpy).toHaveBeenCalledWith({ isOpen: !previsOpenState });
});
});
......@@ -90,9 +100,11 @@ describe('ReportTableIssue', () => {
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(
mockCreateIssuePayload,
mockNotificationPayload);
......@@ -100,10 +112,12 @@ describe('ReportTableIssue', () => {
});
it ('calls sets isOpen to false', () => {
const { props, wrapper } = setup();
const { 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);
});
});
......@@ -121,12 +135,5 @@ describe('ReportTableIssue', () => {
expect(props.createIssue).toBeInstanceOf(Function);
});
});
describe('mapStateToProps', () => {
let result;
beforeAll(() => {
result = mapStateToProps(globalState);
});
});
});
});
......@@ -23,6 +23,7 @@ import {
SEND_SUCCESS_MESSAGE,
} from './constants'
const globalAny:any = global;
const mockFormData = {
'recipients': 'test1@test.com,test2@test.com',
'sender': 'test@test.com',
......@@ -34,8 +35,12 @@ const mockFormData = {
mockFormData.get.mockImplementation((val) => {
return mockFormData[val];
})
// @ts-ignore: How to mock FormData without TypeScript error?
global.FormData = () => (mockFormData);
function formDataMock() {
this.append = jest.fn();
return mockFormData;
}
globalAny.FormData = formDataMock;
describe('RequestMetadataForm', () => {
const setup = (propOverrides?: Partial<RequestMetadataProps>) => {
......@@ -93,17 +98,18 @@ describe('RequestMetadataForm', () => {
describe('renderFlashMessage', () => {
let wrapper;
let mockString;
let getFlashMessageStringMock;
beforeAll(() => {
wrapper = setup().wrapper;
mockString = 'I am the message'
getFlashMessageStringMock = jest.spyOn(wrapper.instance(), 'getFlashMessageString').mockImplementation(() => {
jest.spyOn(wrapper.instance(), 'getFlashMessageString').mockImplementation(() => {
return mockString;
});
});
it('renders a FlashMessage with correct props', () => {
const element = wrapper.instance().renderFlashMessage();
expect(element.props.iconClass).toEqual('icon-mail');
expect(element.props.message).toBe(mockString);
expect(element.props.onClose).toEqual(wrapper.instance().closeDialog);
......@@ -115,7 +121,9 @@ describe('RequestMetadataForm', () => {
const { props, wrapper } = setup();
const submitNotificationSpy = jest.spyOn(props, 'submitNotification');
const { cluster, database, schema, name } = props.tableMetadata;
wrapper.instance().submitNotification({ preventDefault: jest.fn() });
expect(submitNotificationSpy).toHaveBeenCalledWith(
mockFormData['recipients'].split(','),
mockFormData['sender'],
......@@ -132,7 +140,6 @@ describe('RequestMetadataForm', () => {
});
describe('render', () => {
let props;
let wrapper;
let element;
......@@ -140,9 +147,9 @@ describe('RequestMetadataForm', () => {
describe('no optional props', () => {
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
});
it('renders header title', () => {
element = wrapper.find('#request-metadata-title');
expect(element.find('h3').text()).toEqual(TITLE_TEXT);
......@@ -215,12 +222,12 @@ describe('RequestMetadataForm', () => {
describe('table description requested', () => {
beforeAll(() => {
const setupResult = setup({ requestMetadataType: RequestMetadataType.TABLE_DESCRIPTION });
props = setupResult.props;
wrapper = setupResult.wrapper;
});
it('renders checked table description checkbox', () => {
element = wrapper.find('#request-type-form-group');
const label = element.find('label').at(1);
expect(label.find('input').props().defaultChecked).toBe(true);
});
});
......@@ -228,18 +235,19 @@ describe('RequestMetadataForm', () => {
describe('column description requested', () => {
beforeAll(() => {
const setupResult = setup({ requestMetadataType: RequestMetadataType.COLUMN_DESCRIPTION, columnName: 'Test' });
props = setupResult.props;
wrapper = setupResult.wrapper;
});
it('renders checked column description checkbox', () => {
element = wrapper.find('#request-type-form-group');
const label = element.find('label').at(2);
expect(label.find('input').props().defaultChecked).toBe(true);
});
it('renders textarea for column request', () => {
element = wrapper.find('#additional-comments-form-group');
const textArea = element.find('textarea');
expect(textArea.text()).toEqual(`${COLUMN_REQUESTED_COMMENT_PREFIX}Test`);
expect(textArea.props().required).toBe(true);
expect(textArea.props().placeholder).toBe(COMMENT_PLACEHOLDER_COLUMN);
......@@ -250,7 +258,6 @@ describe('RequestMetadataForm', () => {
describe('when !this.props.requestIsOpen', () => {
beforeAll(() => {
const setupResult = setup({ requestIsOpen: false });
props = setupResult.props;
wrapper = setupResult.wrapper;
});
......@@ -262,6 +269,7 @@ describe('RequestMetadataForm', () => {
describe('when sendState is not SendingState.IDLE', () => {
let wrapper;
let renderFlashMessageMock;
beforeAll(() => {
wrapper = setup({ sendState: SendingState.WAITING, requestIsOpen: false }).wrapper;
renderFlashMessageMock = jest.spyOn(wrapper.instance(), 'renderFlashMessage');
......
......@@ -20,6 +20,7 @@ export interface TableListItemProps {
class TableListItem extends React.Component<TableListItemProps, {}> {
getLink = () => {
const { table, logging } = this.props;
return `/table_detail/${table.cluster}/${table.database}/${table.schema}/${table.name}`
+ `?index=${logging.index}&source=${logging.source}`;
};
......
import axios, { AxiosResponse } from 'axios';
import * as qs from 'simple-query-string';
import { QueryResource, ResourceType } from 'interfaces/Resources';
import { DashboardMetadata } from 'interfaces/Dashboard';
export type GetDashboardAPI = {
......@@ -11,13 +11,26 @@ export type GetDashboardAPI = {
const DASHBOARD_BASE = '/api/metadata/v0';
const getDashboardDataFromResponseData = (data) => {
// Adds the type to the query resource
data.queries = data.queries.map(item => ({
...item,
type: ResourceType.query,
}));
return data;
};
export function getDashboard(uri: string, index?: string, source?: string) {
const queryParams = qs.stringify({ index, source, uri });
return axios.get(`${DASHBOARD_BASE}/dashboard?${queryParams}`)
.then((response: AxiosResponse<GetDashboardAPI>) => {
const { data, status } = response;
return {
dashboard: data.dashboard,
dashboard: getDashboardDataFromResponseData(data.dashboard),
statusCode: status
};
})
......@@ -25,6 +38,7 @@ export function getDashboard(uri: string, index?: string, source?: string) {
const response = e.response;
const statusMessage = response ? (response.data ? response.data.msg : undefined) : undefined;
const statusCode = response ? (response.status || 500) : 500;
return Promise.reject({
statusCode,
statusMessage,
......
......@@ -54,7 +54,7 @@ export const initialDashboardState: DashboardMetadata = {
name: "",
owners: [],
product: '',
query_names: [],
queries: [],
recent_view_count: null,
tables: [],
tags: [],
......
......@@ -47,6 +47,7 @@ export const dashboardMetadata = {
],
product: 'mode',
query_names: ["query 1", "query 2"],
queries: [],
recent_view_count: 10,
tables: [],
tags: [],
......
import { User } from 'interfaces/User';
import { Tag } from 'interfaces/Tags';
import { TableReader } from 'interfaces/TableMetadata';
import { TableResource } from 'interfaces/Resources';
import { TableResource, QueryResource } from 'interfaces/Resources';
export interface DashboardMetadata {
badges: Tag[]
......@@ -18,7 +18,7 @@ export interface DashboardMetadata {
name: string;
owners: User[];
product: string;
query_names: string[];
queries: QueryResource[];
recent_view_count: number;
tables: TableResource[];
tags: Tag[];
......
......@@ -5,6 +5,7 @@ export enum ResourceType {
table = "table",
user = "user",
dashboard = "dashboard",
query = "query",
};
export const DEFAULT_RESOURCE_TYPE = ResourceType.table;
......@@ -46,6 +47,13 @@ export interface UserResource extends Resource, PeopleUser {
type: ResourceType.user;
}
export interface QueryResource extends Resource {
type: ResourceType.query;
name: string;
query_text: string;
url: string;
}
export interface ResourceDict<T> {
[ResourceType.table]: T;
[ResourceType.dashboard]?: T;
......
......@@ -8,11 +8,11 @@
"url": "https://github.com/lyft/amundsenfrontendlibrary"
},
"scripts": {
"build": "cross-env TS_NODE_PROJECT='tsconfig-for-webpack.json' webpack -p --progress --config webpack.prod.ts",
"dev-build": "cross-env TS_NODE_PROJECT='tsconfig-for-webpack.json' webpack -d --progress --config webpack.dev.ts",
"build": "cross-env TS_NODE_PROJECT='tsconfig-for-webpack.json' webpack --progress --config webpack.prod.ts",
"dev-build": "cross-env TS_NODE_PROJECT='tsconfig-for-webpack.json' webpack --progress --config webpack.dev.ts",
"test": "cross-env TZ=UTC jest --coverage --collectCoverageFrom=js/**/*.{js,jsx,ts,tsx}",
"test-nocov": "cross-env TZ=UTC jest",
"watch": "cross-env TS_NODE_PROJECT='tsconfig-for-webpack.json' webpack -d --progress --config webpack.dev.ts --watch",
"watch": "cross-env TS_NODE_PROJECT='tsconfig-for-webpack.json' webpack --progress --config webpack.dev.ts --watch",
"lint": "npm run eslint && npm run tslint",
"lint-fix": "npm run eslint-fix && npm run tslint-fix",
"eslint": "eslint --ignore-path=.eslintignore --ext .js,.jsx .",
......@@ -108,6 +108,7 @@
"react": "^16.13.1",
"react-avatar": "^2.5.1",
"react-bootstrap": "^0.32.4",
"react-code-blocks": "0.0.7",
"react-document-title": "^2.0.3",
"react-dom": "^16.13.1",
"react-js-pagination": "^3.0.3",
......
......@@ -4,8 +4,8 @@
"outDir": "./dist/",
"sourceMap": true,
"noImplicitAny": false,
"module": "es6",
"target": "es5",
"module": "esnext",
"target": "es2015",
"jsx": "react",
"typeRoots": [
"node_modules/@types"
......
......@@ -3,7 +3,7 @@ import * as fs from "fs";
import * as webpack from "webpack";
import HtmlWebpackPlugin from "html-webpack-plugin";
import MiniCssExtractPlugin from "mini-css-extract-plugin";
// import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer';
// import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import MomentLocalesPlugin from "moment-locales-webpack-plugin";
import {CleanWebpackPlugin} from "clean-webpack-plugin";
......@@ -52,7 +52,7 @@ const config: webpack.Configuration = {
]
},
output: {
publicPath: "/static/dist",
publicPath: "/static/dist/",
path: PATHS.dist,
filename: "[name].[contenthash].js"
},
......@@ -103,8 +103,8 @@ const config: webpack.Configuration = {
new MiniCssExtractPlugin({
filename: "[name].[contenthash].css"
}),
...htmlWebpackPluginConfig
// new BundleAnalyzerPlugin() // Uncomment to analyze the production bundle on local
...htmlWebpackPluginConfig,
// new BundleAnalyzerPlugin(), // Uncomment to analyze the production bundle on local
],
optimization: {
moduleIds: "hashed",
......
import merge from 'webpack-merge';
import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer';
// import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer';
import commonConfig from './webpack.common';
export default merge(commonConfig, {
mode: 'development',
devtool: 'inline-source-map',
plugins: [
// new BundleAnalyzerPlugin() // Uncomment to check the bundle size on dev
......
......@@ -3,6 +3,7 @@ import commonConfig from './webpack.common'
import TerserPlugin from 'terser-webpack-plugin';
export default merge(commonConfig, {
mode: 'production',
optimization: {
// minify code. also use parameters that improve build speed.
minimizer: [
......
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