Unverified Commit 65defde5 authored by Marcos Iglesias's avatar Marcos Iglesias Committed by GitHub

feat: Adds related dashboards tab to the Table detail page (#470)

* Renders empty dashboards tab and adds some basic tests

* Adds new object to the type, testing request that doesn't work

* Merges dashboard data into TableMetadata object

* Reusing the DashboardResource type instead of creating a new one

* Removing msg and status_code attributes from the merged response as per Tamika's comment

* Updates first of list top border
parent bb1966af
......@@ -26,10 +26,4 @@
margin-bottom: $spacer-4;
}
}
.tabs-component {
.tab-content .list-group-item:first-child {
border-top: none;
}
}
}
......@@ -25,6 +25,7 @@ describe('ExploreButton', () => {
badges: [],
cluster: 'cluster',
columns: [],
dashboards: [],
database: 'database',
is_editable: false,
is_view: false,
......
export const PROGRMMATIC_DESC_HEADER = 'Read-only information, auto-generated'
export const PROGRMMATIC_DESC_HEADER = 'Read-only information, auto-generated';
export const ERROR_MESSAGE = 'Something went wrong...';
import * as React from 'react';
import * as History from 'history';
import { mount } from 'enzyme';
import { getMockRouterProps } from '../../fixtures/mockRouter';
import { tableMetadata } from '../../fixtures/metadata/table';
import LoadingSpinner from '../common/LoadingSpinner';
import { TableDetail, TableDetailProps, MatchProps } from './';
describe('TableDetail', () => {
const setup = (propOverrides?: Partial<TableDetailProps>, location?: Partial<History.Location>) => {
const routerProps = getMockRouterProps<MatchProps>({
"cluster":"gold",
"database":"hive",
"schema":"base",
"table":"rides"
}, location);
const props = {
isLoading: false,
statusCode: 200,
tableData: tableMetadata,
getTableData: jest.fn(),
...routerProps,
...propOverrides,
};
const wrapper = mount<TableDetail>(<TableDetail {...props} />);
return { props, wrapper };
};
describe('render', () => {
it('should render without problems', () => {
expect(() => {setup();}).not.toThrow();
});
it('should render the Loading Spinner', () => {
const {wrapper} = setup();
const expected = 1;
const actual = wrapper.find(LoadingSpinner).length;
expect(actual).toEqual(expected);
});
});
describe('lifecycle', () => {
describe('when mounted', () => {
it('calls loadDashboard with uri from state', () => {
const { props } = setup();
const expected = 1;
const actual = (props.getTableData as jest.Mock).mock.calls.length;
expect(actual).toEqual(expected);
});
});
});
});
......@@ -12,13 +12,16 @@ import AppConfig from 'config/config';
import BadgeList from 'components/common/BadgeList';
import BookmarkIcon from 'components/common/Bookmark/BookmarkIcon';
import Breadcrumb from 'components/common/Breadcrumb';
import TabsComponent from 'components/common/TabsComponent';
import EditableText from 'components/common/EditableText';
import LoadingSpinner from 'components/common/LoadingSpinner';
import Flag from 'components/common/Flag';
import ResourceList from 'components/common/ResourceList';
import DataPreviewButton from 'components/TableDetail/DataPreviewButton';
import ColumnList from 'components/TableDetail/ColumnList';
import EditableText from 'components/common/EditableText';
import ExploreButton from 'components/TableDetail/ExploreButton';
import Flag from 'components/common/Flag';
import FrequentUsers from 'components/TableDetail/FrequentUsers';
import LoadingSpinner from 'components/common/LoadingSpinner';
import LineageLink from 'components/TableDetail/LineageLink';
import OwnerEditor from 'components/TableDetail/OwnerEditor';
import SourceLink from 'components/TableDetail/SourceLink';
......@@ -37,32 +40,43 @@ import { getSourceIconClass, issueTrackingEnabled, notificationsEnabled } from '
import { formatDateTimeShort } from 'utils/dateUtils';
import { getLoggingParams } from 'utils/logUtils';
import './styles';
import RequestDescriptionText from './RequestDescriptionText';
import RequestMetadataForm from './RequestMetadataForm';
import { PROGRMMATIC_DESC_HEADER } from './constants';
import { PROGRMMATIC_DESC_HEADER, ERROR_MESSAGE } from './constants';
import './styles.scss';
const SERVER_ERROR_CODE = 500;
const DASHBOARDS_PER_PAGE = 10;
const TABLE_SOURCE = "table_page";
export interface StateFromProps {
isLoading: boolean;
statusCode?: number;
tableData: TableMetadata;
}
export interface DispatchFromProps {
getTableData: (key: string, searchIndex?: string, source?: string, ) => GetTableDataRequest;
}
interface MatchProps {
export interface MatchProps {
cluster: string;
database: string;
schema: string;
table: string;
}
export type TableDetailProps = StateFromProps & DispatchFromProps & RouteComponentProps<MatchProps>;
type TableDetailProps = StateFromProps & DispatchFromProps & RouteComponentProps<MatchProps>;
const ErrorMessage = () => {
return (
<div className="container error-label">
<Breadcrumb />
<label>{ERROR_MESSAGE}</label>
</div>
);
};
class TableDetail extends React.Component<TableDetailProps & RouteComponentProps<any>> {
export class TableDetail extends React.Component<TableDetailProps & RouteComponentProps<any>> {
private key: string;
private didComponentMount: boolean = false;
......@@ -86,6 +100,7 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps
getDisplayName() {
const params = this.props.match.params;
return `${params.schema}.${params.table}`;
}
......@@ -99,20 +114,47 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps
return `${params.database}://${params.cluster}.${params.schema}/${params.table}`;
}
renderTabs() {
const tabInfo = [];
// Default Column content
tabInfo.push({
content:
(
<ColumnList columns={ this.props.tableData.columns }/>
),
key: 'columns',
title: `Columns (${this.props.tableData.columns.length})`,
});
// Dashboard content
tabInfo.push({
content: (
<ResourceList
allItems={this.props.tableData.dashboards}
itemsPerPage={DASHBOARDS_PER_PAGE}
source={TABLE_SOURCE}
/>
),
key: "dashboards",
title: `Dashboards (${this.props.tableData.dashboards.length})`
});
return <TabsComponent tabs={ tabInfo } defaultTab={ "columns" } />;
}
render() {
const {isLoading, statusCode, tableData} = this.props;
let innerContent;
// We want to avoid rendering the previous table's metadata before new data is fetched in componentDidMount
if (this.props.isLoading || !this.didComponentMount) {
if (isLoading || !this.didComponentMount) {
innerContent = <LoadingSpinner/>;
} else if (this.props.statusCode === 500) {
innerContent = (
<div className="container error-label">
<Breadcrumb />
<label>Something went wrong...</label>
</div>
);
} else if (statusCode === SERVER_ERROR_CODE) {
innerContent = (<ErrorMessage />);
} else {
const data = this.props.tableData;
const data = tableData;
innerContent = (
<div className="resource-detail-layout table-detail">
{
......@@ -228,7 +270,7 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps
}
</section>
<section className="right-panel">
<ColumnList columns={ data.columns }/>
{ this.renderTabs() }
</section>
</article>
</div>
......@@ -243,7 +285,6 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps
}
}
export const mapStateToProps = (state: GlobalState) => {
return {
isLoading: state.tableMetadata.isLoading,
......
......@@ -54,4 +54,10 @@
}
}
}
.right-panel & {
.tab-content .list-group-item:first-child {
border-top: none;
}
}
}
......@@ -145,6 +145,7 @@ describe('generateExploreUrl', () => {
badges: [],
cluster: 'cluster',
columns: [],
dashboards: [],
database: 'database',
is_editable: false,
is_view: false,
......
import axios from 'axios';
import * as qs from 'simple-query-string';
import * as Helpers from '../helpers';
import * as Helpers from './helpers';
import * as Utils from 'ducks/utilMethods';
import globalState from 'fixtures/globalState';
import {relatedDashboards} from 'fixtures/metadata/table';
import { NotificationType, UpdateMethod, UpdateOwnerPayload } from 'interfaces';
import * as API from '../v0';
import * as API from './v0';
const filterFromObjSpy = jest.spyOn(Utils, 'filterFromObj').mockImplementation((initialObject, rejectedKeys) => { return initialObject; });
const filterFromObjSpy = jest.spyOn(Utils, 'filterFromObj');
jest.mock('axios');
describe('helpers', () => {
let mockResponseData: API.TableDataAPI;
let mockRelatedDashboardsResponseData: API.RelatedDashboardDataAPI;
let tableResponseData: API.TableData;
beforeAll(() => {
tableResponseData = {
......@@ -24,6 +26,9 @@ describe('helpers', () => {
owners: [{display_name: 'test', profile_url: 'test.io', email: 'test@test.com', user_id: 'test'}],
tags: [{tag_count: 2, tag_name: 'zname'}, {tag_count: 1, tag_name: 'aname'}],
};
mockRelatedDashboardsResponseData = {
dashboards: relatedDashboards
};
mockResponseData = {
tableData: tableResponseData,
msg: 'Success',
......@@ -54,11 +59,41 @@ describe('helpers', () => {
});
});
it('getTableDataFromResponseData', () => {
Helpers.getTableDataFromResponseData(mockResponseData);
describe('getRelatedDashboardSlug', () => {
it('generates related dashboard slug section for the URL', () => {
const tableKey = 'hive://gold.base/rides';
const actual = Helpers.getRelatedDashboardSlug(tableKey);
const expected = 'hive%3A%2F%2Fgold.base%2Frides';
expect(actual).toEqual(expected);
});
});
describe('getTableDataFromResponseData', () => {
it('uses the filterFromObj method', () => {
Helpers.getTableDataFromResponseData(mockResponseData, mockRelatedDashboardsResponseData);
expect(filterFromObjSpy).toHaveBeenCalledWith(tableResponseData, ['owners', 'tags']);
});
describe('produces the final TableMetadata information', () => {
it('contains the columns key', () => {
const expected = 0;
const actual = Helpers.getTableDataFromResponseData(mockResponseData, mockRelatedDashboardsResponseData).columns.length;
expect(actual).toEqual(expected);
});
it('contains the dashboards key', () => {
const expected = 3;
const actual = Helpers.getTableDataFromResponseData(mockResponseData, mockRelatedDashboardsResponseData).dashboards.length;
expect(actual).toEqual(expected);
});
});
});
it('getTableOwnersFromResponseData', () => {
expect(Helpers.getTableOwnersFromResponseData(mockResponseData)).toEqual({
'test': {display_name: 'test', profile_url: 'test.io', email: 'test@test.com', user_id: 'test'}
......
......@@ -13,10 +13,22 @@ export function getTableQueryParams(key: string, index?: string, source?: string
}
/**
* Parses the response for table metadata to create a TableMetadata object
* Generates the query string parameters needed for the request for the related dashboards to a table
*/
export function getTableDataFromResponseData(responseData: API.TableDataAPI): TableMetadata {
return filterFromObj(responseData.tableData, ['owners', 'tags']) as TableMetadata;
export function getRelatedDashboardSlug(key: string): string {
return encodeURIComponent(key);
}
/**
* Parses the response for table metadata and the related dashboard information to create a TableMetadata object
*/
export function getTableDataFromResponseData(responseData: API.TableDataAPI, relatedDashboardsData: API.RelatedDashboardDataAPI ): TableMetadata {
const mergedTableData = {
...filterFromObj(responseData.tableData, ['owners', 'tags']),
...filterFromObj(relatedDashboardsData, ['msg', 'status_code']),
};
return (mergedTableData) as TableMetadata;
}
/**
......
import axios, { AxiosResponse, AxiosError } from 'axios';
import { PreviewData, PreviewQueryParams, TableMetadata, UpdateOwnerPayload, User, Tag } from 'interfaces';
import { PreviewData, PreviewQueryParams, TableMetadata, DashboardResource, UpdateOwnerPayload, User, Tag } from 'interfaces';
export const API_PATH = '/api/metadata/v0';
......@@ -14,26 +14,45 @@ export type TableData = TableMetadata & {
export type DescriptionAPI = { description: string; } & MessageAPI;
export type LastIndexedAPI = { timestamp: string; } & MessageAPI;
export type PreviewDataAPI = { previewData: PreviewData; } & MessageAPI;
export type TableDataAPI= { tableData: TableData; } & MessageAPI;
export type TableDataAPI = { tableData: TableData; } & MessageAPI;
export type RelatedDashboardDataAPI = { dashboards: DashboardResource[]; };
/** HELPERS **/
import {
getTableQueryParams, getTableDataFromResponseData, getTableOwnersFromResponseData,
getTableQueryParams, getRelatedDashboardSlug, getTableDataFromResponseData, getTableOwnersFromResponseData,
createOwnerUpdatePayload, createOwnerNotificationData, shouldSendNotification
} from './helpers';
export function getTableData(tableKey: string, index?: string, source?: string ) {
const queryParams = getTableQueryParams(tableKey, index, source);
return axios.get(`${API_PATH}/table?${queryParams}`)
.then((response: AxiosResponse<TableDataAPI>) => {
return {
data: getTableDataFromResponseData(response.data),
owners: getTableOwnersFromResponseData(response.data),
tags: response.data.tableData.tags,
statusCode: response.status,
};
});
export function getTableData(
tableKey: string,
index?: string,
source?: string
) {
const relatedDashboardsSlug: string = getRelatedDashboardSlug(tableKey);
const relatedDashboardsURL: string = `${API_PATH}/table/${relatedDashboardsSlug}/dashboards`;
const relatedDashboardsRequest = axios.get<RelatedDashboardDataAPI>(
relatedDashboardsURL
);
const tableQueryParams = getTableQueryParams(tableKey, index, source);
const tableURL = `${API_PATH}/table?${tableQueryParams}`;
const tableRequest = axios.get<TableDataAPI>(tableURL);
return Promise.all([tableRequest, relatedDashboardsRequest]).then(
([tableResponse, relatedDashboardsResponse]: [
AxiosResponse<TableDataAPI>,
AxiosResponse<RelatedDashboardDataAPI>
]) => ({
data: getTableDataFromResponseData(
tableResponse.data,
relatedDashboardsResponse.data
),
owners: getTableOwnersFromResponseData(tableResponse.data),
tags: tableResponse.data.tableData.tags,
statusCode: tableResponse.status
})
);
}
export function getTableDescription(tableData: TableMetadata) {
......
......@@ -178,6 +178,7 @@ export const initialTableDataState: TableMetadata = {
badges: [],
cluster: '',
columns: [],
dashboards: [],
database: '',
is_editable: false,
is_view: false,
......
......@@ -153,6 +153,7 @@ const globalState: GlobalState = {
badges: [],
cluster: '',
columns: [],
dashboards: [],
database: '',
is_editable: false,
is_view: false,
......
import { TableMetadata, DashboardResource, ResourceType } from '../../interfaces';
import { TagType } from '../../interfaces/Tags';
export const tableMetadata:TableMetadata = {
badges: [
{
tag_name: "ga",
tag_type: TagType.BADGE,
}
],
cluster: "gold",
columns: [
{
col_type: "bigint",
description: "Test Value",
is_editable: true,
name: "ride_id",
stats: [
{
end_epoch: 1588896000,
start_epoch: 1588896000,
stat_type: "count",
stat_val: "992487"
},
{
end_epoch: 1588896000,
start_epoch: 1588896000,
stat_type: "count_null",
stat_val: "0"
},
{
end_epoch: 1588896000,
start_epoch: 1588896000,
stat_type: "count_distinct",
stat_val: "992487"
}
]
},
{
col_type: "string",
description:
"ds will be the date part of requested_at ds will be the date part of requested_at ds will be the date part of requested_at ds will be the date part of requested_at ds will be the date part of requested_at ds will be the date part of requested_at ds w",
is_editable: true,
name: "ds",
stats: []
},
{
col_type: "string",
description: null,
is_editable: true,
name: "route_id",
stats: [
{
end_epoch: 1588896000,
start_epoch: 1588896000,
stat_type: "count",
stat_val: "992487"
},
{
end_epoch: 1588896000,
start_epoch: 1588896000,
stat_type: "count_null",
stat_val: "0"
},
{
end_epoch: 1588896000,
start_epoch: 1588896000,
stat_type: "count_distinct",
stat_val: "992405"
},
{
end_epoch: 1586476800,
start_epoch: 1586476800,
stat_type: "len_max",
stat_val: "24"
},
{
end_epoch: 1586476800,
start_epoch: 1586476800,
stat_type: "len_min",
stat_val: "24"
},
{
end_epoch: 1586476800,
start_epoch: 1586476800,
stat_type: "len_avg",
stat_val: "24"
}
]
}
],
dashboards: [],
database: "hive",
description:
"One row per ride request, showing all stages of the ride funnel. ",
is_editable: true,
is_view: false,
key: "hive://gold.base/rides",
last_updated_timestamp: 1583469780,
name: "rides",
partition: {
is_partitioned: true,
key: "ds",
value: "2020-03-05"
},
programmatic_descriptions: [],
schema: "base",
source: {
source:
"https://github.com/lyft/etl/blob/master/sql/hive/base/rides.config",
source_type: "github"
},
table_readers: [
{
read_count: 1735,
user: {
email: "testEmail@lyft.com",
user_id: "123test",
display_name: "Test User The First",
profile_url: "http://ProfileURLForTest.one"
}
},
{
read_count: 229,
user: {
email: "testEmailBis@lyft.com",
user_id: "456test",
display_name: "Test User The Second",
profile_url: "http://ProfileURLForTest.two"
}
},
{
read_count: 189,
user: {
email: "testEmailTri@lyft.com",
user_id: "789test",
display_name: "Test User The Third",
profile_url: "http://ProfileURLForTest.three"
}
}
],
table_writer: {
application_url:
"https://etl-production.lyft.net/admin/airflow/tree?dag_id=ADHOC - root",
description: "Airflow with id ADHOC - root/UNKNOWN",
id: "ADHOC - root/UNKNOWN",
name: "Airflow"
},
watermarks: [
{
create_time: "2020-02-13 19:55:13",
partition_key: "ds",
partition_value: "2020-03-05",
watermark_type: "high_watermark"
},
{
create_time: "2020-02-13 19:55:13",
partition_key: "ds",
partition_value: "2013-02-10",
watermark_type: "low_watermark"
}
]
};
export const relatedDashboards:DashboardResource[] = [
{
"group_name": "Test Group 1",
"description": null,
"cluster": "gold",
"group_url": "https://app.mode.com/testCompany/spaces/1234",
"uri": "mode_dashboard://gold.1234/23445asb",
"last_successful_run_timestamp": 1590505846,
"name": "Test Dashboard 1",
"product": "mode",
"type": ResourceType.dashboard,
"url": "https://app.mode.com/testCompany/reports/23445asb"
}, {
"group_name": "Test Group 2",
"description": null,
"cluster": "gold",
"group_url": "https://app.mode.com/testCompany/spaces/345asd",
"uri": "mode_dashboard://gold.345asd/asdfas001",
"last_successful_run_timestamp": 1590519704,
"name": "Test Dashboard 2",
"product": "mode",
"type": ResourceType.dashboard,
"url": "https://app.mode.com/testCompany/reports/asdfas001"
}, {
"group_name": "Test Group 3",
"description": null,
"cluster": "gold",
"group_url": "https://app.mode.com/testCompany/spaces/casdg80",
"uri": "mode_dashboard://gold.casdg80/123566",
"last_successful_run_timestamp": 1590538191,
"name": "Test Dashboard 3",
"product": "mode",
"type": ResourceType.dashboard,
"url": "https://app.mode.com/testCompany/reports/123566"
}
];
import { UpdateMethod } from './Enums';
import { User } from './User';
import { Badge } from './Tags';
import { DashboardResource } from './Resources';
interface PartitionData {
is_partitioned: boolean;
......@@ -78,6 +79,7 @@ export interface TableMetadata {
badges: Badge[];
cluster: string;
columns: TableColumn[];
dashboards: DashboardResource[];
database: string;
is_editable: boolean;
is_view: boolean;
......
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