Unverified Commit 58da1550 authored by Tamika Tannis's avatar Tamika Tannis Committed by GitHub

feat: Improve Nested Column Types UI (#627)

* feat: Create ColumnType component (#604)

* Create ColumnType component
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Update Modal UI
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Code cleanup; Add a test file

* Prevent ColumnListItem expand/collapse from being triggered

* Lint fix
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* feat: Parse column types (#611)

* WIP: Create a parser & render parsed text
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Cleanup logic
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* More cleanup
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Lint fix
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Code cleanup
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Code cleanup
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Code cleanup
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Use more appropriate elements; Fix typo
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Parser tests
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Fix button; Fix test; Remove obsolete style
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Fix duplicate test name
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* style: Improve UI styles and interactions (#617)

* Vertically center modal
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Match design font specifications
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Miscellaneous cleanup
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Use variables
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* test: Improves unit tests for ColumnType + QA fixes (#625)

* Parser tests
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Updates from design qa
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Improve ColumnType tests
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* log support
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Code cleanup
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Fix some lint warning
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>

* Betterer update
Signed-off-by: 's avatarTamika Tannis <ttannis@lyft.com>
parent 3ce62cc3
...@@ -73,11 +73,11 @@ exports[`strict null compilation`] = { ...@@ -73,11 +73,11 @@ exports[`strict null compilation`] = {
"js/components/SearchPage/index.tsx:1421221531": [ "js/components/SearchPage/index.tsx:1421221531": [
[175, 11, 12, "Property \'filterSections\' is missing in type \'{}\' but required in type \'Readonly<Pick<StateFromProps, \\"filterSections\\">>\'.", "250899467"] [175, 11, 12, "Property \'filterSections\' is missing in type \'{}\' but required in type \'Readonly<Pick<StateFromProps, \\"filterSections\\">>\'.", "250899467"]
], ],
"js/components/TableDetail/ColumnList/index.tsx:2024292996": [ "js/components/TableDetail/ColumnList/index.tsx:355148301": [
[20, 6, 7, "Object is possibly \'undefined\'.", "3718923584"], [22, 6, 7, "Object is possibly \'undefined\'.", "3718923584"],
[25, 21, 7, "Object is possibly \'undefined\'.", "3718923584"], [27, 21, 7, "Object is possibly \'undefined\'.", "3718923584"],
[30, 6, 8, "Type \'string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "1427606500"], [33, 6, 8, "Type \'string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "1427606500"],
[31, 6, 7, "Type \'string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "3817619378"] [34, 6, 7, "Type \'string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "3817619378"]
], ],
"js/components/TableDetail/ColumnStats/index.spec.tsx:1228258528": [ "js/components/TableDetail/ColumnStats/index.spec.tsx:1228258528": [
[90, 39, 4, "Argument of type \'null\' is not assignable to parameter of type \'number\'.", "2087897566"] [90, 39, 4, "Argument of type \'null\' is not assignable to parameter of type \'number\'.", "2087897566"]
...@@ -86,7 +86,7 @@ exports[`strict null compilation`] = { ...@@ -86,7 +86,7 @@ exports[`strict null compilation`] = {
[141, 17, 16, "Object is possibly \'undefined\'.", "3451845569"], [141, 17, 16, "Object is possibly \'undefined\'.", "3451845569"],
[257, 30, 34, "Argument of type \'number | null\' is not assignable to parameter of type \'number\'.\\n Type \'null\' is not assignable to type \'number\'.", "3967943985"] [257, 30, 34, "Argument of type \'number | null\' is not assignable to parameter of type \'number\'.\\n Type \'null\' is not assignable to type \'number\'.", "3967943985"]
], ],
"js/components/TableDetail/SourceLink/index.spec.tsx:3683846951": [ "js/components/TableDetail/SourceLink/index.spec.tsx:2646548231": [
[39, 21, 100, "Object is possibly \'null\'.", "1316242242"] [39, 21, 100, "Object is possibly \'null\'.", "1316242242"]
], ],
"js/components/TableDetail/TableDashboardResourceList/index.tsx:3147978263": [ "js/components/TableDetail/TableDashboardResourceList/index.tsx:3147978263": [
...@@ -107,19 +107,19 @@ exports[`strict null compilation`] = { ...@@ -107,19 +107,19 @@ exports[`strict null compilation`] = {
"js/components/TableDetail/index.spec.tsx:2169788066": [ "js/components/TableDetail/index.spec.tsx:2169788066": [
[32, 4, 8, "Argument of type \'Partial<Location<{} | null | undefined>> | undefined\' is not assignable to parameter of type \'Partial<Location<{} | null | undefined>>\'.\\n Type \'undefined\' is not assignable to type \'Partial<Location<{} | null | undefined>>\'.", "2700611480"] [32, 4, 8, "Argument of type \'Partial<Location<{} | null | undefined>> | undefined\' is not assignable to parameter of type \'Partial<Location<{} | null | undefined>>\'.\\n Type \'undefined\' is not assignable to type \'Partial<Location<{} | null | undefined>>\'.", "2700611480"]
], ],
"js/components/TableDetail/index.tsx:1105490806": [ "js/components/TableDetail/index.tsx:3223260709": [
[153, 10, 13, "Type \'null\' is not assignable to type \'((newValue: string, onSuccess?: (() => any) | undefined, onFailure?: (() => any) | undefined) => void) | undefined\'.", "67794331"], [153, 10, 13, "Type \'null\' is not assignable to type \'((newValue: string, onSuccess?: (() => any) | undefined, onFailure?: (() => any) | undefined) => void) | undefined\'.", "67794331"],
[164, 6, 7, "Type \'Element\' is not assignable to type \'never\'.", "3716929964"], [165, 6, 7, "Type \'Element\' is not assignable to type \'never\'.", "3716929964"],
[171, 6, 3, "Type \'string\' is not assignable to type \'never\'.", "193424690"], [173, 6, 3, "Type \'string\' is not assignable to type \'never\'.", "193424690"],
[172, 6, 5, "Type \'string\' is not assignable to type \'never\'.", "183222373"], [174, 6, 5, "Type \'string\' is not assignable to type \'never\'.", "183222373"],
[182, 8, 7, "Type \'Element\' is not assignable to type \'never\'.", "3716929964"], [184, 8, 7, "Type \'Element\' is not assignable to type \'never\'.", "3716929964"],
[183, 11, 26, "Type \'{ itemsPerPage: number; source: string; }\' is missing the following properties from type \'Readonly<Pick<TableDashboardResourceListProps, \\"source\\" | \\"isLoading\\" | \\"dashboards\\" | \\"errorText\\" | \\"itemsPerPage\\"> & OwnProps>\': isLoading, dashboards, errorText", "2224258167"], [185, 11, 26, "Type \'{ itemsPerPage: number; source: string; }\' is missing the following properties from type \'Readonly<Pick<TableDashboardResourceListProps, \\"source\\" | \\"isLoading\\" | \\"dashboards\\" | \\"errorText\\" | \\"itemsPerPage\\"> & OwnProps>\': isLoading, dashboards, errorText", "2224258167"],
[188, 8, 3, "Type \'string\' is not assignable to type \'never\'.", "193424690"], [190, 8, 3, "Type \'string\' is not assignable to type \'never\'.", "193424690"],
[189, 8, 5, "Type \'string | Element\' is not assignable to type \'never\'.\\n Type \'string\' is not assignable to type \'never\'.", "183222373"], [191, 8, 5, "Type \'string | Element\' is not assignable to type \'never\'.\\n Type \'string\' is not assignable to type \'never\'.", "183222373"],
[263, 16, 7, "Type \'string | null\' is not assignable to type \'string | undefined\'.\\n Type \'null\' is not assignable to type \'string | undefined\'.", "3817619378"], [265, 16, 7, "Type \'string | null\' is not assignable to type \'string | undefined\'.\\n Type \'null\' is not assignable to type \'string | undefined\'.", "3817619378"],
[305, 20, 35, "Argument of type \'ProgrammaticDescription[] | undefined\' is not assignable to parameter of type \'ProgrammaticDescription[]\'.\\n Type \'undefined\' is not assignable to type \'ProgrammaticDescription[]\'.", "4249007202"], [307, 20, 35, "Argument of type \'ProgrammaticDescription[] | undefined\' is not assignable to parameter of type \'ProgrammaticDescription[]\'.\\n Type \'undefined\' is not assignable to type \'ProgrammaticDescription[]\'.", "4249007202"],
[319, 20, 36, "Argument of type \'ProgrammaticDescription[] | undefined\' is not assignable to parameter of type \'ProgrammaticDescription[]\'.\\n Type \'undefined\' is not assignable to type \'ProgrammaticDescription[]\'.", "2770872537"], [321, 20, 36, "Argument of type \'ProgrammaticDescription[] | undefined\' is not assignable to parameter of type \'ProgrammaticDescription[]\'.\\n Type \'undefined\' is not assignable to type \'ProgrammaticDescription[]\'.", "2770872537"],
[324, 16, 36, "Argument of type \'ProgrammaticDescription[] | undefined\' is not assignable to parameter of type \'ProgrammaticDescription[]\'.\\n Type \'undefined\' is not assignable to type \'ProgrammaticDescription[]\'.", "2776557981"] [326, 16, 36, "Argument of type \'ProgrammaticDescription[] | undefined\' is not assignable to parameter of type \'ProgrammaticDescription[]\'.\\n Type \'undefined\' is not assignable to type \'ProgrammaticDescription[]\'.", "2776557981"]
], ],
"js/components/common/Announcements/AnnouncementsList/index.spec.tsx:1395073325": [ "js/components/common/Announcements/AnnouncementsList/index.spec.tsx:1395073325": [
[94, 23, 124, "Object is possibly \'null\'.", "4248337497"] [94, 23, 124, "Object is possibly \'null\'.", "4248337497"]
...@@ -163,7 +163,7 @@ exports[`strict null compilation`] = { ...@@ -163,7 +163,7 @@ exports[`strict null compilation`] = {
"js/components/common/EntityCard/EntityCardSection/index.tsx:1592385405": [ "js/components/common/EntityCard/EntityCardSection/index.tsx:1592385405": [
[40, 4, 23, "Object is possibly \'null\'.", "1725552512"] [40, 4, 23, "Object is possibly \'null\'.", "1725552512"]
], ],
"js/components/common/Flag/index.tsx:128873066": [ "js/components/common/Flag/index.tsx:2997458704": [
[44, 27, 8, "Argument of type \'string | null\' is not assignable to parameter of type \'string\'.\\n Type \'null\' is not assignable to type \'string\'.", "4036080041"] [44, 27, 8, "Argument of type \'string | null\' is not assignable to parameter of type \'string\'.\\n Type \'null\' is not assignable to type \'string\'.", "4036080041"]
], ],
"js/components/common/OwnerEditor/index.spec.tsx:3936675418": [ "js/components/common/OwnerEditor/index.spec.tsx:3936675418": [
......
...@@ -3,6 +3,14 @@ ...@@ -3,6 +3,14 @@
@import 'variables'; @import 'variables';
// Space Mono
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: $font-weight-header-regular;
src: url('/static/fonts/SpaceMono-Regular.ttf') format('truetype');
}
// Roboto // Roboto
@font-face { @font-face {
font-family: 'Roboto'; font-family: 'Roboto';
......
...@@ -72,8 +72,3 @@ ...@@ -72,8 +72,3 @@
margin: auto; margin: auto;
width: 24px; width: 24px;
} }
.column-type-popover {
max-width: 552px; // arbitrary 2x default
word-break: break-word;
}
...@@ -45,6 +45,7 @@ $font-weight-header-regular: 500 !default; ...@@ -45,6 +45,7 @@ $font-weight-header-regular: 500 !default;
$font-weight-header-bold: 700 !default; $font-weight-header-bold: 700 !default;
$font-family-monospace: 'Menlo-Bold', menlo, monospace !default; $font-family-monospace: 'Menlo-Bold', menlo, monospace !default;
$font-family-monospace-code: 'Space Mono', menlo, monospace !default;
$font-family-serif: georgia, 'Times New Roman', times, serif !default; $font-family-serif: georgia, 'Times New Roman', times, serif !default;
$font-size-small: 12px !default; $font-size-small: 12px !default;
...@@ -162,6 +163,8 @@ $w2-headline-line-height: 32px; ...@@ -162,6 +163,8 @@ $w2-headline-line-height: 32px;
$w3-headline-font-size: 22px; $w3-headline-font-size: 22px;
$w3-headline-line-height: 26px; $w3-headline-line-height: 26px;
$code-font-size: 12px;
$title-font-weight: $font-weight-body-bold; $title-font-weight: $font-weight-body-bold;
$subtitle-font-weight: $font-weight-body-semi-bold; $subtitle-font-weight: $font-weight-body-semi-bold;
$body-font-weight: $font-weight-body-regular; $body-font-weight: $font-weight-body-regular;
...@@ -9,12 +9,14 @@ import './styles.scss'; ...@@ -9,12 +9,14 @@ import './styles.scss';
interface ColumnListProps { interface ColumnListProps {
columns?: TableColumn[]; columns?: TableColumn[];
database: string;
editText?: string; editText?: string;
editUrl?: string; editUrl?: string;
} }
const ColumnList: React.FC<ColumnListProps> = ({ const ColumnList: React.FC<ColumnListProps> = ({
columns, columns,
database,
editText, editText,
editUrl, editUrl,
}: ColumnListProps) => { }: ColumnListProps) => {
...@@ -27,6 +29,7 @@ const ColumnList: React.FC<ColumnListProps> = ({ ...@@ -27,6 +29,7 @@ const ColumnList: React.FC<ColumnListProps> = ({
<ColumnListItem <ColumnListItem
key={`column:${index}`} key={`column:${index}`}
data={entry} data={entry}
database={database}
index={index} index={index}
editText={editText} editText={editText}
editUrl={editUrl} editUrl={editUrl}
......
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import { mount } from 'enzyme';
import { Modal } from 'react-bootstrap';
import * as UtilMethods from 'ducks/utilMethods';
import ColumnType, { ColumnTypeProps } from '.';
const logClickSpy = jest.spyOn(UtilMethods, 'logClick');
logClickSpy.mockImplementation(() => null);
const setup = (propOverrides?: Partial<ColumnTypeProps>) => {
const props = {
columnName: 'test',
database: 'presto',
type:
'row(test_id varchar,test2 row(test2_id varchar,started_at timestamp,ended_at timestamp))',
...propOverrides,
};
const wrapper = mount<ColumnType>(<ColumnType {...props} />);
return { wrapper, props };
};
const { wrapper, props } = setup();
describe('ColumnType', () => {
describe('lifecycle', () => {
describe('when clicking on column-type-btn', () => {
it('should call showModal on the instance', () => {
const clickSpy = jest.spyOn(wrapper.instance(), 'showModal');
wrapper.instance().forceUpdate();
wrapper.find('.column-type-btn').simulate('click');
expect(clickSpy).toHaveBeenCalled();
});
it('should log the interaction', () => {
logClickSpy.mockClear();
wrapper.find('.column-type-btn').simulate('click');
expect(logClickSpy).toHaveBeenCalled();
});
});
});
describe('render', () => {
it('renders the column type string for simple types', () => {
const { wrapper, props } = setup({ type: 'varchar(32)' });
expect(wrapper.find('.column-type').text()).toBe(props.type);
});
describe('for nested types', () => {
it('renders the truncated column type string', () => {
const actual = wrapper.find('.column-type-btn').text();
const expected = 'row(...)';
expect(actual).toBe(expected);
});
describe('renders a modal', () => {
it('exists', () => {
const actual = wrapper.find(Modal).exists();
const expected = true;
expect(actual).toBe(expected);
});
it('renders props.type in modal body', () => {
const actual = wrapper.find('.sub-title').text();
const expected = props.columnName;
expect(actual).toBe(expected);
});
it('renders props.type in modal body', () => {
const actual = wrapper.find('.modal-body').text();
const expected = props.type;
expect(actual).toBe(expected);
});
});
});
});
});
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import { Modal, OverlayTrigger, Popover } from 'react-bootstrap';
import { logClick } from 'ducks/utilMethods';
import './styles.scss';
import {
getTruncatedText,
parseNestedType,
NestedType,
ParsedType,
} from './parser';
const CTA_TEXT = 'Click to see nested fields';
const MODAL_TITLE = 'Nested Type';
const TEXT_INDENT = 8;
export interface ColumnTypeProps {
columnName: string;
database: string;
type: string;
}
export interface ColumnTypeState {
showModal: boolean;
}
export class ColumnType extends React.Component<
ColumnTypeProps,
ColumnTypeState
> {
nestedType: NestedType | null;
constructor(props) {
super(props);
this.state = {
showModal: false,
};
const { database, type } = this.props;
this.nestedType = parseNestedType(type, database);
}
hideModal = (e) => {
this.stopPropagation(e);
this.setState({ showModal: false });
};
showModal = (e) => {
logClick(e);
this.stopPropagation(e);
this.setState({ showModal: true });
};
stopPropagation = (e) => {
if (e) {
e.stopPropagation();
}
};
createLineItem = (text: string, textIndent: number) => {
return (
<div key={`lineitem:${text}`} style={{ textIndent: `${textIndent}px` }}>
{text}
</div>
);
};
renderParsedChildren = (children: ParsedType[], level: number) => {
const textIndent = level * TEXT_INDENT;
return children.map((item) => {
if (typeof item === 'string') {
return this.createLineItem(item, textIndent);
}
return this.renderNestedType(item, level);
});
};
renderNestedType = (nestedType: NestedType, level: number = 0) => {
const { head, tail, children } = nestedType;
const textIndent = level * TEXT_INDENT;
return (
<div key={`nesteditem:${head}${tail}`}>
{this.createLineItem(head, textIndent)}
{this.renderParsedChildren(children, level + 1)}
{this.createLineItem(tail, textIndent)}
</div>
);
};
render = () => {
const { columnName, type } = this.props;
if (this.nestedType === null) {
return <p className="column-type">{type}</p>;
}
const popoverHover = (
<Popover
className="column-type-popover"
id={`column-type-popover:${columnName}`}
>
{CTA_TEXT}
</Popover>
);
return (
<div onClick={this.stopPropagation}>
<OverlayTrigger
trigger={['hover', 'focus']}
placement="top"
overlay={popoverHover}
rootClose
>
<button
data-type="column-type"
type="button"
className="column-type-btn"
onClick={this.showModal}
>
{getTruncatedText(this.nestedType)}
</button>
</OverlayTrigger>
<Modal
className="column-type-modal"
show={this.state.showModal}
onHide={this.hideModal}
>
<Modal.Header closeButton>
<Modal.Title>
<h5 className="main-title">{MODAL_TITLE}</h5>
<div className="sub-title">{columnName}</div>
</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className="column-type-modal-content">
{this.renderNestedType(this.nestedType)}
</div>
</Modal.Body>
</Modal>
</div>
);
};
}
export default ColumnType;
import * as Parser from './parser';
describe('getTruncatedText', () => {
it('returns correct text', () => {
const nestedType: Parser.NestedType = {
head: 'hello<',
children: ['how are you'],
tail: '>',
};
const expected = 'hello<...>';
expect(Parser.getTruncatedText(nestedType)).toEqual(expected);
});
it('returns correct text with delimeters removed', () => {
const nestedType: Parser.NestedType = {
head: 'hello<',
children: ['how are you'],
tail: '>,',
};
const expected = 'hello<...>';
expect(Parser.getTruncatedText(nestedType)).toEqual(expected);
});
});
describe('isNestedType', () => {
it('returns true for supported complex types', () => {
expect(Parser.isNestedType('struct<hello, goodbye>', 'hive')).toEqual(true);
});
it('returns false for unsupported complex types', () => {
expect(Parser.isNestedType('xyz<hello, goodbye>', 'hive')).toEqual(false);
});
it('returns false for unsupported databases', () => {
expect(Parser.isNestedType('struct<hello, goodbye>', 'xyz')).toEqual(false);
});
it('returns falsde for non-complex types', () => {
expect(Parser.isNestedType('string', 'hive')).toEqual(false);
});
});
describe('parseNestedType', () => {
it('returns null if not a complex type', () => {
expect(Parser.parseNestedType('test', 'hive')).toEqual(null);
});
describe('hive support', () => {
it('returns expected NestedType for nested structs', () => {
const columnType =
'array<struct<amount:bigint,column:struct<column_id:string,name:string,template:struct<code:string,currency:string>>,id:string>>';
const expected: Parser.NestedType = {
head: 'array<',
children: [
{
head: 'struct<',
children: [
'amount:bigint,',
{
head: 'column:struct<',
children: [
'column_id:string,',
'name:string,',
{
head: 'template:struct<',
children: ['code:string,', 'currency:string'],
tail: '>',
},
],
tail: '>,',
},
'id:string',
],
tail: '>',
},
],
tail: '>',
};
expect(Parser.parseNestedType(columnType, 'hive')).toEqual(expected);
});
});
describe('presto support', () => {
it('returns expected NestedType for row', () => {
const columnType =
'row("c0_test" timestamp(3),"c1" row("c2" timestamp(3),"c3_test" varchar,"c4" double,"c5" double,"c6" row("c7" varchar,"c8" varchar),"c9" row("c10" varchar,"c11" varchar,"c12" row("c13_id" varchar,"c14" varchar)))';
const expected: Parser.NestedType = {
head: 'row(',
children: [
'c0_test timestamp(3),',
{
head: 'c1 row(',
children: [
'c2 timestamp(3),',
'c3_test varchar,',
'c4 double,',
'c5 double,',
{
head: 'c6 row(',
children: ['c7 varchar,', 'c8 varchar'],
tail: '),',
},
{
head: 'c9 row(',
children: [
'c10 varchar,',
'c11 varchar,',
{
head: 'c12 row(',
children: ['c13_id varchar,', 'c14 varchar'],
tail: ')',
},
],
tail: ')',
},
],
tail: ')',
},
],
tail: ')',
};
expect(Parser.parseNestedType(columnType, 'presto')).toEqual(expected);
});
it('returns expected NestedType for array', () => {
const columnType =
'array(row("total" bigint,"currency" varchar,"status" varchar,"payments" array(row("method" varchar,"payment" varchar,"amount" bigint,"authed" bigint,"id" varchar)),"id" varchar,"line_items" array(row("type" varchar,"amount" bigint,"id" varchar))))';
const expected: Parser.NestedType = {
head: 'array(',
children: [
{
head: 'row(',
children: [
'total bigint,',
'currency varchar,',
'status varchar,',
{
head: 'payments array(',
children: [
{
head: 'row(',
children: [
'method varchar,',
'payment varchar,',
'amount bigint,',
'authed bigint,',
'id varchar',
],
tail: ')',
},
],
tail: '),',
},
'id varchar,',
{
head: 'line_items array(',
children: [
{
head: 'row(',
children: ['type varchar,', 'amount bigint,', 'id varchar'],
tail: ')',
},
],
tail: ')',
},
],
tail: ')',
},
],
tail: ')',
};
expect(Parser.parseNestedType(columnType, 'presto')).toEqual(expected);
});
});
});
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
export type ParsedType = string | NestedType;
export interface NestedType {
head: string;
tail: string;
children: ParsedType[];
}
enum DatabaseId {
Hive = 'hive',
Presto = 'presto',
}
const SUPPORTED_TYPES = {
// https://cwiki.apache.org/confluence/display/Hive/LanguageManual+Types#LanguageManualTypes-ComplexTypes
[DatabaseId.Hive]: ['array', 'map', 'struct', 'uniontype'],
// https://prestosql.io/docs/current/language/types.html#structural
[DatabaseId.Presto]: ['array', 'map', 'row'],
};
const OPEN_DELIMETERS = {
'(': ')',
'<': '>',
'[': ']',
};
const CLOSE_DELIMETERS = {
')': '(',
'>': '<',
']': '[',
};
const SEPARATOR_DELIMETER = ',';
/*
* Iterates through the columnType string and recursively creates a NestedType
*/
function parseNestedTypeHelper(
columnType: string,
startIndex: number = 0,
currentIndex: number = 0
): { nextStartIndex: number; results: ParsedType[] } {
const children: ParsedType[] = [];
while (currentIndex < columnType.length) {
const currentChar = columnType.charAt(currentIndex);
if (currentChar === SEPARATOR_DELIMETER) {
/* Case 1: End of non-nested item */
children.push(columnType.substring(startIndex, currentIndex + 1).trim());
startIndex = currentIndex + 1;
currentIndex = startIndex;
} else if (currentChar in CLOSE_DELIMETERS) {
/* Case 2: End of a nested item */
if (startIndex !== currentIndex) {
children.push(columnType.substring(startIndex, currentIndex).trim());
}
return {
nextStartIndex: currentIndex + 1,
results: children,
};
} else if (currentChar in OPEN_DELIMETERS) {
/* Case 3: Beginning of a nested item */
if (
columnType.substring(startIndex, currentIndex).endsWith('timestamp')
) {
/*
Case 3.1: A non-supported item like timestamp() in Presto
Advance until we reach the closing character for this item.
On the next iteration Case 1 will apply.
*/
while (
columnType.charAt(currentIndex) !== OPEN_DELIMETERS[currentChar]
) {
currentIndex++;
}
currentIndex++;
} else {
/* Case 3.2: A supported nested item */
const parsedResults = parseNestedTypeHelper(
columnType,
currentIndex + 1,
currentIndex + 1
);
let isLast: boolean = true;
let { nextStartIndex } = parsedResults;
if (columnType.charAt(nextStartIndex) === SEPARATOR_DELIMETER) {
isLast = false;
nextStartIndex++;
}
children.push({
head: columnType.substring(startIndex, currentIndex + 1),
tail: `${OPEN_DELIMETERS[currentChar]}${
isLast ? '' : SEPARATOR_DELIMETER
}`,
children: parsedResults.results,
});
startIndex = nextStartIndex;
currentIndex = startIndex;
}
} else {
currentIndex++;
}
}
return {
nextStartIndex: currentIndex + 1,
results: children,
};
}
/*
* Returns whether or not a columnType string represents a complex type for the given database
*/
export function isNestedType(columnType: string, databaseId: string): boolean {
const supportedTypes = SUPPORTED_TYPES[databaseId];
let isNested = false;
if (supportedTypes) {
supportedTypes.forEach((supportedType) => {
if (
columnType.startsWith(supportedType) &&
columnType !== supportedType
) {
isNested = true;
}
});
}
return isNested;
}
/**
* Returns a NestedType object for supported complex types, else returns null
*/
export function parseNestedType(
columnType: string,
databaseId: string
): NestedType | null {
// Presto includes un-needed "" characters
if (databaseId === DatabaseId.Presto) {
columnType = columnType.replace(/"/g, '');
}
if (isNestedType(columnType, databaseId)) {
return parseNestedTypeHelper(columnType).results[0] as NestedType;
}
return null;
}
/*
* Returns the truncated string representation for a NestedType
*/
export function getTruncatedText(nestedType: NestedType): string {
const { head, tail } = nestedType;
return `${head}...${tail.replace(SEPARATOR_DELIMETER, '')}`;
}
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
@import 'variables';
@import 'typography';
/* Fixed heights via designs */
$modal-content-height: 484px;
$modal-dialog-height: 484px;
$modal-header-height: 94px;
/* Fixed witdh via designs */
$modal-dialog-width: 418px;
.column-type-btn {
border: none;
background: none;
color: $link-color;
padding-left: 0;
&:hover,
&:focus {
color: $link-hover-color;
cursor: pointer;
}
}
.column-type-modal {
.modal-body {
border-bottom: 1px solid $stroke-light;
border-top: 1px solid $stroke-light;
height: calc(#{$modal-dialog-height - $modal-header-height - $spacer-3});
font-family: $font-family-monospace-code;
font-size: $code-font-size;
/* Override react-bootstrap styles to match design */
margin: 0 $spacer-3 !important;
padding: $spacer-1 0 !important;
text-align: initial !important;
}
.modal-content {
height: $modal-content-height;
}
.modal-dialog {
height: $modal-dialog-height;
width: $modal-dialog-width;
overflow-y: hidden;
}
.modal-header {
border-bottom: none;
height: $modal-header-height;
/* Override react-bootstrap styles to match design */
padding: $spacer-3 !important;
.main-title {
@extend %text-title-w1;
}
.sub-title {
@extend %text-subtitle-w3;
color: $text-secondary;
}
}
}
/*
These three styles vertically center the modal: https://codepen.io/dimbslmh/full/mKfCc
Bootstrap4 will have a dedicated class to handle this.
*/
.modal {
text-align: center;
padding: 0 !important;
}
.modal:before {
content: '';
display: inline-block;
height: 100%;
vertical-align: middle;
margin-right: -4px;
}
.modal-dialog {
display: inline-block;
vertical-align: middle;
}
...@@ -16,6 +16,8 @@ import AppConfig from 'config/config'; ...@@ -16,6 +16,8 @@ import AppConfig from 'config/config';
import * as UtilMethods from 'ducks/utilMethods'; import * as UtilMethods from 'ducks/utilMethods';
import { RequestMetadataType } from 'interfaces/Notifications'; import { RequestMetadataType } from 'interfaces/Notifications';
import ColumnType from './ColumnType';
const logClickSpy = jest.spyOn(UtilMethods, 'logClick'); const logClickSpy = jest.spyOn(UtilMethods, 'logClick');
logClickSpy.mockImplementation(() => null); logClickSpy.mockImplementation(() => null);
...@@ -36,6 +38,7 @@ describe('ColumnListItem', () => { ...@@ -36,6 +38,7 @@ describe('ColumnListItem', () => {
}, },
], ],
}, },
database: 'hive',
index: 0, index: 0,
openRequestDescriptionDialog: jest.fn(), openRequestDescriptionDialog: jest.fn(),
editText: 'Click to edit discription in source', editText: 'Click to edit discription in source',
...@@ -107,9 +110,9 @@ describe('ColumnListItem', () => { ...@@ -107,9 +110,9 @@ describe('ColumnListItem', () => {
expect(columnDesc.text()).toBe(props.data.description); expect(columnDesc.text()).toBe(props.data.description);
}); });
it('renders the correct resource type', () => { it('renders the ColumnType', () => {
const resourceType = wrapper.find('.resource-type'); const resourceType = wrapper.find('.resource-type');
expect(resourceType.text()).toBe(props.data.col_type.toLowerCase()); expect(resourceType.find(ColumnType).exists()).toBe(true);
}); });
it('renders the dropdown when notifications is enabled', () => { it('renders the dropdown when notifications is enabled', () => {
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import * as React from 'react'; import * as React from 'react';
import { Dropdown, MenuItem, OverlayTrigger, Popover } from 'react-bootstrap'; import { Dropdown, MenuItem } from 'react-bootstrap';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
...@@ -16,6 +16,7 @@ import { RequestMetadataType, TableColumn } from 'interfaces'; ...@@ -16,6 +16,7 @@ import { RequestMetadataType, TableColumn } from 'interfaces';
import './styles.scss'; import './styles.scss';
import EditableSection from 'components/common/EditableSection'; import EditableSection from 'components/common/EditableSection';
import ColumnType from './ColumnType';
const MORE_BUTTON_TEXT = 'More options'; const MORE_BUTTON_TEXT = 'More options';
const EDITABLE_SECTION_TITLE = 'Description'; const EDITABLE_SECTION_TITLE = 'Description';
...@@ -29,6 +30,7 @@ interface DispatchFromProps { ...@@ -29,6 +30,7 @@ interface DispatchFromProps {
interface OwnProps { interface OwnProps {
data: TableColumn; data: TableColumn;
database: string;
index: number; index: number;
editText: string; editText: string;
editUrl: string; editUrl: string;
...@@ -52,15 +54,15 @@ export class ColumnListItem extends React.Component< ...@@ -52,15 +54,15 @@ export class ColumnListItem extends React.Component<
} }
toggleExpand = (e) => { toggleExpand = (e) => {
const metadata = this.props.data; const { data } = this.props;
if (!this.state.isExpanded) { if (!this.state.isExpanded) {
logClick(e, { logClick(e, {
target_id: `column::${metadata.name}`, target_id: `column::${data.name}`,
target_type: 'column stats', target_type: 'column stats',
label: `${metadata.name} ${metadata.col_type}`, label: `${data.name} ${data.col_type}`,
}); });
} }
if (this.shouldRenderDescription() || metadata.stats.length !== 0) { if (this.shouldRenderDescription() || data.stats.length !== 0) {
this.setState((prevState) => ({ this.setState((prevState) => ({
isExpanded: !prevState.isExpanded, isExpanded: !prevState.isExpanded,
})); }));
...@@ -89,58 +91,8 @@ export class ColumnListItem extends React.Component< ...@@ -89,58 +91,8 @@ export class ColumnListItem extends React.Component<
return true; return true;
}; };
renderColumnType = (columnIndex: number, type: string) => {
const truncatedTypes: string[] = ['array', 'struct', 'map', 'row'];
let shouldTrucate = false;
const fullText = type.toLowerCase();
let text = fullText;
truncatedTypes.forEach((truncatedType) => {
if (type.startsWith(truncatedType) && type !== truncatedType) {
shouldTrucate = true;
const lastChar = type.charAt(type.length - 1);
if (lastChar === '>') {
text = `${truncatedType}<...>`;
} else if (lastChar === ')') {
text = `${truncatedType}(...)`;
} else {
text = `${truncatedType}...`;
}
}
});
if (shouldTrucate) {
const popoverHover = (
<Popover
className="column-type-popover"
id={`column-type-popover:${columnIndex}`}
>
{fullText}
</Popover>
);
return (
<OverlayTrigger
trigger={['click']}
placement="left"
overlay={popoverHover}
rootClose
>
<a
className="column-type"
href="JavaScript:void(0)"
onClick={this.stopPropagation}
>
{text}
</a>
</OverlayTrigger>
);
}
return <div className="column-type">{text}</div>;
};
render() { render() {
const metadata = this.props.data; const { data, database } = this.props;
return ( return (
<li className="list-group-item clickable" onClick={this.toggleExpand}> <li className="list-group-item clickable" onClick={this.toggleExpand}>
<div className="column-list-item"> <div className="column-list-item">
...@@ -150,15 +102,19 @@ export class ColumnListItem extends React.Component< ...@@ -150,15 +102,19 @@ export class ColumnListItem extends React.Component<
!this.state.isExpanded ? 'my-auto' : '' !this.state.isExpanded ? 'my-auto' : ''
}`} }`}
> >
<div className="column-name">{metadata.name}</div> <div className="column-name">{data.name}</div>
{!this.state.isExpanded && ( {!this.state.isExpanded && (
<div className="column-desc body-3 truncated"> <div className="column-desc body-3 truncated">
{metadata.description} {data.description}
</div> </div>
)} )}
</div> </div>
<div className="resource-type"> <div className="resource-type">
{this.renderColumnType(this.props.index, metadata.col_type)} <ColumnType
columnName={data.name}
database={database}
type={data.col_type}
/>
</div> </div>
<div className="badges">{/* Placeholder */}</div> <div className="badges">{/* Placeholder */}</div>
<div className="actions"> <div className="actions">
...@@ -191,20 +147,20 @@ export class ColumnListItem extends React.Component< ...@@ -191,20 +147,20 @@ export class ColumnListItem extends React.Component<
{this.shouldRenderDescription() && ( {this.shouldRenderDescription() && (
<EditableSection <EditableSection
title={EDITABLE_SECTION_TITLE} title={EDITABLE_SECTION_TITLE}
readOnly={!metadata.is_editable} readOnly={!data.is_editable}
editText={this.props.editText} editText={this.props.editText}
editUrl={this.props.editUrl} editUrl={this.props.editUrl}
> >
<ColumnDescEditableText <ColumnDescEditableText
columnIndex={this.props.index} columnIndex={this.props.index}
editable={metadata.is_editable} editable={data.is_editable}
maxLength={getMaxLength('columnDescLength')} maxLength={getMaxLength('columnDescLength')}
value={metadata.description} value={data.description}
/> />
</EditableSection> </EditableSection>
)} )}
</div> </div>
<ColumnStats stats={metadata.stats} /> <ColumnStats stats={data.stats} />
</section> </section>
)} )}
</div> </div>
......
import * as React from 'react'; import * as React from 'react';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import SourceLink, { SourceLinkProps } from '.';
import AvatarLabel from 'components/common/AvatarLabel'; import AvatarLabel from 'components/common/AvatarLabel';
import AppConfig from 'config/config'; import AppConfig from 'config/config';
import { ResourceType } from 'interfaces/Resources'; import { ResourceType } from 'interfaces/Resources';
import SourceLink, { SourceLinkProps } from '.';
const setup = (propOverrides?: Partial<SourceLinkProps>) => { const setup = (propOverrides?: Partial<SourceLinkProps>) => {
const props = { const props = {
......
...@@ -159,18 +159,20 @@ export class TableDetail extends React.Component< ...@@ -159,18 +159,20 @@ export class TableDetail extends React.Component<
renderTabs(editText, editUrl) { renderTabs(editText, editUrl) {
const tabInfo = []; const tabInfo = [];
const { isLoadingDashboards, numRelatedDashboards, tableData } = this.props;
// Default Column content // Default Column content
tabInfo.push({ tabInfo.push({
content: ( content: (
<ColumnList <ColumnList
columns={this.props.tableData.columns} columns={tableData.columns}
database={tableData.database}
editText={editText} editText={editText}
editUrl={editUrl} editUrl={editUrl}
/> />
), ),
key: 'columns', key: 'columns',
title: `Columns (${this.props.tableData.columns.length})`, title: `Columns (${tableData.columns.length})`,
}); });
if (indexDashboardsEnabled()) { if (indexDashboardsEnabled()) {
...@@ -187,9 +189,9 @@ export class TableDetail extends React.Component< ...@@ -187,9 +189,9 @@ export class TableDetail extends React.Component<
/> />
), ),
key: 'dashboards', key: 'dashboards',
title: this.props.isLoadingDashboards title: isLoadingDashboards
? loadingTitle ? loadingTitle
: `Dashboards (${this.props.numRelatedDashboards})`, : `Dashboards (${numRelatedDashboards})`,
}); });
} }
......
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