Unverified Commit 77b45373 authored by Daniel's avatar Daniel Committed by GitHub

Merge table v2 into master (#332)

* Redesigned the `table_detail` page with a new layout.
* Added new components for FrequentUsers, Lineage, Writer, Source, ExploreButton, etc
* Added EditableSection to replace EntityCard and EntityCardSection with an updated interaction and design
* Updated WatermarkLabel to a new design
* Update Table Detail Columns (#311)
* Added a new 'ColumnStats' component
parent b2a747f8
@import 'variables'; @import 'variables';
.btn { .btn {
&.btn-primary, &.btn-primary,
&.btn-default { &.btn-default {
border-width: 2px;
font-weight: $font-weight-body-bold; font-weight: $font-weight-body-bold;
height: 32px; height: 32px;
padding: 6px 16px; padding: 6px 16px;
...@@ -12,7 +14,7 @@ ...@@ -12,7 +14,7 @@
height: 18px; height: 18px;
-webkit-mask-size: 18px; -webkit-mask-size: 18px;
mask-size: 18px; mask-size: 18px;
margin: 0 4px; margin: 0 4px 0 0;
vertical-align: top; vertical-align: top;
min-width: 18px; min-width: 18px;
width: 18px; width: 18px;
...@@ -21,13 +23,13 @@ ...@@ -21,13 +23,13 @@
&.btn-lg { &.btn-lg {
font-weight: $font-size-large; font-weight: $font-size-large;
height: 48px; height: 48px;
padding: 12px 24px; padding: 10px 16px;
img.icon { img.icon {
height: 24px; height: 24px;
-webkit-mask-size: 24px; -webkit-mask-size: 24px;
mask-size: 24px; mask-size: 24px;
margin: 0 4px; margin: 0 4px 0 0;
min-width: 24px; min-width: 24px;
width: 24px; width: 24px;
} }
...@@ -53,6 +55,16 @@ ...@@ -53,6 +55,16 @@
background-color: $btn-default-color; background-color: $btn-default-color;
} }
&.muted {
border-color: $divider;
color: $text-secondary;
padding: 0 8px;
.icon {
background-color: $text-secondary;
}
}
&:not(.disabled):hover, &:not(.disabled):hover,
&:not([disabled]):hover, &:not([disabled]):hover,
&:focus { &:focus {
......
@import 'variables'; @import 'variables';
.dropdown-menu { .dropdown {
box-shadow: 0 0 24px -2px rgba(0, 0, 0, .2); .dropdown-toggle {
border-radius: 5px; box-shadow: none;
border-style: none; }
padding: 0;
overflow: hidden;
li { .dropdown-menu {
&:hover { box-shadow: 0 4px 12px -3px rgba(17, 17, 31, 0.12);
background-color: $body-bg-secondary; border-radius: 4px;
} border: 1px solid $stroke;
a { padding: 0;
padding: 8px; overflow: hidden;
li {
&:hover { &:hover {
background-color: inherit; background-color: $body-bg-tertiary;
}
a {
padding: 8px;
&:hover {
background-color: inherit;
}
} }
} }
} }
......
...@@ -9,6 +9,16 @@ img.icon { ...@@ -9,6 +9,16 @@ img.icon {
mask-repeat: no-repeat; mask-repeat: no-repeat;
min-width: 24px; min-width: 24px;
width: 24px; width: 24px;
-webkit-mask-size: 24px 24px;
mask-size: 24px 24px;
&.icon-small {
height: 16px;
width: 16px;
min-width: 16px;
-webkit-mask-size: 16px 16px;
mask-size: 16px 16px;
}
&.icon-color { &.icon-color {
background-color: $icon-bg-brand; background-color: $icon-bg-brand;
...@@ -56,6 +66,11 @@ img.icon { ...@@ -56,6 +66,11 @@ img.icon {
mask-image: url('/static/images/icons/Down.svg'); mask-image: url('/static/images/icons/Down.svg');
} }
&.icon-edit {
-webkit-mask-image: url('/static/images/icons/Edit.svg');
mask-image: url('/static/images/icons/Edit.svg');
}
&.icon-help { &.icon-help {
-webkit-mask-image: url('/static/images/icons/Help-Circle.svg'); -webkit-mask-image: url('/static/images/icons/Help-Circle.svg');
mask-image: url('/static/images/icons/Help-Circle.svg'); mask-image: url('/static/images/icons/Help-Circle.svg');
...@@ -86,6 +101,11 @@ img.icon { ...@@ -86,6 +101,11 @@ img.icon {
mask-image: url('/static/images/icons/mail.svg'); mask-image: url('/static/images/icons/mail.svg');
} }
&.icon-plus {
-webkit-mask-image: url('/static/images/icons/plus.svg');
mask-image: url('/static/images/icons/plus.svg');
}
&.icon-plus-circle { &.icon-plus-circle {
-webkit-mask-image: url('/static/images/icons/Plus-Circle.svg'); -webkit-mask-image: url('/static/images/icons/Plus-Circle.svg');
mask-image: url('/static/images/icons/Plus-Circle.svg'); mask-image: url('/static/images/icons/Plus-Circle.svg');
......
...@@ -9,6 +9,14 @@ input { ...@@ -9,6 +9,14 @@ input {
color: $text-placeholder !important; color: $text-placeholder !important;
} }
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus,
&:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0px 1000px $white inset !important;
font-size: 20px !important;
}
&[type="radio"] { &[type="radio"] {
margin: 5px; margin: 5px;
} }
......
@import 'variables';
$header-height: 84px;
.resource-detail-layout {
min-width: 1048px;
.resource-header {
display: flex;
height: $header-height;
border-bottom: 2px solid $divider;
padding: 16px 24px 0;
.icon-header {
height: 32px;
width: 32px;
margin: 10px;
}
.header-section {
&.header-title {
flex-grow: 1;
overflow: hidden;
.header-title-text {
display: inline-block;
max-width: calc(100% - 100px);
}
}
&.header-links {
flex-shrink: 0;
> * {
margin-right: 16px;
}
}
&.header-buttons {
flex-shrink: 0;
> * {
margin-right: 8px;
&:last-child {
margin-right: 0;
}
}
}
}
}
// 630 | 414+ Flexible layout
.column-layout-1 {
display: flex;
height: calc(100% - #{$header-height});
> .left-panel {
> .banner {
border: 1px solid $stroke;
height: 40px;
margin: 24px 24px 0 24px;
padding: 8px;
}
border-right: 4px solid $divider;
flex-basis: 634px; // 630 + 4px border
flex-shrink: 0;
overflow-y: scroll;
padding: 0 24px;
}
> .right-panel {
flex-basis: 414px;
flex-grow: 1;
flex-shrink: 0;
overflow-y: scroll;
width: 0; // Required for text truncation
}
}
// 313 | 245 Fixed Layout
.column-layout-2 {
display: flex;
flex-basis: 100%;
> .left-panel {
flex-basis: 313px;
margin-right: 24px;
flex-direction: column;
}
> .right-panel {
flex-basis: 245px;
}
}
.left-panel,
.right-panel {
display: flex;
flex-direction: column;
}
}
...@@ -8,12 +8,9 @@ ...@@ -8,12 +8,9 @@
padding: 0; padding: 0;
&:hover { &:hover {
box-shadow: -2px 2px 4px 1px rgba(0, 0, 0, 0.16); box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.12), 0 2px 3px 0 rgba(0, 0, 0, 0.16);
cursor: pointer; cursor: pointer;
} z-index: 1;
&:hover + .list-group-item {
box-shadow: inset 0px 8px 6px -6px rgba(0, 0, 0, 0.16)
} }
} }
} }
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
&:focus, &:focus,
&:hover { &:hover {
background-color: $body-bg-secondary; background-color: $body-bg-tertiary;
color: $link-hover-color; color: $link-hover-color;
z-index: 0; z-index: 0;
} }
......
...@@ -4,18 +4,19 @@ ...@@ -4,18 +4,19 @@
.popover { .popover {
background-color: $body-bg-dark; background-color: $body-bg-dark;
border: 1px solid $body-bg-dark; border: 1px solid $body-bg-dark;
color: $text-light; color: $text-inverse;
font-size: 12px; font-size: 12px;
padding: 5px; padding: 5px;
} }
.popover-title { .popover-title {
border-bottom: 1px solid $stroke; border-bottom: 1px solid $stroke;
color: $text-light; color: $text-inverse;
font-size: 14px; font-size: 14px;
padding: 5px; padding: 5px;
} }
.popover-content { .popover-content {
padding: 2px 5px; padding: 2px 5px;
word-break: break-word;
} }
.popover.right .arrow:after { .popover.right .arrow:after {
border-right-color: $body-bg-dark; border-right-color: $body-bg-dark;
......
...@@ -4,11 +4,14 @@ ...@@ -4,11 +4,14 @@
.text-left { text-align: left; } .text-left { text-align: left; }
.text-right { text-align: right; } .text-right { text-align: right; }
h1, h2, h3, h4, h5, h6 {
margin: 0;
}
h1, h2, h3, h1, h2, h3,
.h1, .h2, .h3 { .h1, .h2, .h3 {
color: $text-primary; color: $text-primary;
font-family: $font-family-header; font-family: $font-family-header;
margin: 0;
} }
h1, h1,
...@@ -119,8 +122,8 @@ body { ...@@ -119,8 +122,8 @@ body {
font-weight: $font-weight-body-bold; font-weight: $font-weight-body-bold;
} }
.column-type { .column-name {
color: $brand-color-3; color: $column-name-color;
font-size: 13px; font-size: 13px;
font-family: $font-family-monospace; font-family: $font-family-monospace;
} }
......
...@@ -11,10 +11,10 @@ $brand-color-5: $indigo80 !default; ...@@ -11,10 +11,10 @@ $brand-color-5: $indigo80 !default;
$brand-primary: $brand-color-4 !default; $brand-primary: $brand-color-4 !default;
/* Scaffolding */ /* Scaffolding */
$body-bg: $white !default; $body-bg: $white !default;
$body-bg-secondary: $gray5 !default; $body-bg-secondary: $gray0 !default;
$body-bg-tertiary: $gray5 !default;
$body-bg-dark: $gray100 !default; $body-bg-dark: $gray100 !default;
$divider: $gray15 !default; $divider: $gray15 !default;
$stroke: $gray20 !default; $stroke: $gray20 !default;
...@@ -24,9 +24,12 @@ $stroke-focus: $gray60 !default; ...@@ -24,9 +24,12 @@ $stroke-focus: $gray60 !default;
// Typography // Typography
$text-primary: $gray100 !default; $text-primary: $gray100 !default;
$text-secondary: $gray60 !default; $text-secondary: $gray60 !default;
$text-light: $white !default; $text-secondary: $gray60 !default;
$text-placeholder: $gray40; $text-tertiary: $gray40 !default;
$text-placeholder: $gray40 !default;
$text-inverse: $white !default;
$column-name-color: $indigo70;
$link-color: $brand-color-4; $link-color: $brand-color-4;
$link-hover-color: $brand-color-5; $link-hover-color: $brand-color-5;
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
@import 'icons'; @import 'icons';
@import 'inputs'; @import 'inputs';
@import 'labels'; @import 'labels';
@import 'layouts';
@import 'list-group'; @import 'list-group';
@import 'pagination'; @import 'pagination';
@import 'popovers'; @import 'popovers';
......
<?xml version="1.0" encoding="UTF-8"?> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/><path d="M0 0h24v24H0z" fill="none"/></svg>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Edit</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M17,14 C17,13.4477153 17.4477153,13 18,13 C18.5522847,13 19,13.4477153 19,14 L19,17 C19,18.5522847 17.5522847,20 16,20 L7,20 C5.41405389,20 4,18.644016 4,17 L4,7.98046875 C4,6.44299261 5.45244866,5 7,5 L10,5 C10.5522847,5 11,5.44771525 11,6 C11,6.55228475 10.5522847,7 10,7 L7,7 C6.55410442,7 6,7.55049697 6,7.98046875 L6,17 C6,17.5202489 6.50029637,18 7,18 L16,18 C16.4477153,18 17,17.4477153 17,17 L17,14 Z M17.9289109,5.91662172 C18.3682507,6.35596155 18.3682507,7.06827215 17.9289109,7.50761198 L10.1018764,15.3159836 L8.45489769,15.8649765 C8.25841894,15.9304695 8.04604895,15.8242845 7.98055604,15.6278057 C7.95489769,15.5508307 7.95489769,15.4676099 7.98055604,15.3906349 L8.559746,13.6947963 L16.3379206,5.91662172 C16.7772604,5.47728189 17.489571,5.47728189 17.9289109,5.91662172 Z" id="path-1"></path>
</defs>
<g id="Edit" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use id="Combined-Shape" fill="#11111F" fill-rule="nonzero" xlink:href="#path-1"></use>
<g id="Tint/Black" mask="url(#mask-2)">
<g transform="translate(2.000000, 2.000000)"></g>
</g>
</g>
</svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-plus"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
\ No newline at end of file
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
bottom: 0; bottom: 0;
height: $footer-height; height: $footer-height;
width: 100%; width: 100%;
z-index: 10;
} }
.phantom-div { .phantom-div {
......
...@@ -131,3 +131,26 @@ ...@@ -131,3 +131,26 @@
} }
} }
} }
.avatar-dropdown {
border-style: none;
padding: 0 !important;
border-radius: 50%;
}
.profile-menu {
$profile-menu-width: 200px;
width: $profile-menu-width;
.profile-menu-header {
padding: 16px 16px 0 16px;
}
li {
padding: 16px;
a {
padding: 0;
}
}
}
...@@ -150,14 +150,6 @@ describe('NavBar', () => { ...@@ -150,14 +150,6 @@ describe('NavBar', () => {
}) })
}); });
it('renders a Link to the user profile if `indexUsers` is enabled', () => {
expect(wrapper.find('#nav-bar-avatar-link').exists()).toBe(true)
expect(wrapper.find('#nav-bar-avatar-link').props()).toMatchObject({
to: `/user/${props.loggedInUser.user_id}?source=navbar`
});
});
describe('if indexUsers is enabled', () => { describe('if indexUsers is enabled', () => {
it('renders Avatar for loggedInUser inside of user dropdown', () => { it('renders Avatar for loggedInUser inside of user dropdown', () => {
expect(wrapper.find(Dropdown).find(Dropdown.Toggle).find(Avatar).props()).toMatchObject({ expect(wrapper.find(Dropdown).find(Dropdown.Toggle).find(Avatar).props()).toMatchObject({
......
import * as React from 'react'; import * as React from 'react';
import DetailListItem from './DetailListItem'; import ColumnListItem from '../ColumnListItem';
import { TableColumn } from 'interfaces'; import { TableColumn } from 'interfaces';
interface DetailListProps { import "./styles.scss";
interface ColumnListProps {
columns?: TableColumn[]; columns?: TableColumn[];
} }
const DetailList: React.SFC<DetailListProps> = ({ columns }) => { // TODO - convert into a component for easier testing
const ColumnList: React.SFC<ColumnListProps> = ({ columns }) => {
if (columns.length < 1) { if (columns.length < 1) {
return (<div />); return (<div />);
// ToDo: return No Results Message // ToDo: return No Results Message
} }
const columnList = columns.map((entry, index) => const columnList = columns.map((entry, index) =>
<DetailListItem <ColumnListItem
key={`column:${index}`} key={`column:${index}`}
data={ entry } data={ entry }
index={ index } index={ index }
/>); />);
return ( return (
<ul className="list-group"> <ul className="column-list list-group">
{ columnList } { columnList }
</ul> </ul>
); );
}; };
DetailList.defaultProps = { ColumnList.defaultProps = {
columns: [] as TableColumn[], columns: [] as TableColumn[],
}; };
export default DetailList; export default ColumnList;
import * as React from 'react';
import { Dropdown, MenuItem } from 'react-bootstrap';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import ColumnDescEditableText from 'components/TableDetail/ColumnDescEditableText';
import ColumnStats from 'components/TableDetail/ColumnStats';
import { notificationsEnabled } from 'config/config-utils';
import { openRequestDescriptionDialog } from 'ducks/notification/reducer';
import { OpenRequestAction } from 'ducks/notification/types';
import { logClick } from 'ducks/utilMethods';
import { RequestMetadataType, TableColumn } from 'interfaces';
import './styles.scss';
import { EditableSection } from 'components/TableDetail/EditableSection';
interface DispatchFromProps {
openRequestDescriptionDialog: (requestMetadataType: RequestMetadataType, columnName: string) => OpenRequestAction;
}
interface OwnProps {
data: TableColumn;
index: number;
}
interface ColumnListItemState {
isExpanded: boolean;
}
export type ColumnListItemProps = DispatchFromProps & OwnProps;
export class ColumnListItem extends React.Component<ColumnListItemProps, ColumnListItemState> {
constructor(props) {
super(props);
this.state = {
isExpanded: false
};
}
toggleExpand = (e) => {
if (!this.state.isExpanded) {
const metadata = this.props.data;
logClick(e, {
target_id: `column::${metadata.name}`,
target_type: 'column stats',
label: `${metadata.name} ${metadata.type}`,
});
}
this.setState({ isExpanded: !this.state.isExpanded });
};
openRequest = () => {
this.props.openRequestDescriptionDialog(RequestMetadataType.COLUMN_DESCRIPTION, this.props.data.name);
};
stopPropagation = (e) => {
e.stopPropagation();
};
render() {
const metadata = this.props.data;
return (
<li className="list-group-item" onClick={ this.toggleExpand }>
<div className="column-list-item">
<section className="column-header">
<div className="column-details truncated">
<div className="column-name">
{ metadata.name }
</div>
{
!this.state.isExpanded &&
<div className="column-desc body-3 truncated">
{ metadata.description }
</div>
}
</div>
<div className="resource-type">
{ metadata.type ? metadata.type.toLowerCase() : 'null' }
</div>
<div className="badges">
{/* Placeholder */}
</div>
<div className="actions">
{
// TODO - Make this dropdown into a separate component
notificationsEnabled() &&
<Dropdown id={`detail-list-item-dropdown:${this.props.index}`}
onClick={ this.stopPropagation }
pullRight={ true }
className="column-dropdown">
<Dropdown.Toggle noCaret={ true }>
<img className="icon icon-more"/>
</Dropdown.Toggle>
<Dropdown.Menu>
<MenuItem onClick={ this.openRequest }>
Request Column Description
</MenuItem>
</Dropdown.Menu>
</Dropdown>
}
</div>
</section>
{
this.state.isExpanded &&
<section className="expanded-content">
<div className="stop-propagation" onClick={ this.stopPropagation }>
<EditableSection title="Description">
<ColumnDescEditableText
columnIndex={ this.props.index }
editable={ metadata.is_editable }
value={ metadata.description }
/>
</EditableSection>
</div>
<ColumnStats stats={ metadata.stats } />
</section>
}
</div>
</li>
);
}
}
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ openRequestDescriptionDialog }, dispatch);
};
export default connect<{}, DispatchFromProps, OwnProps>(null, mapDispatchToProps)(ColumnListItem);
@import 'variables';
.list-group-item .column-list-item {
padding: 16px 24px;
.column-header {
display: flex;
height: 38px;
.column-details {
flex-basis: 50%;
flex-grow: 1;
padding-right: 16px;
}
.resource-type,
.badges,
.actions {
display: flex;
align-items: center;
}
.resource-type {
flex-basis: 100px;
}
.badges {
// placeholder
}
.actions {
.column-dropdown {
&.open {
background-color: $body-bg-tertiary;
.icon {
background-color: $icon-bg-dark;
}
}
.dropdown-toggle {
border: none;
border-radius: 4px;
height: 32px;
width: 32px;
padding: 4px;
.icon {
background-color: $icon-bg;
height: 22px;
-webkit-mask-size: 22px;
mask-size: 22px;
width: 22px;
margin: 0;
}
&:hover,
&:focus {
background-color: $body-bg-secondary;
.icon {
background-color: $icon-bg-dark;
}
}
}
}
}
}
.expanded-content {
margin-top: -16px;
}
.stop-propagation {
cursor: default;
}
}
import * as React from 'react';
import { shallow } from 'enzyme';
import ColumnDescEditableText from 'components/TableDetail/ColumnDescEditableText';
import { ColumnListItem, ColumnListItemProps, mapDispatchToProps } from 'components/TableDetail/ColumnListItem';
import ColumnStats from 'components/TableDetail/ColumnStats';
import AppConfig from 'config/config';
import * as UtilMethods from 'ducks/utilMethods';
import { RequestMetadataType } from 'interfaces/Notifications';
const logClickSpy = jest.spyOn(UtilMethods, 'logClick');
logClickSpy.mockImplementation(() => null);
describe('ColumnListItem', () => {
const setup = (propOverrides?: Partial<ColumnListItemProps>) => {
const props = {
data: {
name: "test_column_name",
description: "This is a test description of this table",
is_editable: true,
type: "varchar(32)",
stats: [{ end_epoch: 1571616000, start_epoch: 1571616000, stat_type: "count", stat_val: "12345" }]
},
index: 0,
openRequestDescriptionDialog: jest.fn(),
...propOverrides,
};
const wrapper = shallow<ColumnListItem>(<ColumnListItem {...props} />);
return { wrapper, props };
};
const { wrapper, props } = setup();
const instance = wrapper.instance();
const setStateSpy = jest.spyOn(instance, 'setState');
describe('toggleExpand', () => {
it('calls the logClick when isExpanded is false', () => {
instance.setState({ isExpanded: false });
logClickSpy.mockClear();
instance.toggleExpand(null);
expect(logClickSpy).toHaveBeenCalled();
});
it('does not calls the logClick when isExpanded is true', () => {
instance.setState({ isExpanded: true });
logClickSpy.mockClear();
instance.toggleExpand(null);
expect(logClickSpy).not.toHaveBeenCalled();
});
it('turns expanded state to the opposite state', () => {
setStateSpy.mockClear();
const isExpanded = instance.state.isExpanded;
instance.toggleExpand(null);
expect(setStateSpy).toHaveBeenCalledWith({ isExpanded: !isExpanded });
});
});
describe('openRequest', () => {
it('calls openRequestDescriptionDialog', () => {
const openRequestDescriptionDialogSpy = jest.spyOn(props, 'openRequestDescriptionDialog');
instance.openRequest();
expect(openRequestDescriptionDialogSpy).toHaveBeenCalledWith(RequestMetadataType.COLUMN_DESCRIPTION, props.data.name);
});
});
describe('render', () => {
it('renders a list-group-item with toggle expand attached', () => {
const listGroupItem = wrapper.find(".list-group-item");
expect(listGroupItem.props()).toMatchObject({
onClick: instance.toggleExpand
});
});
it('renders the column name correctly', () => {
const columnName = wrapper.find('.column-name');
expect(columnName.text()).toBe(props.data.name)
});
it('renders the column description when not expanded', () => {
instance.setState({ isExpanded: false });
const columnDesc = wrapper.find('.column-desc');
expect(columnDesc.text()).toBe(props.data.description);
});
it('renders the correct resource type', () => {
const resourceType = wrapper.find('.resource-type');
expect(resourceType.text()).toBe(props.data.type.toLowerCase());
});
it('renders the dropdown when notifications is enabled', () => {
AppConfig.mailClientFeatures.notificationsEnabled = true;
const { wrapper, props } = setup();
expect(wrapper.find(".column-dropdown").exists()).toBe(true);
});
it('does not render the dropdown when notifications is disabled', () => {
AppConfig.mailClientFeatures.notificationsEnabled = false;
const { wrapper, props } = setup();
expect(wrapper.find(".column-dropdown").exists()).toBe(false);
});
it('renders column stats and editable text when expanded', () => {
instance.setState({ isExpanded: true });
const newWrapper = shallow(instance.render());
expect(newWrapper.find('.expanded-content').exists()).toBe(true);
expect(newWrapper.find(ColumnDescEditableText).exists()).toBe(true);
expect(newWrapper.find(ColumnStats).exists()).toBe(true);
});
it('does not render column stats when not expanded', () => {
instance.setState({ isExpanded: false });
const newWrapper = shallow(instance.render());
expect(newWrapper.find('.expanded-content').exists()).toBe(false);
expect(newWrapper.find(ColumnDescEditableText).exists()).toBe(false);
expect(newWrapper.find(ColumnStats).exists()).toBe(false);
});
});
});
describe('mapDispatchToProps', () => {
let dispatch;
let result;
beforeAll(() => {
dispatch = jest.fn(() => Promise.resolve());
result = mapDispatchToProps(dispatch);
});
it('sets openRequestDescriptionDialog on the props', () => {
expect(result.openRequestDescriptionDialog).toBeInstanceOf(Function);
});
});
import * as React from 'react';
import * as moment from 'moment-timezone';
import { TableColumnStats } from 'interfaces/index';
import './styles.scss';
export interface ColumnStatsProps {
stats: TableColumnStats[];
}
export class ColumnStats extends React.Component<ColumnStatsProps> {
constructor(props) {
super(props);
}
formatDate = (unixEpochSeconds) => {
return moment(unixEpochSeconds * 1000).format("MMM DD, YYYY");
};
getStatsInfoText = (startEpoch: number, endEpoch: number) => {
const startDate = startEpoch ? this.formatDate(startEpoch) : null;
const endDate = endEpoch ? this.formatDate(endEpoch) : null;
let infoText = 'Stats reflect data collected';
if (startDate && endDate) {
if (startDate === endDate) {
infoText = `${infoText} on ${startDate} only. (daily partition)`;
} else {
infoText = `${infoText} between ${startDate} and ${endDate}.`;
}
} else {
infoText = `${infoText} over a recent period of time.`;
}
return infoText;
};
renderColumnStat = (entry: TableColumnStats) => {
return (
<div className="column-stat-row" key={entry.stat_type}>
<div className="stat-name body-3">
{ entry.stat_type.toUpperCase() }
</div>
<div className="stat-value">
{ entry.stat_val }
</div>
</div>
)
};
render = () => {
const { stats } = this.props;
if (stats.length === 0) {
return null;
}
// TODO - Move map statements to separate functions for better testing
const startEpoch = Math.min(...stats.map(s => s.start_epoch));
const endEpoch = Math.max(...stats.map(s => s.end_epoch));
return (
<section className="column-stats">
<div className="stat-collection-info">
<span className="title-3">Column Statistics&nbsp;</span>
{ this.getStatsInfoText(startEpoch, endEpoch) }
</div>
<div className="column-stats-table">
<div className="column-stats-column">
{
stats.map((stat, index) => {
if (index % 2 === 0) {
return this.renderColumnStat(stat);
}
})
}
</div>
<div className="column-stats-column">
{
this.props.stats.map((stat, index) => {
if (index % 2 === 1) {
return this.renderColumnStat(stat);
}
})
}
</div>
</div>
</section>
);
};
}
export default ColumnStats;
@import 'variables';
.column-stats {
.stat-collection-info {
color: $text-secondary;
font-style: italic;
margin: 8px 0;
}
.column-stats-table {
display: flex;
max-width: 600px;
.column-stats-column {
flex-basis: 50%;
flex-grow: 1;
&:first-child {
margin-right: 16px;
}
.column-stat-row {
display: flex;
border-top: 1px solid $divider;
line-height: 32px;
&:nth-child(odd) {
background-color: $body-bg-secondary;
}
&:last-child {
border-bottom: 1px solid $divider;
}
.stat-name {
flex: 100px 1 0;
padding-left: 4px;
text-transform: lowercase;
}
.stat-value {
flex: 100px 1 1;
padding-right: 4px;
}
}
}
}
}
import * as React from 'react';
import { shallow } from 'enzyme';
import { ColumnStats, ColumnStatsProps } from '../';
describe('ColumnStats', () => {
const setup = (propOverrides?: Partial<ColumnStatsProps>) => {
const props = {
stats: [
{ end_epoch: 1571616000, start_epoch: 1571616000, stat_type: "count", stat_val: "12345" },
{ end_epoch: 1571616000, start_epoch: 1571616000, stat_type: "count_null", stat_val: "123" },
{ end_epoch: 1571616000, start_epoch: 1571616000, stat_type: "count_distinct", stat_val: "22" },
{ end_epoch: 1571616000, start_epoch: 1571616000, stat_type: "count_zero", stat_val: "44" },
{ end_epoch: 1571616000, start_epoch: 1571616000, stat_type: "max", stat_val: "1237466454" },
{ end_epoch: 1571616000, start_epoch: 1571616000, stat_type: "min", stat_val: "856" },
{ end_epoch: 1571616000, start_epoch: 1571616000, stat_type: "avg", stat_val: "2356575" },
{ end_epoch: 1571616000, start_epoch: 1571616000, stat_type: "stddev", stat_val: "1234563" },
],
...propOverrides,
};
const wrapper = shallow<ColumnStats>(<ColumnStats {...props} />);
return { props, wrapper };
};
const { wrapper, props } = setup();
const instance = wrapper.instance();
describe('formatDate', () => {
it('formats a date in the correct format', () => {
const epochTime = 1571616000;
const expectedDateString = "Oct 21, 2019";
expect(instance.formatDate(epochTime)).toBe(expectedDateString);
});
});
describe('getStatsInfoText', () => {
it('generates correct info text for a daily partition', () => {
const startEpoch = 1568160000;
const endEpoch = 1568160000;
const expectedInfoText = `Stats reflect data collected on Sep 11, 2019 only. (daily partition)`;
expect(instance.getStatsInfoText(startEpoch, endEpoch)).toBe(expectedInfoText);
});
it('generates correct info text for a date range', () => {
const startEpoch = 1568160000;
const endEpoch = 1571616000;
const expectedInfoText = `Stats reflect data collected between Sep 11, 2019 and Oct 21, 2019.`;
expect(instance.getStatsInfoText(startEpoch, endEpoch)).toBe(expectedInfoText);
});
it('generates correct when no dates are given', () => {
const expectedInfoText = `Stats reflect data collected over a recent period of time.`;
expect(instance.getStatsInfoText(null, null)).toBe(expectedInfoText);
});
});
describe('renderColumnStat', () => {
it('renders a single column stat', () => {
const columnStat = { end_epoch: 1571616000, start_epoch: 1571616000, stat_type: "count", stat_val: "12345" };
const expectedStatType = columnStat.stat_type.toUpperCase();
const expectedStatValue = columnStat.stat_val;
const result = shallow(instance.renderColumnStat(columnStat));
expect(result.find('.stat-name').text()).toBe(expectedStatType)
expect(result.find('.stat-value').text()).toBe(expectedStatValue)
});
});
describe('render', () => {
it('calls the appropriate functions', () => {
const getStatsInfoTextSpy = jest.spyOn(instance, 'getStatsInfoText');
instance.render();
expect(getStatsInfoTextSpy).toHaveBeenCalledWith(1571616000, 1571616000);
});
it('calls renderColumnStat with all of the stats', () => {
const renderColumnStatSpy = jest.spyOn(instance, 'renderColumnStat');
instance.render();
props.stats.forEach((stat) => {
expect(renderColumnStatSpy).toHaveBeenCalledWith(stat);
});
});
});
});
import * as React from 'react'; import * as React from 'react';
import { connect } from 'react-redux'; import { Modal, OverlayTrigger, Popover } from 'react-bootstrap';
import { Button, Modal, OverlayTrigger, Popover, Table } from 'react-bootstrap';
import Linkify from 'react-linkify' import Linkify from 'react-linkify'
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { getPreviewData } from 'ducks/tableMetadata/reducer';
import { GlobalState } from 'ducks/rootReducer'; import { GlobalState } from 'ducks/rootReducer';
import { logClick } from 'ducks/utilMethods'; import { logClick } from 'ducks/utilMethods';
import { PreviewData, PreviewQueryParams, TableMetadata } from 'interfaces';
import { PreviewData } from 'interfaces';
// TODO: Use css-modules instead of 'import' // TODO: Use css-modules instead of 'import'
import './styles.scss'; import './styles.scss';
...@@ -24,18 +24,21 @@ enum LoadingStatus { ...@@ -24,18 +24,21 @@ enum LoadingStatus {
export interface StateFromProps { export interface StateFromProps {
previewData: PreviewData; previewData: PreviewData;
status: LoadingStatus; status: LoadingStatus;
tableData: TableMetadata;
}
export interface DispatchFromProps {
getPreviewData: (queryParams: PreviewQueryParams) => void;
} }
export interface ComponentProps { export interface ComponentProps {
modalTitle: string; modalTitle: string;
} }
type DataPreviewButtonProps = StateFromProps & ComponentProps; type DataPreviewButtonProps = StateFromProps & DispatchFromProps & ComponentProps;
interface DataPreviewButtonState { interface DataPreviewButtonState {
status: LoadingStatus;
showModal: boolean; showModal: boolean;
previewData: PreviewData;
} }
export function getStatusFromCode(httpErrorCode: number) { export function getStatusFromCode(httpErrorCode: number) {
...@@ -61,20 +64,21 @@ export function getStatusFromCode(httpErrorCode: number) { ...@@ -61,20 +64,21 @@ export function getStatusFromCode(httpErrorCode: number) {
} }
export class DataPreviewButton extends React.Component<DataPreviewButtonProps, DataPreviewButtonState> { export class DataPreviewButton extends React.Component<DataPreviewButtonProps, DataPreviewButtonState> {
static getDerivedStateFromProps(nextProps, prevState) {
const { previewData, status } = nextProps;
return { ...prevState, previewData, status };
}
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
status: LoadingStatus.LOADING,
showModal: false, showModal: false,
previewData: {}, };
} }
componentDidMount() {
const tableData = this.props.tableData;
this.props.getPreviewData({
database: tableData.database,
schema: tableData.schema,
tableName: tableData.table_name,
});
} }
handleClose = () => { handleClose = () => {
...@@ -99,9 +103,9 @@ export class DataPreviewButton extends React.Component<DataPreviewButtonProps, D ...@@ -99,9 +103,9 @@ export class DataPreviewButton extends React.Component<DataPreviewButtonProps, D
} }
renderModalBody() { renderModalBody() {
const previewData = this.state.previewData; const previewData = this.props.previewData;
if (this.state.status === LoadingStatus.SUCCESS) { if (this.props.status === LoadingStatus.SUCCESS) {
if (!previewData.columns || !previewData.data || previewData.columns.length === 0 || previewData.data.length === 0) { if (!previewData.columns || !previewData.data || previewData.columns.length === 0 || previewData.data.length === 0) {
return ( return (
<div> <div>
...@@ -140,7 +144,7 @@ export class DataPreviewButton extends React.Component<DataPreviewButtonProps, D ...@@ -140,7 +144,7 @@ export class DataPreviewButton extends React.Component<DataPreviewButtonProps, D
} }
if (this.state.status === LoadingStatus.UNAUTHORIZED) { if (this.props.status === LoadingStatus.UNAUTHORIZED) {
return ( return (
<div> <div>
<Linkify>{previewData.error_text}</Linkify> <Linkify>{previewData.error_text}</Linkify>
...@@ -152,7 +156,7 @@ export class DataPreviewButton extends React.Component<DataPreviewButtonProps, D ...@@ -152,7 +156,7 @@ export class DataPreviewButton extends React.Component<DataPreviewButtonProps, D
} }
renderPreviewButton() { renderPreviewButton() {
const previewData = this.state.previewData; const previewData = this.props.previewData;
// Based on the state, the preview button will show different things. // Based on the state, the preview button will show different things.
let buttonText = 'Loading...'; let buttonText = 'Loading...';
...@@ -161,25 +165,25 @@ export class DataPreviewButton extends React.Component<DataPreviewButtonProps, D ...@@ -161,25 +165,25 @@ export class DataPreviewButton extends React.Component<DataPreviewButtonProps, D
let popoverText = 'The data preview is loading'; let popoverText = 'The data preview is loading';
// TODO: Setting hardcoded strings that should be customizable/translatable // TODO: Setting hardcoded strings that should be customizable/translatable
switch (this.state.status) { switch (this.props.status) {
case LoadingStatus.SUCCESS: case LoadingStatus.SUCCESS:
case LoadingStatus.UNAUTHORIZED: case LoadingStatus.UNAUTHORIZED:
buttonText = 'Preview Data'; buttonText = 'Preview';
iconClass = 'icon-preview'; iconClass = 'icon-preview';
disabled = false; disabled = false;
break; break;
case LoadingStatus.FORBIDDEN: case LoadingStatus.FORBIDDEN:
buttonText = 'Preview Forbidden'; buttonText = 'Preview';
iconClass = 'icon-preview'; iconClass = 'icon-preview';
popoverText = previewData.error_text || 'User is forbidden to preview this data'; popoverText = previewData.error_text || 'User is forbidden to preview this data';
break; break;
case LoadingStatus.UNAVAILABLE: case LoadingStatus.UNAVAILABLE:
buttonText = 'Preview Unavailable'; buttonText = 'Preview';
iconClass = 'icon-preview'; iconClass = 'icon-preview';
popoverText = 'This feature has not been configured by your service'; popoverText = 'This feature has not been configured by your service';
break; break;
case LoadingStatus.ERROR: case LoadingStatus.ERROR:
buttonText = 'Preview Unavailable'; buttonText = 'Preview';
iconClass = 'icon-preview'; iconClass = 'icon-preview';
popoverText = previewData.error_text || 'An internal server error has occurred, please contact service admin'; popoverText = previewData.error_text || 'An internal server error has occurred, please contact service admin';
break; break;
...@@ -190,12 +194,11 @@ export class DataPreviewButton extends React.Component<DataPreviewButtonProps, D ...@@ -190,12 +194,11 @@ export class DataPreviewButton extends React.Component<DataPreviewButtonProps, D
const previewButton = ( const previewButton = (
<button <button
id="data-preview-button" id="data-preview-button"
className="btn btn-default btn-block" className="btn btn-default btn-lg"
disabled={disabled} disabled={ disabled }
onClick={this.handleClick} onClick={ this.handleClick }
> >
<img className={"icon icon-color " + iconClass} /> { buttonText }
<span>{buttonText}</span>
</button> </button>
); );
...@@ -213,32 +216,31 @@ export class DataPreviewButton extends React.Component<DataPreviewButtonProps, D ...@@ -213,32 +216,31 @@ export class DataPreviewButton extends React.Component<DataPreviewButtonProps, D
<OverlayTrigger <OverlayTrigger
trigger={['hover', 'focus']} trigger={['hover', 'focus']}
placement='top' placement='top'
delayHide={200} delayHide={ 200 }
overlay={popoverHover}> overlay={ popoverHover }>
<div className="overlay-trigger"> {/* Disabled buttons don't trigger hover/focus events so we need a wrapper */}
{previewButton} <div className="overlay-trigger">
</div> { previewButton }
</div>
</OverlayTrigger> </OverlayTrigger>
) )
} }
render() { render() {
// else render button that triggers the preview data modal
return ( return (
<div className="preview-data"> <>
{this.renderPreviewButton()} { this.renderPreviewButton() }
<Modal show={ this.state.showModal } onHide={ this.handleClose }>
<Modal show={this.state.showModal} onHide={this.handleClose}> <Modal.Header className="text-center" closeButton={ true }>
<Modal.Header className="text-center" closeButton={true}>
<Modal.Title> <Modal.Title>
{this.props.modalTitle} { this.props.modalTitle }
</Modal.Title> </Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
{this.renderModalBody()} { this.renderModalBody() }
</Modal.Body> </Modal.Body>
</Modal> </Modal>
</div> </>
) )
} }
} }
...@@ -247,7 +249,12 @@ export const mapStateToProps = (state: GlobalState) => { ...@@ -247,7 +249,12 @@ export const mapStateToProps = (state: GlobalState) => {
return { return {
previewData: state.tableMetadata.preview.data, previewData: state.tableMetadata.preview.data,
status: getStatusFromCode(state.tableMetadata.preview.status), status: getStatusFromCode(state.tableMetadata.preview.status),
tableData: state.tableMetadata.tableData,
}; };
}; };
export default connect<StateFromProps, {}, ComponentProps>(mapStateToProps, null)(DataPreviewButton); export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ getPreviewData }, dispatch);
};
export default connect<StateFromProps, {}, ComponentProps>(mapStateToProps, mapDispatchToProps)(DataPreviewButton);
@import 'variables'; @import 'variables';
.overlay-trigger {
display: inline-block;
}
.modal-dialog { .modal-dialog {
width: 90%; width: 90%;
......
import * as React from 'react';
import moment from 'moment-timezone';
import { Dropdown, MenuItem, OverlayTrigger, Popover } from 'react-bootstrap';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { notificationsEnabled } from 'config/config-utils';
import AppConfig from 'config/config';
import ColumnDescEditableText from 'components/TableDetail/ColumnDescEditableText';
import { GlobalState } from 'ducks/rootReducer';
import { logClick } from 'ducks/utilMethods';
import { OpenRequestAction } from 'ducks/notification/types';
import { openRequestDescriptionDialog } from 'ducks/notification/reducer';
import { RequestMetadataType, TableColumn } from 'interfaces';
// TODO: Use css-modules instead of 'import'
import './styles.scss';
interface DispatchFromProps {
openRequestDescriptionDialog: (requestMetadataType: RequestMetadataType, columnName: string) => OpenRequestAction;
}
interface OwnProps {
data?: TableColumn;
index: number;
}
export type DetailListItemProps = DispatchFromProps & OwnProps;
interface DetailListItemState {
isExpanded: boolean;
}
class DetailListItem extends React.Component<DetailListItemProps, DetailListItemState> {
public static defaultProps: Partial<DetailListItemProps> = {
data: {} as TableColumn,
index: null,
};
constructor(props) {
super(props);
this.state = {
isExpanded: false
};
}
openRequest = () => {
this.props.openRequestDescriptionDialog(RequestMetadataType.COLUMN_DESCRIPTION, this.props.data.name);
}
onClick = (e) => {
if (!this.state.isExpanded) {
const metadata = this.props.data;
logClick(e, {
target_id: `column::${metadata.name}`,
target_type: 'column stats',
label: `${metadata.name} ${metadata.type}`,
});
}
this.setState(prevState => ({
isExpanded: !prevState.isExpanded
}));
};
formatDate = (unixEpochSeconds) => {
return moment(unixEpochSeconds * 1000).format("MMM DD, YYYY");
};
renderColumnType = (columnIndex: number, type: string) => {
const truncatedTypes: string[] = ['array', 'struct', 'map'];
let shouldTrucate = false;
const fullText = type.toLowerCase();
let text = fullText;
truncatedTypes.forEach((truncatedType) => {
if (type.startsWith(truncatedType) && type !== truncatedType) {
shouldTrucate = true;
text = `${truncatedType}<...>`;
return;
};
})
if (shouldTrucate) {
const popoverHover = (
<Popover className='column-type-popover' id={`column-type-popover:${columnIndex}`}>
{fullText}
</Popover>
);
const stopPropagation = (event) => {
event.stopPropagation();
}
return (
<OverlayTrigger
trigger={['click']}
placement='right'
overlay={popoverHover}
rootClose={true}>
<a className='column-type'
href="JavaScript:void(0)"
onClick={ stopPropagation }
>
{text}
</a>
</OverlayTrigger>
)
}
return (<div className='column-type'>{text}</div>);
};
render() {
const metadata = this.props.data;
const isExpandable = metadata.stats && metadata.stats.length > 0;
const startEpoch = Math.min(...metadata.stats.map(s => parseInt(s.start_epoch, 10)));
const endEpoch = Math.max(...metadata.stats.map(s => parseInt(s.end_epoch, 10)));
const startDate = isExpandable ? this.formatDate(startEpoch) : null;
const endDate = isExpandable ? this.formatDate(endEpoch) : null;
let infoText = 'Stats reflect data collected';
if (startDate && endDate) {
if (startDate === endDate) {
infoText = `${infoText} on ${startDate} only. (daily partition)`;
} else {
infoText = `${infoText} between ${startDate} and ${endDate}.`;
}
} else {
infoText = `${infoText} over a recent period of time.`;
}
return (
<li className='list-group-item detail-list-item'>
<div className={'column-info ' + (isExpandable ? 'expandable' : '')} onClick={ isExpandable? this.onClick : null }>
<div className='title-section'>
<div className='title-row'>
<div className='name title-2'>{metadata.name}</div>
{ this.renderColumnType(this.props.index, metadata.type) }
</div>
</div>
{
isExpandable &&
<img className={'icon ' + (this.state.isExpanded ? 'icon-up' : 'icon-down')}/>
}
</div>
<div className='description-container'>
<div className={'body-secondary-3 description ' + (isExpandable && !this.state.isExpanded ? 'truncated' : '')}>
<ColumnDescEditableText
columnIndex={this.props.index}
editable={metadata.is_editable}
value={metadata.description}
maxLength={AppConfig.editableText.columnDescLength}
/>
</div>
{
notificationsEnabled() &&
<Dropdown id={`detail-list-item-dropdown:${this.props.index}`} pullRight={true} className="column-dropdown">
<Dropdown.Toggle noCaret={true} className="dropdown-icon-more">
<img className="icon icon-more"/>
</Dropdown.Toggle>
<Dropdown.Menu>
<MenuItem onClick={this.openRequest}>Request Column Description</MenuItem>
</Dropdown.Menu>
</Dropdown>
}
</div>
{
this.state.isExpanded &&
<div className='column-stats'>
{
metadata.stats.map(entry =>
<div className='column-stat' key={entry.stat_type}>
<div className='caption'>
{entry.stat_type.toUpperCase()}
</div>
<div className='body-link'>
{entry.stat_val}
</div>
</div>
)
}
{
metadata.stats.length > 0 &&
<div className="stat-collection-info">
{ infoText }
</div>
}
</div>
}
</li>
);
}
}
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ openRequestDescriptionDialog } , dispatch);
};
export default connect<{}, DispatchFromProps, OwnProps>(null, mapDispatchToProps)(DetailListItem);
@import 'variables';
.list-group .list-group-item.detail-list-item {
text-decoration: none;
display: flex;
flex-direction: column;
/* tmp fixes until we refactor/settle on styles */
cursor: default;
border-top-color: $stroke !important;
border-bottom-color: $stroke !important;
background-color: transparent !important;
padding: 10px 4px;
.description {
max-width: 100%;
min-width: 0;
}
.truncated .editable-text,
.truncated .editable-text p {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.description-container {
display: flex;
justify-content: space-between;
}
.column-info {
display: flex;
flex-direction: row;
margin: -10px -4px 0;
padding: 10px 4px 0;
.title-section {
width: 100%;
overflow-wrap: break-word;
.title-row {
display: flex;
flex-direction: row;
.name {
margin-right: 8px;
max-width: 85%;
}
.column-type {
margin-top: 4px;
max-width: 15%;
}
a.column-type {
text-decoration: none;
}
}
}
&.expandable {
cursor: pointer;
&:hover {
.icon {
background-color: $brand-color-4;
}
}
}
}
.column-stats {
display: block;
margin-top: 8px;
width: 100%;
.column-stat {
display: inline-block;
margin-top: 8px;
max-width: 150px;
min-width: 120px;
width: 20%;
}
.stat-collection-info {
color: $text-secondary;
font-style: italic;
margin-top: 4px;
}
}
.open {
.dropdown-icon-more {
box-shadow: none;
visibility: visible;
}
}
.column-dropdown {
height: fit-content;
.dropdown-icon-more {
border-style: none;
border-radius: 4px;
height: 22px;
width: 22px;
padding: 4px;
margin-right: 5px;
.icon {
background-color: $stroke;
height: 14px;
-webkit-mask-size: 14px;
mask-size: 14px;
width: 14px;
margin: 0;
}
&:hover,
&:focus {
background-color: $body-bg-secondary;
.icon {
background-color: $body-bg-dark;
}
}
}
}
}
import * as React from 'react';
import './styles.scss';
export interface EditableSectionProps {
title: string;
}
interface EditableSectionState {
isEditing: boolean;
}
export interface EditableSectionChildProps {
isEditing?: boolean;
setEditMode?: (isEditing: boolean) => void;
}
export class EditableSection extends React.Component<EditableSectionProps, EditableSectionState> {
constructor(props) {
super(props);
this.state = {
isEditing: false,
}
}
setEditMode = (isEditing: boolean) => {
this.setState({ isEditing });
};
toggleEdit = () => {
this.setState({ isEditing: !this.state.isEditing });
};
render() {
const childrenWithProps = React.Children.map(this.props.children, child => {
if (!React.isValidElement(child)) {
return child;
}
return React.cloneElement(child, {
isEditing: this.state.isEditing,
setEditMode: this.setEditMode,
});
});
return (
<section className="editable-section">
<div className="section-title title-3">
{ this.props.title }
<button className={"btn btn-flat-icon edit-button" + (this.state.isEditing? " active": "")} onClick={ this.toggleEdit }>
<img className={"icon icon-small icon-edit" + (this.state.isEditing? " icon-color" : "")} />
</button>
</div>
{ childrenWithProps }
</section>
);
}
}
@import 'variables';
.editable-section {
.section-title {
color: $text-tertiary;
margin-bottom: 4px;
}
.edit-button {
opacity: 0;
margin-left: 4px;
&.active {
opacity: 1;
}
}
&:hover {
.edit-button {
opacity: 1;
}
}
}
import * as React from 'react';
import { shallow } from 'enzyme';
import { EditableSection, EditableSectionProps } from '../';
import TagInput from 'components/Tags/TagInput';
describe("EditableSection", () => {
const setup = (propOverrides?: Partial<EditableSectionProps>, children?) => {
const props = {
title: "defaultTitle",
...propOverrides,
};
const wrapper = shallow<EditableSection>(<EditableSection {...props} >{ children }</EditableSection>)
return { wrapper, props };
};
describe("setEditMode", () => {
const { wrapper, props } = setup();
it("Enters edit mode after calling setEditMode(true)", () => {
wrapper.instance().setEditMode(true);
expect(wrapper.state().isEditing).toBe(true);
});
it("Exits edit mode after calling setEditMode(false)", () => {
wrapper.instance().setEditMode(false);
expect(wrapper.state().isEditing).toBe(false);
});
});
describe("toggleEdit", () => {
const { wrapper, props } = setup();
const initialEditMode = wrapper.state().isEditing;
it("Toggles the edit mode from the after each call", () => {
// First call
wrapper.instance().toggleEdit();
expect(wrapper.state().isEditing).toBe(!initialEditMode);
// Second call
wrapper.instance().toggleEdit();
expect(wrapper.state().isEditing).toBe(initialEditMode);
});
});
describe("render", () => {
const customTitle = "custom title";
const { wrapper, props } = setup({ title: customTitle }, <TagInput/>);
it("sets the title from a prop", () => {
expect(wrapper.find(".section-title").text()).toBe(customTitle);
});
it("renders children with additional props", () => {
const childProps = wrapper.find(TagInput).props();
expect(childProps).toMatchObject({
isEditing: wrapper.state().isEditing,
setEditMode: wrapper.instance().setEditMode
});
});
it("renders children as-is for non-react elements", () => {
const child = "non-react-child";
const { wrapper } = setup(null, child);
expect(wrapper.childAt(1).text()).toBe(child);
});
});
});
import * as React from 'react';
import AppConfig from 'config/config';
import { logClick } from 'ducks/utilMethods';
import { TableMetadata } from 'interfaces';
export interface ExploreButtonProps {
tableData: TableMetadata;
}
export class ExploreButton extends React.Component<ExploreButtonProps> {
constructor(props) {
super(props);
}
generateUrl() {
const tableData = this.props.tableData;
const partition = tableData.partition;
if (partition.is_partitioned) {
return AppConfig.tableProfile.exploreUrlGenerator(
tableData.database, tableData.cluster, tableData.schema, tableData.table_name, partition.key, partition.value);
}
return AppConfig.tableProfile.exploreUrlGenerator(
tableData.database, tableData.cluster, tableData.schema, tableData.table_name);
}
render() {
if (!AppConfig.tableProfile.isExploreEnabled) {
return null;
}
return (
<a
className="btn btn-default btn-lg"
href={ this.generateUrl() }
role="button"
target="_blank"
id="explore-sql"
onClick={ logClick }
>
Explore
</a>
);
}
};
export default ExploreButton;
import * as React from 'react';
import * as Avatar from 'react-avatar';
import { OverlayTrigger, Popover } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { TableReader } from 'interfaces';
import AppConfig from 'config/config';
import { logClick } from 'ducks/utilMethods';
export interface FrequentUsersProps {
readers: TableReader[];
}
export function renderReader(reader: TableReader, index: number, readers: TableReader[]) {
const user = reader.reader;
let link = user.profile_url;
let target = '_blank';
if (AppConfig.indexUsers.enabled) {
link = `/user/${user.user_id}?source=frequent_users`;
target = '';
}
return (
<OverlayTrigger
key={ user.display_name }
trigger={['hover', 'focus']}
placement="top"
overlay={
<Popover id="popover-trigger-hover-focus">
{user.display_name}
</Popover>
}
>
<Link
className="avatar-overlap"
id="frequent-users"
onClick={ logClick }
to={ link }
target={ target }
>
<Avatar
name={ user.display_name }
round={ true }
size={ 25 }
style={{ zIndex: readers.length - index, position: 'relative' }}
/>
</Link>
</OverlayTrigger>
);
};
const FrequentUsers: React.SFC<FrequentUsersProps> = ({ readers }) => {
if (readers.length === 0) {
return (<label className="body-3">No frequent users exist</label>);
}
return (
<div className="frequent-users">
{
readers.map(renderReader)
}
</div>
)
};
export default FrequentUsers;
import * as React from 'react';
import AvatarLabel from 'components/common/AvatarLabel';
import AppConfig from 'config/config';
import { logClick } from 'ducks/utilMethods';
import { TableMetadata } from 'interfaces/TableMetadata';
export interface LineageLinkProps {
tableData: TableMetadata
}
const LineageLink: React.SFC<LineageLinkProps> = ({ tableData }) => {
const config = AppConfig.tableLineage;
if (!config.isEnabled) return null;
const { database, cluster, schema, table_name } = tableData;
const href = config.urlGenerator(database, cluster, schema, table_name);
const label = 'Lineage';
return (
<a
className="header-link"
href={ href }
target="_blank"
id="explore-lineage"
onClick={ logClick }
>
<AvatarLabel
label={ label }
src={ config.iconPath }
/>
</a>
);
};
export default LineageLink;
...@@ -3,9 +3,6 @@ import { connect } from 'react-redux'; ...@@ -3,9 +3,6 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import ReactDOM from 'react-dom';
import serialize from 'form-serialize';
import AppConfig from 'config/config'; import AppConfig from 'config/config';
import AvatarLabel, { AvatarLabelProps } from 'components/common/AvatarLabel'; import AvatarLabel, { AvatarLabelProps } from 'components/common/AvatarLabel';
import LoadingSpinner from 'components/common/LoadingSpinner'; import LoadingSpinner from 'components/common/LoadingSpinner';
...@@ -19,6 +16,7 @@ const DEFAULT_ERROR_TEXT = 'There was a problem with the request, please reload ...@@ -19,6 +16,7 @@ const DEFAULT_ERROR_TEXT = 'There was a problem with the request, please reload
import { GlobalState } from 'ducks/rootReducer'; import { GlobalState } from 'ducks/rootReducer';
import { updateTableOwner } from 'ducks/tableMetadata/owners/reducer'; import { updateTableOwner } from 'ducks/tableMetadata/owners/reducer';
import { EditableSectionChildProps } from 'components/TableDetail/EditableSection';
import { logClick } from 'ducks/utilMethods'; import { logClick } from 'ducks/utilMethods';
export interface DispatchFromProps { export interface DispatchFromProps {
...@@ -40,14 +38,13 @@ export interface StateFromProps { ...@@ -40,14 +38,13 @@ export interface StateFromProps {
itemProps: { [id: string]: OwnerAvatarLabelProps }; itemProps: { [id: string]: OwnerAvatarLabelProps };
} }
type OwnerEditorProps = ComponentProps & DispatchFromProps & StateFromProps; type OwnerEditorProps = ComponentProps & DispatchFromProps & StateFromProps & EditableSectionChildProps;
interface OwnerEditorState { interface OwnerEditorState {
errorText: string | null; errorText: string | null;
isLoading: boolean; isLoading: boolean;
itemProps: { [id: string]: OwnerAvatarLabelProps }; itemProps: { [id: string]: OwnerAvatarLabelProps };
readOnly: boolean; readOnly: boolean;
showModal: boolean;
tempItemProps: { [id: string]: AvatarLabelProps }; tempItemProps: { [id: string]: AvatarLabelProps };
} }
...@@ -62,7 +59,7 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt ...@@ -62,7 +59,7 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt
readOnly: true, readOnly: true,
}; };
static getDerivedStateFromProps(nextProps, prevState) { static getDerivedStateFromProps(nextProps) {
const { isLoading, itemProps, readOnly } = nextProps; const { isLoading, itemProps, readOnly } = nextProps;
return { isLoading, itemProps, readOnly, tempItemProps: itemProps }; return { isLoading, itemProps, readOnly, tempItemProps: itemProps };
} }
...@@ -75,7 +72,6 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt ...@@ -75,7 +72,6 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt
isLoading: props.isLoading, isLoading: props.isLoading,
itemProps: props.itemProps, itemProps: props.itemProps,
readOnly: props.readOnly, readOnly: props.readOnly,
showModal: false,
tempItemProps: props.itemProps, tempItemProps: props.itemProps,
}; };
...@@ -83,12 +79,13 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt ...@@ -83,12 +79,13 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt
} }
handleShow = () => { handleShow = () => {
this.setState({ showModal: true }); this.props.setEditMode(true)
} };
cancelEdit = () => { cancelEdit = () => {
this.setState({ tempItemProps: this.state.itemProps, showModal: false }); this.setState({ tempItemProps: this.state.itemProps });
} this.props.setEditMode(false);
};
saveEdit = () => { saveEdit = () => {
const updateArray = []; const updateArray = [];
...@@ -104,13 +101,14 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt ...@@ -104,13 +101,14 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt
}); });
const onSuccessCallback = () => { const onSuccessCallback = () => {
this.setState({ showModal: false }); this.props.setEditMode(false);
} };
const onFailureCallback = () => { const onFailureCallback = () => {
this.setState({ errorText: DEFAULT_ERROR_TEXT, readOnly: true, showModal: false }); this.setState({ errorText: DEFAULT_ERROR_TEXT, readOnly: true });
} this.props.setEditMode(false);
};
this.props.onUpdateList(updateArray, onSuccessCallback, onFailureCallback); this.props.onUpdateList(updateArray, onSuccessCallback, onFailureCallback);
} };
recordAddItem = (event: React.FormEvent<HTMLFormElement>) => { recordAddItem = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
...@@ -120,10 +118,10 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt ...@@ -120,10 +118,10 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt
const newTempItemProps = { const newTempItemProps = {
...this.state.tempItemProps, ...this.state.tempItemProps,
[value]: { label: value }, [value]: { label: value },
} };
this.setState({ tempItemProps: newTempItemProps }); this.setState({ tempItemProps: newTempItemProps });
} }
} };
recordDeleteItem = (deletedKey: string) => { recordDeleteItem = (deletedKey: string) => {
const newTempItemProps = Object.keys(this.state.tempItemProps) const newTempItemProps = Object.keys(this.state.tempItemProps)
...@@ -135,10 +133,10 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt ...@@ -135,10 +133,10 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt
return obj; return obj;
}, {}); }, {});
this.setState({ tempItemProps: newTempItemProps }); this.setState({ tempItemProps: newTempItemProps });
} };
renderModalBody = () => { renderModalBody = () => {
if (!this.state.showModal) { if (!this.props.isEditing) {
return null; return null;
} }
...@@ -185,7 +183,7 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt ...@@ -185,7 +183,7 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt
</ul> </ul>
</Modal.Body> </Modal.Body>
); );
} };
render() { render() {
let content; let content;
...@@ -240,23 +238,23 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt ...@@ -240,23 +238,23 @@ export class OwnerEditor extends React.Component<OwnerEditorProps, OwnerEditorSt
<div className='owner-editor-component'> <div className='owner-editor-component'>
{ content } { content }
{ {
!this.state.readOnly && !this.state.readOnly && Object.keys(this.state.itemProps).length === 0 &&
<button <button
className='btn btn-flat-icon add-item-button' className='btn btn-flat-icon add-item-button'
onClick={this.handleShow}> onClick={ this.handleShow }>
<img className='icon icon-plus-circle'/> <img className='icon icon-plus-circle'/>
<span>Add</span> <span>Add Owner</span>
</button> </button>
} }
<Modal className='owner-editor-modal' show={this.state.showModal} onHide={this.cancelEdit}> <Modal className='owner-editor-modal' show={ this.props.isEditing } onHide={ this.cancelEdit }>
<Modal.Header className="text-center" closeButton={false}> <Modal.Header className="text-center" closeButton={false}>
<Modal.Title>Owned By</Modal.Title> <Modal.Title>Owned By</Modal.Title>
</Modal.Header> </Modal.Header>
{ this.renderModalBody() } { this.renderModalBody() }
<Modal.Footer> <Modal.Footer>
<button type="button" className="btn btn-default" onClick={this.cancelEdit}>Cancel</button> <button type="button" className="btn btn-default" onClick={ this.cancelEdit }>Cancel</button>
<button type="button" className="btn btn-primary" onClick={this.saveEdit}>Save</button> <button type="button" className="btn btn-primary" onClick={ this.saveEdit }>Save</button>
</Modal.Footer> </Modal.Footer>
</Modal> </Modal>
</div> </div>
......
...@@ -3,8 +3,14 @@ ...@@ -3,8 +3,14 @@
@import 'variables'; @import 'variables';
.owner-editor-component { .owner-editor-component {
.avatar-label-component {
.avatar-label {
margin-left: 4px;
}
}
.btn.add-item-button { .btn.add-item-button {
height: 32px; height: 24px;
padding: 0px 1px; padding: 0px 1px;
} }
...@@ -27,10 +33,10 @@ ...@@ -27,10 +33,10 @@
&:focus, &:focus,
&:hover { &:hover {
background-color: $body-bg-secondary; background-color: $body-bg-tertiary;
.btn.delete-button { .btn.delete-button {
background-color: $body-bg-secondary; background-color: $body-bg-tertiary;
display: block display: block
} }
} }
......
import * as React from 'react';
import { logClick } from 'ducks/utilMethods';
import AvatarLabel from 'components/common/AvatarLabel';
import { TableSource } from 'interfaces';
export interface SourceLinkProps {
tableSource: TableSource;
}
const SourceLink: React.SFC<SourceLinkProps> = ({ tableSource }) => {
if (tableSource === null || tableSource.source === null) return null;
const image = (tableSource.source_type === 'github')? '/static/images/github.png': '';
return (
<a
className="header-link"
href={ tableSource.source }
id="explore-source"
onClick={ logClick }
target='_blank'
>
<AvatarLabel label={ tableSource.source_type } src={ image }/>
</a>
);
};
export default SourceLink;
export const NO_WATERMARK_LINE_1 = "Non-Partitioned Table";
export const NO_WATERMARK_LINE_2 = "Data available for all dates";
export const LOW_WATERMARK_LABEL = "From:";
export const HIGH_WATERMARK_LABEL = "To:";
export const WATERMARK_INPUT_FORMAT = "YYYY-MM-DD";
export const WATERMARK_DISPLAY_FORMAT = "MMM DD, YYYY";
export enum WatermarkType {
HIGH = "high_watermark",
LOW = "low_watermark",
}
import * as React from 'react'; import * as React from 'react';
import moment from 'moment-timezone'; import * as moment from 'moment-timezone';
import './styles.scss'; import './styles.scss';
import { Watermark } from 'interfaces'; import { Watermark } from 'interfaces';
import {
HIGH_WATERMARK_LABEL,
NO_WATERMARK_LINE_1, NO_WATERMARK_LINE_2, LOW_WATERMARK_LABEL,
WATERMARK_DISPLAY_FORMAT,
WATERMARK_INPUT_FORMAT,
WatermarkType
} from './constants';
interface WatermarkLabelProps { export interface WatermarkLabelProps {
watermarks: Watermark[]; watermarks: Watermark[];
} }
class WatermarkLabel extends React.Component<WatermarkLabelProps> { class WatermarkLabel extends React.Component<WatermarkLabelProps> {
constructor(props) { constructor(props) {
super(props); super(props);
this.getWatermarksLabel = this.getWatermarksLabel.bind(this);
} }
render() { formatWatermarkDate = (dateString: string) => {
return ( return moment(dateString, WATERMARK_INPUT_FORMAT).format(WATERMARK_DISPLAY_FORMAT);
<div className="watermark-label">{this.getWatermarksLabel(this.props.watermarks)}</div> };
)
} getWatermarkValue = (type: WatermarkType) => {
const watermark = this.props.watermarks.find((watermark: Watermark) => watermark.watermark_type === type);
return watermark && watermark.partition_value || null;
};
getWatermarksLabel(watermarks: Watermark[]) { renderWatermarkInfo = (low: string, high: string) => {
const low = watermarks.find((wtm) => wtm.watermark_type === "low_watermark"); if (low === null && high === null) {
const high = watermarks.find((wtm) => wtm.watermark_type === "high_watermark"); return (
if (low === undefined && high === undefined) { <div className="body-2">
return "Non Partitioned Table. Data available for all dates." { NO_WATERMARK_LINE_1 }
<br/>
{ NO_WATERMARK_LINE_2 }
</div>
);
} }
return [low, high].map((wtm) => {
return moment(wtm.partition_value, "YYYY-MM-DD").format("MMM DD, YYYY"); return (
}).join(" – "); <>
<div className="range-labels body-2">
{ LOW_WATERMARK_LABEL }
<br/>
{ HIGH_WATERMARK_LABEL }
</div>
<div className="range-dates body-2">
{ low && this.formatWatermarkDate(low) }
<br/>
{ high && this.formatWatermarkDate(high) }
</div>
</>
);
};
render() {
const low = this.getWatermarkValue(WatermarkType.LOW);
const high = this.getWatermarkValue(WatermarkType.HIGH);
return (
<div className="watermark-label">
<img className="range-icon" src="/static/images/watermark-range.png"/>
{ this.renderWatermarkInfo(low, high) }
</div>
);
} }
} }
......
@import 'variables'; @import 'variables';
.watermark-label { .watermark-label {
margin-bottom: 10px; display: flex;
.range-icon {
flex-basis: 12px;
height: 40px;
width: 12px;
margin-right: 16px;
}
.range-labels {
flex-basis: 50px;
}
} }
import * as React from 'react';
import { shallow, mount } from 'enzyme';
import WatermarkLabel, { WatermarkLabelProps } from '../';
import {
NO_WATERMARK_LINE_1,
NO_WATERMARK_LINE_2,
WatermarkType
} from 'components/TableDetail/WatermarkLabel/constants';
describe('WatermarkLabel', () => {
const setup = (propOverrides?: Partial<WatermarkLabelProps>) => {
const props = {
watermarks: [
{
create_time: "2018-10-16 11:03:17",
partition_key: "ds",
partition_value: "2018-08-03",
watermark_type: "low_watermark",
},
{
create_time: "2019-10-15 11:03:17",
partition_key: "ds",
partition_value: "2019-10-15",
watermark_type: "high_watermark",
}
],
...propOverrides,
};
const wrapper = shallow<WatermarkLabel>(<WatermarkLabel {...props} />);
return { wrapper, props };
};
describe('formatWatermarkDate', () => {
it('Parses a date string and converts it to a new format', () => {
const { wrapper } = setup();
const dateString = "2019-10-15";
const formattedDate = wrapper.instance().formatWatermarkDate(dateString);
expect(formattedDate).toBe("Oct 15, 2019");
});
});
describe('getWatermarkValue', () => {
const { wrapper } = setup();
it ('Gets the high watermark value', () => {
const highWatermark = wrapper.instance().getWatermarkValue(WatermarkType.HIGH);
expect(highWatermark).toBe("2019-10-15")
});
it ('Gets the low watermark value', () => {
const highWatermark = wrapper.instance().getWatermarkValue(WatermarkType.LOW);
expect(highWatermark).toBe("2018-08-03")
});
it ('Returns null if no partition is found', () => {
const { wrapper } = setup({ watermarks: [] });
const nullWatermark = wrapper.instance().getWatermarkValue(WatermarkType.LOW);
expect(nullWatermark).toBe(null);
});
});
describe('renderWatermarkInfo', () => {
const { wrapper } = setup();
const instance = wrapper.instance();
it('renders a no-watermark message if there are no watermarks', () => {
const watermarkInfo = instance.renderWatermarkInfo(null, null);
expect(shallow(watermarkInfo).text()).toBe(`${NO_WATERMARK_LINE_1}${NO_WATERMARK_LINE_2}`)
});
it('renders the date when present', () => {
const watermarkInfo = instance.renderWatermarkInfo('2018-08-03', '2019-10-15');
expect(mount(watermarkInfo).find('.range-dates').text()).toBe('Aug 03, 2018Oct 15, 2019')
});
});
describe('render', () => {
const { wrapper, props } = setup();
const instance = wrapper.instance();
const getWatermarkValueSpy = jest.spyOn(instance, 'getWatermarkValue');
const renderWatermarkInfoSpy = jest.spyOn(instance, 'renderWatermarkInfo');
instance.render();
it('calls getWatermarkValue with both high and low values', () => {
expect(getWatermarkValueSpy).toHaveBeenCalledWith(WatermarkType.LOW);
expect(getWatermarkValueSpy).toHaveBeenCalledWith(WatermarkType.HIGH);
});
it('calls renderWatermarkInfo', () => {
const low = props.watermarks[0].partition_value;
const high = props.watermarks[1].partition_value;
expect(renderWatermarkInfoSpy).toHaveBeenCalledWith(low, high);
});
it('renders the watermark-range image', () => {
expect(wrapper.find('img.range-icon').exists()).toBe(true);
});
});
});
import * as React from 'react';
import { OverlayTrigger, Popover } from 'react-bootstrap';
import AvatarLabel from 'components/common/AvatarLabel';
import { logClick } from 'ducks/utilMethods';
import { TableWriter } from 'interfaces';
export interface WriterLinkProps {
tableWriter: TableWriter;
}
const WriterLink: React.SFC<WriterLinkProps> = ({ tableWriter }) => {
if (tableWriter === null || tableWriter.application_url === null) {
return null;
}
const image = (tableWriter.name === 'Airflow') ? '/static/images/airflow.jpeg' : '';
return (
<OverlayTrigger
trigger={['hover', 'focus']}
placement='top'
delayHide={ 200 }
overlay={
<Popover id="popover-trigger-hover-focus">
{ tableWriter.id }
</Popover>
}
>
<a
id="explore-writer"
className="header-link"
href={tableWriter.application_url}
onClick={ logClick }
target='_blank'
>
<AvatarLabel label={tableWriter.name} src={image}/>
</a>
</OverlayTrigger>
);
};
export default WriterLink;
@import 'variables'; @import 'variables';
.table-detail { .table-detail-2 {
height: calc(100% - #{$nav-bar-height} - #{$footer-height});
.error-label { .header-link {
margin: 48px auto; display: inline-block;
width: fit-content; margin: 13px 0;
}
.detail-header {
margin-bottom: 32px;
}
.detail-header .detail-header-text {
overflow-wrap: break-word;
}
.detail-list-header { .avatar-label {
margin-bottom: 32px; font-weight: $font-weight-body-bold;
}
} }
@media (min-width: $screen-md-min) { .column-layout-1 .left-panel {
.section-title {
/* TODO: Delete this with Bootstrap V4 */ color: $text-tertiary;
.float-md-right { margin: 40px 0 8px;
float: right;
} }
.detail-header { .editable-text {
margin-bottom: 72px; font-size: 16px;
} }
} }
} }
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
border: 0; border: 0;
border-radius: 4px; border-radius: 4px;
color: $text-primary; color: $text-primary;
margin: 4px; margin: 0 8px 8px 0;
overflow: hidden; overflow: hidden;
padding: 8px; padding: 8px;
text-overflow: ellipsis; text-overflow: ellipsis;
...@@ -14,7 +14,6 @@ ...@@ -14,7 +14,6 @@
&.compact { &.compact {
line-height: 14px; line-height: 14px;
height: 30px; height: 30px;
margin: 2px;
} }
&:hover, &:hover,
......
...@@ -12,6 +12,7 @@ import { updateTags } from 'ducks/tableMetadata/tags/reducer'; ...@@ -12,6 +12,7 @@ import { updateTags } from 'ducks/tableMetadata/tags/reducer';
import { UpdateTagsRequest } from 'ducks/tableMetadata/types'; import { UpdateTagsRequest } from 'ducks/tableMetadata/types';
import TagInfo from "../TagInfo"; import TagInfo from "../TagInfo";
import { EditableSectionChildProps } from 'components/TableDetail/EditableSection';
import { Tag, UpdateMethod, UpdateTagData } from 'interfaces'; import { Tag, UpdateMethod, UpdateTagData } from 'interfaces';
// TODO: Use css-modules instead of 'import' // TODO: Use css-modules instead of 'import'
...@@ -40,18 +41,10 @@ export interface DispatchFromProps { ...@@ -40,18 +41,10 @@ export interface DispatchFromProps {
getAllTags: () => GetAllTagsRequest; getAllTags: () => GetAllTagsRequest;
} }
export interface ComponentProps { type TagInputProps = StateFromProps & DispatchFromProps & EditableSectionChildProps;
readOnly: boolean;
}
type TagInputProps = StateFromProps & DispatchFromProps & ComponentProps;
interface TagInputState { interface TagInputState {
allTags: Tag[];
isLoading: boolean;
readOnly: boolean;
showModal: boolean; showModal: boolean;
tags: Tag[];
} }
class TagInput extends React.Component<TagInputProps, TagInputState> { class TagInput extends React.Component<TagInputProps, TagInputState> {
...@@ -61,24 +54,14 @@ class TagInput extends React.Component<TagInputProps, TagInputState> { ...@@ -61,24 +54,14 @@ class TagInput extends React.Component<TagInputProps, TagInputState> {
allTags: [], allTags: [],
getAllTags: () => void(0), getAllTags: () => void(0),
isLoading: false, isLoading: false,
readOnly: true,
tags: undefined, tags: undefined,
updateTags: () => void(0), updateTags: () => void(0),
}; };
static getDerivedStateFromProps(nextProps, prevState) {
const { allTags, isLoading, readOnly, tags } = nextProps;
return { ...prevState, allTags, isLoading, readOnly, tags };
}
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
allTags: props.allTags,
isLoading: props.isLoading,
readOnly: props.readOnly,
showModal: false, showModal: false,
tags: props.tags,
}; };
} }
...@@ -89,15 +72,15 @@ class TagInput extends React.Component<TagInputProps, TagInputState> { ...@@ -89,15 +72,15 @@ class TagInput extends React.Component<TagInputProps, TagInputState> {
handleClose = () => { handleClose = () => {
this.batchEditSet = {}; this.batchEditSet = {};
this.setState({ showModal: false }); this.setState({ showModal: false });
} };
handleShow = () => { handleShow = () => {
this.batchEditSet = {}; this.batchEditSet = {};
this.state.tags.map((tag) => { this.props.tags.map((tag) => {
this.batchEditSet[tag.tag_name] = BatchEditState.CURRENT; this.batchEditSet[tag.tag_name] = BatchEditState.CURRENT;
}); });
this.setState({ showModal: true }); this.setState({ showModal: true });
} };
handleSaveModalEdit = () => { handleSaveModalEdit = () => {
const tagArray = Object.keys(this.batchEditSet).reduce((previousValue, tag) => { const tagArray = Object.keys(this.batchEditSet).reduce((previousValue, tag) => {
...@@ -167,11 +150,14 @@ class TagInput extends React.Component<TagInputProps, TagInputState> { ...@@ -167,11 +150,14 @@ class TagInput extends React.Component<TagInputProps, TagInputState> {
} }
}; };
preventDeleteOnBackSpace(event) { onKeyDown = (event) => {
if (event.keyCode === 8 && event.target.value.length === 0){ if (event.key === 8 && event.target.value.length === 0) {
event.preventDefault(); event.preventDefault();
} }
} if (event.key === "Escape") {
this.stopEditing();
}
};
toggleTag = (event, tagName) => { toggleTag = (event, tagName) => {
const element = event.currentTarget; const element = event.currentTarget;
...@@ -217,16 +203,24 @@ class TagInput extends React.Component<TagInputProps, TagInputState> { ...@@ -217,16 +203,24 @@ class TagInput extends React.Component<TagInputProps, TagInputState> {
<div className=''> <div className=''>
<p className=''>Click on a tag to add/remove</p> <p className=''>Click on a tag to add/remove</p>
<div className='tag-blob'> <div className='tag-blob'>
{ this.renderTagBlob(this.state.tags, 'current', 'multi-value-container selected') } { this.renderTagBlob(this.props.tags, 'current', 'multi-value-container selected') }
{ this.renderTagBlob(this.state.allTags.filter(FILTER_COMMON_TAGS(this.state.tags)), 'existing', 'multi-value-container') } { this.renderTagBlob(this.props.allTags.filter(FILTER_COMMON_TAGS(this.props.tags)), 'existing', 'multi-value-container') }
</div> </div>
</div> </div>
) )
} }
startEditing = () => {
this.props.setEditMode && this.props.setEditMode(true);
};
stopEditing = () => {
this.props.setEditMode && this.props.setEditMode(false);
};
render() { render() {
// https://react-select.com/props#api // https://react-select.com/props#api
const componentOverides = this.state.readOnly ? { const componentOverides = !this.props.isEditing ? {
DropdownIndicator: () => { return null }, DropdownIndicator: () => { return null },
IndicatorSeparator: () => { return null }, IndicatorSeparator: () => { return null },
MultiValueRemove: () => { return null }, MultiValueRemove: () => { return null },
...@@ -236,8 +230,16 @@ class TagInput extends React.Component<TagInputProps, TagInputState> { ...@@ -236,8 +230,16 @@ class TagInput extends React.Component<TagInputProps, TagInputState> {
}; };
let tagBody; let tagBody;
if (this.state.readOnly) { if (!this.props.isEditing) {
tagBody = this.state.tags.map((tag, index) => <TagInfo data={tag} key={index}/>) if (this.props.tags.length === 0) {
tagBody = (
<button className="btn btn-default muted add-btn" onClick={ this.startEditing }>
<img className="icon icon-plus"/>New
</button>
);
} else {
tagBody = this.props.tags.map((tag, index) => <TagInfo data={tag} key={index}/>);
}
} else { } else {
tagBody = ( tagBody = (
<CreatableSelect <CreatableSelect
...@@ -246,15 +248,15 @@ class TagInput extends React.Component<TagInputProps, TagInputState> { ...@@ -246,15 +248,15 @@ class TagInput extends React.Component<TagInputProps, TagInputState> {
classNamePrefix="amundsen" classNamePrefix="amundsen"
components={componentOverides} components={componentOverides}
isClearable={false} isClearable={false}
isDisabled={this.state.isLoading} isDisabled={this.props.isLoading}
isLoading={this.state.isLoading} isLoading={this.props.isLoading}
isMulti={true} isMulti={true}
isValidNewOption={this.isValidNewOption} isValidNewOption={this.isValidNewOption}
name="tags" name="tags"
noOptionsMessage={this.noOptionsMessage} noOptionsMessage={this.noOptionsMessage}
onChange={this.onChange} onChange={this.onChange}
onKeyDown={this.preventDeleteOnBackSpace} onKeyDown={this.onKeyDown}
options={this.mapOptionsToReactSelectAPI(this.state.allTags)} options={this.mapOptionsToReactSelectAPI(this.props.allTags)}
placeholder='Add a new tag' placeholder='Add a new tag'
styles={{ styles={{
multiValueLabel: (provided) => ({ multiValueLabel: (provided) => ({
...@@ -266,7 +268,7 @@ class TagInput extends React.Component<TagInputProps, TagInputState> { ...@@ -266,7 +268,7 @@ class TagInput extends React.Component<TagInputProps, TagInputState> {
}), }),
option: this.generateCustomOptionStyle option: this.generateCustomOptionStyle
}} }}
value={this.mapTagsToReactSelectAPI(this.state.tags)} value={this.mapTagsToReactSelectAPI(this.props.tags)}
/> />
); );
} }
...@@ -303,4 +305,4 @@ export const mapDispatchToProps = (dispatch: any) => { ...@@ -303,4 +305,4 @@ export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ getAllTags, updateTags } , dispatch); return bindActionCreators({ getAllTags, updateTags } , dispatch);
}; };
export default connect<StateFromProps, DispatchFromProps, ComponentProps>(mapStateToProps, mapDispatchToProps)(TagInput); export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(TagInput);
@import 'variables'; @import 'variables';
/* .tag-input {
/*
override default react-select style classes. override default react-select style classes.
using !important as a temporary workaround in places where a compiled class using !important as a temporary workaround in places where a compiled class
is taking precendence, is taking precendence,
*/ */
.tag-input .basic-multi-select { .basic-multi-select {
.amundsen__control { .amundsen__control {
min-height: 32px; min-height: 32px;
&, &,
.amundsen__control--is-focused, .amundsen__control--is-focused,
.amundsen__control--is-focused:hover { .amundsen__control--is-focused:hover {
border: none !important; border: none !important;
box-shadow: none !important; box-shadow: none !important;
}
.amundsen__multi-value {
background-color: $tag-bg !important;
border-radius: 4px;
.amundsen__multi-value__label {
border-radius: 4px 0 0 4px;
color: $text-primary;
line-height: 14px;
padding: 8px;
} }
.amundsen__multi-value__remove { .amundsen__multi-value {
border-radius: 0 4px 4px 0; background-color: $tag-bg !important;
cursor: pointer; border-radius: 4px;
margin: 8px 8px 0 0;
&:hover, .amundsen__multi-value__label {
&:focus { border-radius: 4px 0 0 4px;
background-color: $tag-bg-hover !important;
color: $text-primary; color: $text-primary;
line-height: 14px;
padding: 8px;
}
.amundsen__multi-value__remove {
border-radius: 0 4px 4px 0;
cursor: pointer;
&:hover,
&:focus {
background-color: $tag-bg-hover !important;
color: $text-primary;
}
} }
} }
} }
}
.amundsen__control--is-disabled { .amundsen__control--is-disabled {
background-color: transparent; background-color: transparent;
border-style: none; border-style: none;
}
} }
}
.amundsen__value-container {
padding: 0 !important;
}
.amundsen__option--is-focused {
background-color: #eee !important;
}
.amundsen__multi-value--is-disabled .amundsen__multi-value__label {
padding-right: 6px;
}
.amundsen__indicators {
width: 48px;
}
.tag-blob {
display: flex;
flex-wrap: wrap;
}
.multi-value-container { .amundsen__value-container {
border-radius: 4px; padding: 0 !important;
border: 1px solid hsl(0,0%,90%); }
display: flex;
margin: 4px;
min-width: 0;
box-sizing: border-box;
}
.multi-value-container.selected { .amundsen__option--is-focused {
background-color: hsl(0,0%,90%); background-color: #eee !important;
} }
.multi-value-label { .amundsen__multi-value--is-disabled .amundsen__multi-value__label {
color: hsl(0,0%,20%); padding-right: 6px;
overflow: hidden; }
padding: 3px;
padding-left: 6px;
text-overflow: ellipsis;
white-space: nowrap;
box-sizing: border-box;
width: 100%;
padding-right: 6px;
}
.multi-value-label:hover { .amundsen__indicators {
cursor: pointer; width: 48px;
}
} }
.tag-input-modal { .tag-input-modal {
...@@ -118,4 +87,40 @@ ...@@ -118,4 +87,40 @@
margin-left: 4px; margin-left: 4px;
} }
} }
.tag-blob {
display: flex;
flex-wrap: wrap;
}
.multi-value-container {
border-radius: 4px;
border: 1px solid hsl(0, 0%, 90%);
display: flex;
margin: 4px;
min-width: 0;
box-sizing: border-box;
}
.multi-value-container.selected {
background-color: hsl(0, 0%, 90%);
}
.multi-value-label {
color: hsl(0, 0%, 20%);
overflow: hidden;
padding: 3px;
padding-left: 6px;
text-overflow: ellipsis;
white-space: nowrap;
box-sizing: border-box;
width: 100%;
padding-right: 6px;
}
.multi-value-label:hover {
cursor: pointer;
}
} }
// TODO - Implement tests for TagInput
describe('TagInput', () => {
it('Placeholder test', () => {})
});
...@@ -11,11 +11,9 @@ export interface AvatarLabelProps { ...@@ -11,11 +11,9 @@ export interface AvatarLabelProps {
const AvatarLabel: React.SFC<AvatarLabelProps> = ({ label, src }) => { const AvatarLabel: React.SFC<AvatarLabelProps> = ({ label, src }) => {
return ( return (
<div className='avatar-label-component'> <div className="avatar-label-component">
<div id='component-avatar' className='component-avatar'> <Avatar name={label} src={src} size={24} round={true} />
<Avatar name={label} src={src} size={24} round={true} /> <span className="avatar-label body-2">{ label }</span>
</div>
<label id='component-label' className='component-label'>{label}</label>
</div> </div>
); );
}; };
......
@import 'variables'; @import 'variables';
.avatar-label-component { .avatar-label-component {
display: flex; display: inline-block;
font-size: 12px;
word-wrap: break-word;
}
.component-avatar {
margin-top: auto;
margin-bottom: auto;
margin-right: 4px;
}
.component-label { .avatar-label {
margin-top: auto; margin-left: 8px;
margin-bottom: auto; cursor: inherit;
cursor: inherit; min-width: 0;
font-weight: normal; color: $text-secondary;
min-width: 0; vertical-align: middle;
}
} }
.avatar-overlap { .avatar-overlap {
......
...@@ -35,7 +35,7 @@ describe('AvatarLabel', () => { ...@@ -35,7 +35,7 @@ describe('AvatarLabel', () => {
}); });
it('renders label with correct text', () => { it('renders label with correct text', () => {
expect(wrapper.find('label').text()).toEqual(props.label); expect(wrapper.find('.avatar-label').text()).toEqual(props.label);
}); });
}); });
}); });
...@@ -25,7 +25,7 @@ ...@@ -25,7 +25,7 @@
&:hover, &:hover,
&:focus { &:focus {
background-color: $body-bg-secondary; background-color: $body-bg-tertiary;
} }
.icon { .icon {
......
export const REFRESH_MESSAGE = "This text is out of date, please refresh the component";
export const REFRESH_BUTTON_TEXT = "Refresh";
export const UPDATE_BUTTON_TEXT = "Update";
export const CANCEL_BUTTON_TEXT = "Cancel";
import autosize from 'autosize'; import * as autosize from 'autosize';
import * as React from 'react'; import * as React from 'react';
import { Overlay, Tooltip } from 'react-bootstrap';
import * as ReactMarkdown from 'react-markdown'; import * as ReactMarkdown from 'react-markdown';
// TODO: Use css-modules instead of 'import' // TODO: Use css-modules instead of 'import'
import './styles.scss'; import './styles.scss';
import {
CANCEL_BUTTON_TEXT,
REFRESH_BUTTON_TEXT,
REFRESH_MESSAGE,
UPDATE_BUTTON_TEXT
} from './constants';
import { EditableSectionChildProps } from 'components/TableDetail/EditableSection';
export interface StateFromProps { export interface StateFromProps {
refreshValue?: string; refreshValue?: string;
...@@ -21,19 +27,15 @@ export interface ComponentProps { ...@@ -21,19 +27,15 @@ export interface ComponentProps {
value?: string; value?: string;
} }
export type EditableTextProps = ComponentProps & DispatchFromProps & StateFromProps; export type EditableTextProps = ComponentProps & DispatchFromProps & StateFromProps & EditableSectionChildProps;
interface EditableTextState { interface EditableTextState {
editable: boolean;
inEditMode: boolean;
value?: string; value?: string;
refreshValue?: string;
isDisabled: boolean; isDisabled: boolean;
} }
class EditableText extends React.Component<EditableTextProps, EditableTextState> { class EditableText extends React.Component<EditableTextProps, EditableTextState> {
private textAreaRef; readonly textAreaRef;
private editAnchorRef;
public static defaultProps: EditableTextProps = { public static defaultProps: EditableTextProps = {
editable: true, editable: true,
...@@ -43,136 +45,113 @@ class EditableText extends React.Component<EditableTextProps, EditableTextState> ...@@ -43,136 +45,113 @@ class EditableText extends React.Component<EditableTextProps, EditableTextState>
value: '', value: '',
}; };
static getDerivedStateFromProps(nextProps, prevState) {
const { refreshValue } = nextProps;
return { refreshValue };
}
constructor(props) { constructor(props) {
super(props); super(props);
this.textAreaRef = React.createRef(); this.textAreaRef = React.createRef();
this.editAnchorRef = React.createRef();
this.state = { this.state = {
editable: props.editable,
inEditMode: false,
isDisabled: false, isDisabled: false,
value: props.value, value: props.value,
refreshValue: props.value,
}; };
} }
componentDidUpdate() { componentDidUpdate(prevProps) {
const { isDisabled, inEditMode, refreshValue, value } = this.state; if (!this.props.isEditing) return;
const textArea = this.textAreaRef.current; if (!prevProps.isEditing) {
if (!inEditMode) return; const textArea = this.textAreaRef.current;
if (textArea) {
autosize(textArea); autosize(textArea);
if (refreshValue && refreshValue !== value && !isDisabled) { textArea.focus();
}
if (this.props.getLatestValue) {
this.props.getLatestValue();
}
} else if (this.props.refreshValue !== this.state.value && !this.state.isDisabled) {
// disable the component if a refresh is needed // disable the component if a refresh is needed
this.setState({ isDisabled: true }) this.setState({ isDisabled: true })
} else if (textArea) {
// when entering edit mode, place focus in the textarea
textArea.focus();
} }
} }
exitEditMode = () => { exitEditMode = () => {
this.setState({ isDisabled: false, inEditMode: false, refreshValue: '' }); this.props.setEditMode(false);
}; };
enterEditMode = () => { enterEditMode = () => {
if (this.props.getLatestValue) { this.props.setEditMode(true);
const onSuccessCallback = () => { this.setState({ inEditMode: true }); };
this.props.getLatestValue(onSuccessCallback, null);
} else {
this.setState({ inEditMode: true });
}
}; };
refreshText = () => { refreshText = () => {
this.setState({value: this.state.refreshValue, isDisabled: false, inEditMode: false, refreshValue: undefined }); this.setState({ value: this.props.refreshValue, isDisabled: false });
const textArea = this.textAreaRef.current;
if (textArea) {
textArea.value = this.props.refreshValue;
autosize.update(textArea);
}
}; };
updateText = () => { updateText = () => {
const newValue = this.textAreaRef.current.value; const newValue = this.textAreaRef.current.value;
const onSuccessCallback = () => { this.setState({value: newValue, inEditMode: false, refreshValue: undefined }); }; const onSuccessCallback = () => {
this.props.setEditMode(false);
this.setState({ value: newValue });
};
const onFailureCallback = () => { this.exitEditMode(); }; const onFailureCallback = () => { this.exitEditMode(); };
this.props.onSubmitValue(newValue, onSuccessCallback, onFailureCallback); this.props.onSubmitValue(newValue, onSuccessCallback, onFailureCallback);
}; };
getAnchorTarget = () => {
return this.editAnchorRef.current;
};
getTextAreaTarget = () => {
return this.textAreaRef.current;
};
render() { render() {
if (!this.state.editable) { if (!this.props.isEditing) {
return (
<div id='editable-container' className='editable-container'>
<div id='editable-text' className='editable-text'>
<ReactMarkdown source={ this.state.value }/>
</div>
</div>
);
}
if (!this.state.inEditMode || (this.state.inEditMode && this.state.isDisabled)) {
return ( return (
<div id='editable-container' className='editable-container'> <div className="editable-text">
<Overlay <div className="markdown-wrapper">
placement='top'
show={ this.state.isDisabled }
target={ this.getAnchorTarget }
>
<Tooltip id='error-tooltip'>
<div className="error-tooltip">
<text>This text is out of date, please refresh the component</text>
<button onClick={ this.refreshText } className="btn btn-flat-icon">
<img className='icon icon-refresh'/>
</button>
</div>
</Tooltip>
</Overlay>
<div id='editable-text' className="editable-text">
<ReactMarkdown source={ this.state.value }/> <ReactMarkdown source={ this.state.value }/>
<a className={ "edit-link" + (this.state.value ? "" : " no-value") } </div>
{
this.props.editable && !this.state.value &&
<a className="edit-link"
href="JavaScript:void(0)" href="JavaScript:void(0)"
onClick={ this.enterEditMode } onClick={ this.enterEditMode }
ref={ this.editAnchorRef } >Add Description</a>
> }
{
this.state.value ? "edit" : "Add Description"
}
</a>
</div> </div>
</div>
); );
} }
return ( return (
<div id='editable-container' className='editable-container'> <div className="editable-text">
<textarea <textarea
id='editable-textarea' className="editable-textarea"
className='editable-textarea'
rows={ 2 } rows={ 2 }
maxLength={ this.props.maxLength } maxLength={ this.props.maxLength }
ref={ this.textAreaRef } ref={ this.textAreaRef }
defaultValue={ this.state.value } defaultValue={ this.state.value }
disabled={ this.state.isDisabled }
/> />
<Overlay <div className="editable-textarea-controls">
placement='top' {
show={ true } this.state.isDisabled &&
target={ this.getTextAreaTarget } <>
> <h2 className="label label-danger refresh-message">
<Tooltip id='save-tooltip'> { REFRESH_MESSAGE }
<button id='cancel' onClick={this.exitEditMode}>Cancel</button> </h2>
<button id='save' onClick={this.updateText}>Save</button> <button className="btn btn-primary refresh-button" onClick={ this.refreshText } >
</Tooltip> <img className="icon icon-refresh"/>
</Overlay> { REFRESH_BUTTON_TEXT }
</button>
</>
}
{
!this.state.isDisabled &&
<button className="btn btn-primary update-button" onClick={ this.updateText }>
{ UPDATE_BUTTON_TEXT }
</button>
}
<button className="btn btn-default cancel-button" onClick={ this.exitEditMode }>
{ CANCEL_BUTTON_TEXT }
</button>
</div>
</div> </div>
); );
} }
......
@import 'variables'; @import 'variables';
.editable-container { .editable-text {
display: flex;
font-size: 16px;
color: $text-secondary;
&:hover {
.edit-link {
opacity: 1;
}
}
.edit-link {
opacity: 0;
text-decoration: none;
&.no-value {
opacity: 1;
}
}
.editable-text { // React-Markdown
flex-grow: 0.1; .markdown-wrapper {
font-size: 14px;
word-break: break-word; word-break: break-word;
// React-Markdown wraps editable text with a paragraph // Remove extra margin form text elements
> p { > p, ul, ol {
margin: 0; margin: 0;
} }
// Restrict max size of header elements
h1, h2, h3 {
font-size: 20px;
font-weight: $font-weight-header-bold;
line-height: 28px;
}
}
.edit-link {
text-decoration: none;
} }
.editable-textarea { .editable-textarea {
padding: 0; background: $body-bg-secondary;
border-radius: 4px;
border-color: $stroke;
min-width: 300px;
max-width: 600px;
outline: none; outline: none;
border: 0; padding: 8px;
background: $brand-color-1; resize: none;
caret-color: $brand-color-4;
&:disabled {
background: $body-bg-tertiary;
}
} }
.editable-textarea:focus-within { .editable-textarea-controls {
outline: none; margin: 16px 0 8px;
background: $white;
.btn {
margin-right: 8px;
}
.label.label-danger {
display: block;
margin-bottom: 16px;
}
} }
} }
import * as React from 'react'; import * as React from 'react';
import * as ReactMarkdown from 'react-markdown'; import * as ReactMarkdown from 'react-markdown';
import * as autosize from 'autosize';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { Overlay, Popover, Tooltip } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import EditableText, { EditableTextProps } from '../'; import EditableText, { EditableTextProps } from '../';
import {
CANCEL_BUTTON_TEXT,
REFRESH_BUTTON_TEXT,
REFRESH_MESSAGE,
UPDATE_BUTTON_TEXT
} from 'components/common/EditableText/constants';
describe('EditableText', () => { describe('EditableText', () => {
let props: EditableTextProps; const setup = (propOverrides?: Partial<EditableTextProps>) => {
let subject; const props = {
editable: true,
beforeEach(() => { isEditing: true,
props = { maxLength: 4000,
editable: true, onSubmitValue: jest.fn(),
maxLength: 4000, getLatestValue: jest.fn(),
onSubmitValue: jest.fn(), refreshValue: '',
getLatestValue: jest.fn(), setEditMode: jest.fn(),
refreshValue: 'newValue', value: 'currentValue',
value: 'currentValue', ...propOverrides,
}; };
subject = shallow(<EditableText {...props} />); const wrapper = shallow<EditableText>(<EditableText {...props} />);
return { props, wrapper };
};
const { props, wrapper } = setup();
const instance = wrapper.instance();
const setEditModeSpy = jest.spyOn(props, "setEditMode");
describe('componentDidUpdate', () => {
// TODO - figure out how to spy on library
// it('calls autosize on the text area ', () => {
// const autosizeSpy = jest.spyOn(autosize, 'default');
// });
// TODO - test getLatestValue call
// TODO - figure out how to use refs in jest
// it('calls focus on the text area', () => {
// const textareaFocusSpy = jest.spyOn(instance.textAreaRef.current, 'focus');
// wrapper.setState({ inEditMode: true });
// expect(textareaFocusSpy).toHaveBeenCalled();
// });
it('sets isDisabled:true when refresh value does not equal value', () => {
const { wrapper, props } = setup({
isEditing: true,
refreshValue: 'new value',
value: 'different value',
});
wrapper.instance().componentDidUpdate(props)
const state = wrapper.state();
expect(state.isDisabled).toBe(true);
}); });
});
describe('render', () => {
it('renders value in a div if not editable', () => {
props.editable = false;
/* Note: Do not copy this pattern, for some reason setProps is not updating the content in this case */
subject = shallow(<EditableText {...props} />);
expect(subject.find('div#editable-text').find(ReactMarkdown).prop('source')).toEqual(props.value);
});
describe('renders correctly if !this.state.inEditMode', () => { describe('exitEditMode', () => {
beforeEach(() => { it('updates the state', () => {
subject.setState({ inEditMode: false }); setEditModeSpy.mockClear();
}); instance.exitEditMode();
it('renders value as first child', () => { expect(setEditModeSpy).toHaveBeenCalledWith(false);
expect(subject.find('#editable-text').children().first().prop('source')).toEqual(props.value); expect(wrapper.state()).toMatchObject({
}); isDisabled: false,
});
it('renders edit link to enterEditMode', () => { })
expect(subject.find('#editable-text').find('a').props().onClick).toEqual(subject.instance().enterEditMode); });
});
it('renders edit link with correct class if state.value exists', () => { describe('enterEditMode', () => {
expect(subject.find('#editable-text').find('a').props().className).toEqual('edit-link');
});
it('renders edit link with correct text if state.value exists', () => {
expect(subject.find('#editable-text').find('a').text()).toEqual('edit');
});
it('renders edit link with correct class if state.value does not exist', () => {
subject.setState({ value: null });
expect(subject.find('#editable-text').find('a').props().className).toEqual('edit-link no-value');
});
it('renders edit link with correct text if state.value does not exist', () => {
subject.setState({ value: null });
expect(subject.find('#editable-text').find('a').text()).toEqual('Add Description');
});
});
/* TODO: The use of ReactDOM.findDOMNode is difficult to test, preventing further coverage
describe('renders correctly if this.state.inEditMode && this.state.isDisabled', () => {
beforeEach(() => {
subject.setState({ inEditMode: true, isDisabled: true });
});
it('renders value as first child', () => {
expect(subject.find('#editable-text').props().children[0]).toEqual(props.value);
});
it('renders edit link to enterEditMode', () => {
expect(subject.find('#editable-text').find('a').props().onClick).toEqual(subject.instance().enterEditMode);
});
it('renders edit link with correct class if state.value exists', () => {
expect(subject.find('#editable-text').find('a').props().className).toEqual('edit-link');
});
it('renders edit link with correct text if state.value exists', () => {
expect(subject.find('#editable-text').find('a').text()).toEqual('edit');
});
it('renders edit link with correct class if state.value does not exist', () => {
subject.setState({ value: null });
expect(subject.find('#editable-text').find('a').props().className).toEqual('edit-link no-value');
});
it('renders edit link with correct text if state.value does not exist', () => {
subject.setState({ value: null });
expect(subject.find('#editable-text').find('a').text()).toEqual('Add Description');
});
// TODO: Test Overlay & Tooltip
});
*/
// TODO: Test rendering of textarea with Overlay & Tooltip it('it calls setEditMode with a value of true', () => {
const { props, wrapper } = setup();
const instance = wrapper.instance();
const setEditModeSpy = jest.spyOn(props, 'setEditMode');
instance.enterEditMode();
expect(setEditModeSpy).toHaveBeenCalledWith(true);
}); });
});
describe('refreshText', () => {
it('updates the state', () => {
const setStateSpy = jest.spyOn(instance, 'setState');
instance.refreshText();
expect(setStateSpy).toHaveBeenCalledWith({
value: props.refreshValue,
isDisabled: false,
refreshValue: undefined
});
})
});
// TODO - Figure out how to use refs in jest
// describe('updateText', () => {
// it('calls onSubmitValue', () => {
// const onSubmitValueSpy = jest.spyOn(props, 'onSubmitValue');
// instance.updateText();
// expect(onSubmitValueSpy).toHaveBeenCalled();
// })
// });
describe('render', () => {
describe('not in edit mode', () => {
const { props, wrapper } = setup({
isEditing: false,
value: '',
});
const instance = wrapper.instance();
it('renders a ReactMarkdown component', () => {
const markdown = wrapper.find(ReactMarkdown);
expect(markdown.exists()).toBe(true);
expect(markdown.props()).toMatchObject({ source: wrapper.state().value });
});
it('renders an edit link if it is editable and the text is empty', () => {
const editLink = wrapper.find('.edit-link');
expect(editLink.exists()).toBe(true);
expect(editLink.props()).toMatchObject({
onClick: instance.enterEditMode,
})
});
it('does not render an edit link if it is not editable', () => {
const { wrapper } = setup({ editable: false });
const editLink = wrapper.find('.edit-link');
expect(editLink.exists()).toBe(false);
});
});
describe('in edit mode', () => {
it('renders a textarea ', () => {
const textarea = wrapper.find('textarea');
expect(textarea.exists()).toBe(true);
expect(textarea.props()).toMatchObject({
maxLength: props.maxLength,
defaultValue: wrapper.state().value,
disabled: wrapper.state().isDisabled,
});
});
it('when disabled, renders the refresh message and button', () => {
wrapper.setState({ isDisabled: true });
const refreshMessage = wrapper.find('.refresh-message');
expect(refreshMessage.text()).toBe(REFRESH_MESSAGE);
// TODO: Test component methods const refreshButton = wrapper.find('.refresh-button');
expect(refreshButton.text()).toMatch(REFRESH_BUTTON_TEXT);
expect(refreshButton.props()).toMatchObject({
onClick: instance.refreshText,
});
});
it('when not disabled, renders the update text button', () => {
wrapper.setState({ isDisabled: false });
const updateButton = wrapper.find('.update-button');
expect(updateButton.text()).toMatch(UPDATE_BUTTON_TEXT);
expect(updateButton.props()).toMatchObject({
onClick: instance.updateText,
})
});
it('renders the cancel button', () => {
const cancelButton = wrapper.find('.cancel-button');
expect(cancelButton.text()).toMatch(CANCEL_BUTTON_TEXT);
expect(cancelButton.props()).toMatchObject({
onClick: instance.exitEditMode,
})
});
})
});
}); });
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
flex-grow: 1; flex-grow: 1;
} }
} }
.content { .content {
margin-top: 5px; margin-top: 5px;
} }
...@@ -19,7 +20,6 @@ ...@@ -19,7 +20,6 @@
margin-top: -4px; margin-top: -4px;
margin-left: 4px; margin-left: 4px;
} }
}
button.edit-button { button.edit-button {
...@@ -50,11 +50,33 @@ button.active-edit-button:focus { ...@@ -50,11 +50,33 @@ button.active-edit-button:focus {
outline: none; outline: none;
box-shadow: 0 0 1pt 1pt $brand-color-4 box-shadow: 0 0 1pt 1pt $brand-color-4
} }
button.edit-button:hover {
background-color: $brand-color-4;
}
@media (min-width: $screen-md-min) {
button.edit-button,
button.active-edit-button { button.active-edit-button {
float: right; height: 24px;
width: 24px;
border: none;
border-radius: 5px;
background-color: $brand-color-1;
background-image: url('/static/images/icons/Edit_Inverted.svg');
-moz-box-shadow: inset 1px 1px 2px $brand-color-4;
-webkit-box-shadow: inset 1px 1px 2px $brand-color-4;
box-shadow: inset 1px 1px 2px $brand-color-4;
}
button.edit-button:focus,
button.active-edit-button:focus {
outline: none;
box-shadow: 0 0 1pt 1pt $brand-color-4
}
@media (min-width: $screen-md-min) {
button.edit-button,
button.active-edit-button {
float: right;
}
} }
} }
...@@ -109,7 +109,7 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> { ...@@ -109,7 +109,7 @@ export class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
}; };
render() { render() {
const inputClass = `${this.props.size === SIZE_SMALL ? 'h3 small' : 'h2 large'} search-bar-input form-control`; const inputClass = `${this.props.size === SIZE_SMALL ? 'title-2 small' : 'h2 large'} search-bar-input form-control`;
const searchButtonClass = `btn btn-flat-icon search-button ${this.props.size === SIZE_SMALL ? 'small' : 'large'}`; const searchButtonClass = `btn btn-flat-icon search-button ${this.props.size === SIZE_SMALL ? 'small' : 'large'}`;
const subTextClass = `subtext body-secondary-3 ${this.state.subTextClassName}`; const subTextClass = `subtext body-secondary-3 ${this.state.subTextClassName}`;
......
...@@ -40,6 +40,7 @@ ...@@ -40,6 +40,7 @@
} }
&.small { &.small {
border: none;
height: 36px; height: 36px;
padding: 6px 6px 6px 36px; padding: 6px 6px 6px 36px;
} }
...@@ -54,7 +55,7 @@ ...@@ -54,7 +55,7 @@
} }
@media (max-width: $screen-md-max) { @media (max-width: $screen-md-max) {
.search-button { .search-button.large {
left: 16px; left: 16px;
top: 18px; top: 18px;
} }
......
...@@ -269,6 +269,19 @@ describe('mapDispatchToProps', () => { ...@@ -269,6 +269,19 @@ describe('mapDispatchToProps', () => {
}); });
}); });
describe('mapDispatchToProps', () => {
let dispatch;
let result;
beforeAll(() => {
dispatch = jest.fn(() => Promise.resolve());
result = mapDispatchToProps(dispatch);
});
it('sets searchAll on the props', () => {
expect(result.submitSearch).toBeInstanceOf(Function);
});
});
describe('mapStateToProps', () => { describe('mapStateToProps', () => {
let result; let result;
beforeAll(() => { beforeAll(() => {
......
...@@ -97,6 +97,18 @@ interface MailClientFeaturesConfig { ...@@ -97,6 +97,18 @@ interface MailClientFeaturesConfig {
notificationsEnabled: boolean; notificationsEnabled: boolean;
} }
/**
* MailClientFeaturesConfig - Enable/disable UI features with a dependency on
* configuring a custom mail client.
*
* feedbackEnabled - Enables the feedback feature UI
* notificationsEnabled - Enables any UI related to sending notifications to users
*/
interface MailClientFeaturesConfig {
feedbackEnabled: boolean;
notificationsEnabled: boolean;
}
/** /**
* TableProfileConfig - Customize the "Table Profile" section of the "Table Details" page. * TableProfileConfig - Customize the "Table Profile" section of the "Table Details" page.
* *
......
...@@ -68,16 +68,11 @@ export function getTableDescription(tableData: TableMetadata) { ...@@ -68,16 +68,11 @@ export function getTableDescription(tableData: TableMetadata) {
} }
export function updateTableDescription(description: string, tableData: TableMetadata) { export function updateTableDescription(description: string, tableData: TableMetadata) {
if (description.length === 0) { return axios.put(`${API_PATH}/put_table_description`, {
throw new Error(); description,
} key: tableData.key,
else { source: 'user',
return axios.put(`${API_PATH}/put_table_description`, { });
description,
key: tableData.key,
source: 'user',
});
}
} }
export function getTableOwners(tableKey: string) { export function getTableOwners(tableKey: string) {
...@@ -127,18 +122,13 @@ export function getColumnDescription(columnIndex: number, tableData: TableMetada ...@@ -127,18 +122,13 @@ export function getColumnDescription(columnIndex: number, tableData: TableMetada
} }
export function updateColumnDescription(description: string, columnIndex: number, tableData: TableMetadata) { export function updateColumnDescription(description: string, columnIndex: number, tableData: TableMetadata) {
if (description.length === 0) { const columnName = tableData.columns[columnIndex].name;
throw new Error(); return axios.put(`${API_PATH}/put_column_description`, {
} description,
else { column_name: columnName,
const columnName = tableData.columns[columnIndex].name; key: tableData.key,
return axios.put(`${API_PATH}/put_column_description`, { source: 'user',
description, });
column_name: columnName,
key: tableData.key,
source: 'user',
});
}
} }
export function getLastIndexed() { export function getLastIndexed() {
......
...@@ -16,26 +16,26 @@ interface PreviewDataItem { ...@@ -16,26 +16,26 @@ interface PreviewDataItem {
id: string; id: string;
} }
interface TableColumnStats { export interface TableColumnStats {
stat_type: string; stat_type: string;
stat_val: string; stat_val: string;
/** The start date of the stat aggregation period, in unix epoch time */ /** The start date of the stat aggregation period, in unix epoch time */
start_epoch: string; start_epoch: number;
/** The end date of the stat aggregation period, in unix epoch time */ /** The end date of the stat aggregation period, in unix epoch time */
end_epoch: string; end_epoch: number;
} }
interface TableReader { export interface TableReader {
read_count: number; read_count: number;
reader: User; reader: User;
} }
interface TableSource { export interface TableSource {
source: string | null; source: string | null;
source_type: string; source_type: string;
} }
interface TableWriter { export interface TableWriter {
application_url: string; application_url: string;
description: string; description: string;
id: string; id: string;
......
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