Unverified Commit 050f674c authored by Marcos Iglesias's avatar Marcos Iglesias Committed by GitHub

feat: Column list with usage column and new reusable table (#684)

Signed-off-by: 's avatarMarcos Iglesias Valle <golodhros@gmail.com>
parent ea235de8
...@@ -19,8 +19,8 @@ exports[`strict null compilation`] = { ...@@ -19,8 +19,8 @@ exports[`strict null compilation`] = {
[88, 4, 11, "Type \'Element\' is not assignable to type \'null\'.", "3768376622"], [88, 4, 11, "Type \'Element\' is not assignable to type \'null\'.", "3768376622"],
[91, 4, 11, "Type \'Element[]\' is not assignable to type \'null\'.", "3768376622"] [91, 4, 11, "Type \'Element[]\' is not assignable to type \'null\'.", "3768376622"]
], ],
"js/components/common/BadgeList/index.tsx:4121776008": [ "js/components/common/BadgeList/index.tsx:3472030733": [
[21, 12, 4, "Type \'string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2087956376"] [32, 14, 4, "Type \'string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2087956376"]
], ],
"js/components/common/Bookmark/MyBookmarks/index.tsx:1185364658": [ "js/components/common/Bookmark/MyBookmarks/index.tsx:1185364658": [
[66, 6, 7, "Type \'Element | null\' is not assignable to type \'never\'.\\n Type \'null\' is not assignable to type \'never\'.", "3716929964"], [66, 6, 7, "Type \'Element | null\' is not assignable to type \'never\'.\\n Type \'null\' is not assignable to type \'never\'.", "3716929964"],
...@@ -76,7 +76,7 @@ exports[`strict null compilation`] = { ...@@ -76,7 +76,7 @@ exports[`strict null compilation`] = {
], ],
"js/components/common/ResourceListItem/TableListItem/index.spec.tsx:1564573503": [ "js/components/common/ResourceListItem/TableListItem/index.spec.tsx:1564573503": [
[161, 14, 18, "Type \'null\' is not assignable to type \'string | undefined\'.", "3750638477"], [161, 14, 18, "Type \'null\' is not assignable to type \'string | undefined\'.", "3750638477"],
[229, 14, 6, "Type \'null\' is not assignable to type \'Badge[] | undefined\'.", "1502764275"] [229, 14, 6, "Type \'null\' is not assignable to type \'any[] | undefined\'.", "1502764275"]
], ],
"js/components/common/ResourceListItem/UserListItem/index.tsx:942740425": [ "js/components/common/ResourceListItem/UserListItem/index.tsx:942740425": [
[29, 21, 49, "Argument of type \'Element\' is not assignable to parameter of type \'never\'.", "2430213531"], [29, 21, 49, "Argument of type \'Element\' is not assignable to parameter of type \'never\'.", "2430213531"],
...@@ -99,8 +99,8 @@ exports[`strict null compilation`] = { ...@@ -99,8 +99,8 @@ exports[`strict null compilation`] = {
[255, 6, 11, "No overload matches this call.\\n The last overload gave the following error.\\n Type \'(() => SubmitSearchRequest) | null\' is not assignable to type \'ActionCreator<any>\'.\\n Type \'null\' is not assignable to type \'ActionCreator<any>\'.", "2296208050"], [255, 6, 11, "No overload matches this call.\\n The last overload gave the following error.\\n Type \'(() => SubmitSearchRequest) | null\' is not assignable to type \'ActionCreator<any>\'.\\n Type \'null\' is not assignable to type \'ActionCreator<any>\'.", "2296208050"],
[270, 4, 18, "No overload matches this call.\\n The last overload gave the following error.\\n Argument of type \'(dispatch: any, ownProps: any) => ActionCreator<unknown>\' is not assignable to parameter of type \'DispatchFromProps\'.\\n Type \'(dispatch: any, ownProps: any) => ActionCreator<unknown>\' is missing the following properties from type \'DispatchFromProps\': submitSearch, onInputChange, onSelectInlineResult", "2926224796"] [270, 4, 18, "No overload matches this call.\\n The last overload gave the following error.\\n Argument of type \'(dispatch: any, ownProps: any) => ActionCreator<unknown>\' is not assignable to parameter of type \'DispatchFromProps\'.\\n Type \'(dispatch: any, ownProps: any) => ActionCreator<unknown>\' is missing the following properties from type \'DispatchFromProps\': submitSearch, onInputChange, onSelectInlineResult", "2926224796"]
], ],
"js/components/common/Table/index.tsx:2588109192": [ "js/components/common/Table/index.tsx:3110699653": [
[189, 22, 13, "Type \'unknown\' is not assignable to type \'ReactNode\'.\\n Type \'unknown\' is not assignable to type \'ReactPortal\'.", "971959308"] [220, 22, 13, "Type \'unknown\' is not assignable to type \'ReactNode\'.\\n Type \'unknown\' is not assignable to type \'ReactPortal\'.", "971959308"]
], ],
"js/components/common/Tags/TagInput/index.tsx:3754832290": [ "js/components/common/Tags/TagInput/index.tsx:3754832290": [
[63, 22, 6, "Type \'undefined\' is not assignable to type \'GetAllTagsRequest\'.", "1979467425"], [63, 22, 6, "Type \'undefined\' is not assignable to type \'GetAllTagsRequest\'.", "1979467425"],
...@@ -127,7 +127,7 @@ exports[`strict null compilation`] = { ...@@ -127,7 +127,7 @@ exports[`strict null compilation`] = {
"js/config/config-utils.ts:1027600130": [ "js/config/config-utils.ts:1027600130": [
[86, 4, 25, "\'style\' is specified more than once, so this usage will be overwritten.", "1214862559"] [86, 4, 25, "\'style\' is specified more than once, so this usage will be overwritten.", "1214862559"]
], ],
"js/config/index.spec.ts:696974304": [ "js/config/index.spec.ts:1695312294": [
[17, 6, 61, "Object is possibly \'undefined\'.", "1496333578"], [17, 6, 61, "Object is possibly \'undefined\'.", "1496333578"],
[51, 6, 61, "Object is possibly \'undefined\'.", "1496333578"] [51, 6, 61, "Object is possibly \'undefined\'.", "1496333578"]
], ],
...@@ -240,8 +240,8 @@ exports[`strict null compilation`] = { ...@@ -240,8 +240,8 @@ exports[`strict null compilation`] = {
[154, 15, 36, "Object is possibly \'undefined\'.", "3626509514"], [154, 15, 36, "Object is possibly \'undefined\'.", "3626509514"],
[171, 16, 37, "Object is possibly \'undefined\'.", "1613771502"] [171, 16, 37, "Object is possibly \'undefined\'.", "1613771502"]
], ],
"js/ducks/utilMethods.ts:2135866627": [ "js/ducks/utilMethods.ts:3713570825": [
[51, 4, 5, "Type \'string | null\' is not assignable to type \'string | undefined\'.\\n Type \'null\' is not assignable to type \'string | undefined\'.", "173467459"] [52, 4, 5, "Type \'string | null\' is not assignable to type \'string | undefined\'.\\n Type \'null\' is not assignable to type \'string | undefined\'.", "173467459"]
], ],
"js/fixtures/globalState.ts:3931474038": [ "js/fixtures/globalState.ts:3931474038": [
[69, 4, 12, "Type \'null\' is not assignable to type \'string\'.", "124336133"] [69, 4, 12, "Type \'null\' is not assignable to type \'string\'.", "124336133"]
...@@ -322,15 +322,6 @@ exports[`strict null compilation`] = { ...@@ -322,15 +322,6 @@ exports[`strict null compilation`] = {
"js/pages/SearchPage/index.tsx:3158270092": [ "js/pages/SearchPage/index.tsx:3158270092": [
[176, 11, 12, "Property \'filterSections\' is missing in type \'{}\' but required in type \'Readonly<Pick<StateFromProps, \\"filterSections\\">>\'.", "250899467"] [176, 11, 12, "Property \'filterSections\' is missing in type \'{}\' but required in type \'Readonly<Pick<StateFromProps, \\"filterSections\\">>\'.", "250899467"]
], ],
"js/pages/TableDetailPage/ColumnList/index.tsx:3163605539": [
[31, 6, 7, "Object is possibly \'undefined\'.", "3718923584"],
[36, 21, 7, "Object is possibly \'undefined\'.", "3718923584"],
[43, 6, 8, "Type \'string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "1427606500"],
[44, 6, 7, "Type \'string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "3817619378"]
],
"js/pages/TableDetailPage/ColumnStats/index.spec.tsx:1228258528": [
[90, 39, 4, "Argument of type \'null\' is not assignable to parameter of type \'number\'.", "2087897566"]
],
"js/pages/TableDetailPage/DataPreviewButton/index.tsx:793484139": [ "js/pages/TableDetailPage/DataPreviewButton/index.tsx:793484139": [
[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"]
...@@ -353,7 +344,7 @@ exports[`strict null compilation`] = { ...@@ -353,7 +344,7 @@ exports[`strict null compilation`] = {
"js/pages/TableDetailPage/index.spec.tsx:3148704474": [ "js/pages/TableDetailPage/index.spec.tsx:3148704474": [
[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/pages/TableDetailPage/index.tsx:2666700431": [ "js/pages/TableDetailPage/index.tsx:3027031293": [
[160, 10, 13, "Type \'null\' is not assignable to type \'((newValue: string, onSuccess?: (() => any) | undefined, onFailure?: (() => any) | undefined) => void) | undefined\'.", "67794331"], [160, 10, 13, "Type \'null\' is not assignable to type \'((newValue: string, onSuccess?: (() => any) | undefined, onFailure?: (() => any) | undefined) => void) | undefined\'.", "67794331"],
[199, 11, 26, "Type \'{ itemsPerPage: number; source: string; }\' is missing the following properties from type \'Readonly<Pick<TableDashboardResourceListProps, \\"source\\" | \\"isLoading\\" | \\"dashboards\\" | \\"itemsPerPage\\" | \\"errorText\\"> & OwnProps>\': isLoading, dashboards, errorText", "2224258167"], [199, 11, 26, "Type \'{ itemsPerPage: number; source: string; }\' is missing the following properties from type \'Readonly<Pick<TableDashboardResourceListProps, \\"source\\" | \\"isLoading\\" | \\"dashboards\\" | \\"itemsPerPage\\" | \\"errorText\\"> & OwnProps>\': isLoading, dashboards, errorText", "2224258167"],
[279, 16, 7, "Type \'string | null\' is not assignable to type \'string | undefined\'.\\n Type \'null\' is not assignable to type \'string | undefined\'.", "3817619378"], [279, 16, 7, "Type \'string | null\' is not assignable to type \'string | undefined\'.\\n Type \'null\' is not assignable to type \'string | undefined\'.", "3817619378"],
......
...@@ -37,6 +37,7 @@ $screen-lg-container: 1440px; ...@@ -37,6 +37,7 @@ $screen-lg-container: 1440px;
.amundsen-breadcrumb { .amundsen-breadcrumb {
// Vertically align the breadcrumb // Vertically align the breadcrumb
// (84px header height - 18px breadcrumb height) / 2 for top & bottom - 16px resource-header padding = 17px // (84px header height - 18px breadcrumb height) / 2 for top & bottom - 16px resource-header padding = 17px
padding-top: 17px; padding-top: 17px;
} }
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
<mask id="mask-2" fill="white"> <mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use> <use xlink:href="#path-1"></use>
</mask> </mask>
<use id="Combined-Shape" fill="#D6D9DB" fill-rule="nonzero" xlink:href="#path-1"></use> <use id="Combined-Shape" fill="#9191a8" fill-rule="nonzero" xlink:href="#path-1"></use>
<g id="Tint/Gray-3" mask="url(#mask-2)"></g> <g id="Tint/Gray-3" mask="url(#mask-2)"></g>
</g> </g>
</svg> </svg>
\ No newline at end of file
...@@ -39,7 +39,12 @@ module.exports = { ...@@ -39,7 +39,12 @@ module.exports = {
}, },
testRegex: '(test|spec)\\.(j|t)sx?$', testRegex: '(test|spec)\\.(j|t)sx?$',
moduleDirectories: ['node_modules', 'js'], moduleDirectories: ['node_modules', 'js'],
coveragePathIgnorePatterns: ['stories/*', 'constants.ts'], coveragePathIgnorePatterns: [
'stories/*',
'constants.ts',
'.story.tsx',
'js/index.tsx',
],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
moduleNameMapper: { moduleNameMapper: {
'^.+\\.(css|scss)$': '<rootDir>/node_modules/jest-css-modules', '^.+\\.(css|scss)$': '<rootDir>/node_modules/jest-css-modules',
......
...@@ -88,7 +88,8 @@ describe('ColumnStats', () => { ...@@ -88,7 +88,8 @@ describe('ColumnStats', () => {
it('generates correct when no dates are given', () => { it('generates correct when no dates are given', () => {
const expectedInfoText = `Stats reflect data collected over a recent period of time.`; const expectedInfoText = `Stats reflect data collected over a recent period of time.`;
expect(instance.getStatsInfoText(null, null)).toBe(expectedInfoText);
expect(instance.getStatsInfoText()).toBe(expectedInfoText);
}); });
}); });
......
...@@ -13,7 +13,7 @@ export interface ColumnStatsProps { ...@@ -13,7 +13,7 @@ export interface ColumnStatsProps {
} }
export class ColumnStats extends React.Component<ColumnStatsProps> { export class ColumnStats extends React.Component<ColumnStatsProps> {
getStatsInfoText = (startEpoch: number, endEpoch: number) => { getStatsInfoText = (startEpoch?: number, endEpoch?: number) => {
const startDate = startEpoch const startDate = startEpoch
? formatDate({ epochTimestamp: startEpoch }) ? formatDate({ epochTimestamp: startEpoch })
: null; : null;
......
export const MORE_BUTTON_TEXT = 'More options';
export const REQUEST_DESCRIPTION_TEXT = 'Request Column Description';
export const EMPTY_MESSAGE = 'There are no available columns for this table';
export const EDITABLE_SECTION_TITLE = 'Description';
export const COLUMN_STATS_TITLE = 'Column Statistics';
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import { mount } from 'enzyme';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import { mocked } from 'ts-jest/utils';
import { notificationsEnabled } from 'config/config-utils';
import globalState from 'fixtures/globalState';
import ColumnList, { ColumnListProps } from '.';
import ColumnType from './ColumnType';
import { EMPTY_MESSAGE } from './constants';
import TestDataBuilder from './testDataBuilder';
jest.mock('config/config-utils');
const mockedNotificationsEnabled = mocked(notificationsEnabled, true);
const dataBuilder = new TestDataBuilder();
const middlewares = [];
const mockStore = configureStore(middlewares);
const setup = (propOverrides?: Partial<ColumnListProps>) => {
const props = {
editText: 'Click to edit description in the data source site',
editUrl: 'https://test.datasource.site/table',
database: 'testDatabase',
columns: [],
openRequestDescriptionDialog: jest.fn(),
...propOverrides,
};
// Update state
const testState = globalState;
testState.tableMetadata.tableData.columns = props.columns;
const wrapper = mount<ColumnListProps>(
<Provider store={mockStore(testState)}>
<ColumnList {...props} />
</Provider>
);
return { props, wrapper };
};
describe('ColumnList', () => {
mockedNotificationsEnabled.mockReturnValue(true);
describe('render', () => {
it('renders without issues', () => {
expect(() => {
setup();
}).not.toThrow();
});
describe('when empty columns are passed', () => {
const { columns } = dataBuilder.withEmptyColumns().build();
it('should render the custom empty messagee', () => {
const { wrapper } = setup({ columns });
const expected = EMPTY_MESSAGE;
const actual = wrapper
.find('.table-detail-table .ams-empty-message-cell')
.text();
expect(actual).toEqual(expected);
});
});
describe('when simple type columns are passed', () => {
const { columns } = dataBuilder.build();
it('should render the rows', () => {
const { wrapper } = setup({ columns });
const expected = columns.length;
const actual = wrapper.find('.table-detail-table .ams-table-row')
.length;
expect(actual).toEqual(expected);
});
it('should render the usage column', () => {
const { wrapper } = setup({ columns });
const expected = columns.length;
const actual = wrapper.find('.table-detail-table .usage-value').length;
expect(actual).toEqual(expected);
});
it('should render the actions column', () => {
const { wrapper } = setup({ columns });
const expected = columns.length;
const actual = wrapper.find('.table-detail-table .actions').length;
expect(actual).toEqual(expected);
});
});
describe('when complex type columns are passed', () => {
const { columns } = dataBuilder.withAllComplexColumns().build();
it('should render the rows', () => {
const { wrapper } = setup({ columns });
const expected = columns.length;
const actual = wrapper.find('.table-detail-table .ams-table-row')
.length;
expect(actual).toEqual(expected);
});
it('should render ColumnType components', () => {
const { wrapper } = setup({ columns });
const expected = columns.length;
const actual = wrapper.find(ColumnType).length;
expect(actual).toEqual(expected);
});
});
describe('when columns with no usage data are passed', () => {
const { columns } = dataBuilder.withComplexColumnsNoStats().build();
it('should render the rows', () => {
const { wrapper } = setup({ columns });
const expected = columns.length;
const actual = wrapper.find('.table-detail-table .ams-table-row')
.length;
expect(actual).toEqual(expected);
});
it('should not render the usage column', () => {
const { wrapper } = setup({ columns });
const expected = 0;
const actual = wrapper.find('.table-detail-table .usage-value').length;
expect(actual).toEqual(expected);
});
});
describe('when columns with one usage data entry are passed', () => {
const { columns } = dataBuilder.withComplexColumnsOneStat().build();
it('should render the usage column', () => {
const { wrapper } = setup({ columns });
const expected = columns.length;
const actual = wrapper.find('.table-detail-table .usage-value').length;
expect(actual).toEqual(expected);
});
});
describe('when notifications are not enabled', () => {
const { columns } = dataBuilder.build();
it('should not render the actions column', () => {
mockedNotificationsEnabled.mockReturnValue(false);
const { wrapper } = setup({ columns });
const expected = 0;
const actual = wrapper.find('.table-detail-table .actions').length;
expect(actual).toEqual(expected);
});
});
});
});
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import { Dropdown, MenuItem } from 'react-bootstrap';
import { OpenRequestAction } from 'ducks/notification/types';
import EditableSection from 'components/common/EditableSection';
import Table, {
TableColumn as ReusableTableColumn,
} from 'components/common/Table';
import { logAction } from 'ducks/utilMethods';
import { formatDate } from 'utils/dateUtils';
import { notificationsEnabled, getMaxLength } from 'config/config-utils';
import { TableColumn, RequestMetadataType } from 'interfaces';
import ColumnType from './ColumnType';
import ColumnDescEditableText from './ColumnDescEditableText';
import {
MORE_BUTTON_TEXT,
REQUEST_DESCRIPTION_TEXT,
EMPTY_MESSAGE,
EDITABLE_SECTION_TITLE,
COLUMN_STATS_TITLE,
} from './constants';
import './styles.scss';
export interface ColumnListProps {
columns: TableColumn[];
openRequestDescriptionDialog: (
requestMetadataType: RequestMetadataType,
columnName: string
) => OpenRequestAction;
database: string;
editText?: string;
editUrl?: string;
}
type ContentType = {
title: string;
description: string;
};
type DatatypeType = {
name: string;
database: string;
type: string;
};
type StatType = {
end_epoch: number;
start_epoch: number;
stat_type: string;
stat_val: string;
};
type FormattedDataType = {
content: ContentType;
type: DatatypeType;
usage: string | null;
stats: StatType | null;
action: string;
editText?: string;
editUrl?: string;
index: number;
isEditable: boolean;
};
type ExpandedRowProps = {
rowValue: FormattedDataType;
index: number;
};
const SHOW_STATS_THRESHOLD = 1;
const handleRowExpand = (rowValues) => {
logAction({
command: 'click',
label: `${rowValues.content.title} ${rowValues.type.type}`,
target_id: `column::${rowValues.content.title}`,
target_type: 'column stats',
});
};
// TODO: Move into utils
const getStatsInfoText = (startEpoch: number, endEpoch: number) => {
const startDate = startEpoch
? formatDate({ epochTimestamp: startEpoch })
: null;
const endDate = endEpoch ? formatDate({ epochTimestamp: 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;
};
// @ts-ignore
const ExpandedRowComponent: React.FC<ExpandedRowProps> = (
rowValue: FormattedDataType
) => {
const shouldRenderDescription = () => {
const { content, editText, editUrl, isEditable } = rowValue;
if (content.description) {
return true;
}
if (!editText && !editUrl && !isEditable) {
return false;
}
return true;
};
return (
<div className="expanded-row-container">
{shouldRenderDescription() && (
<EditableSection
title={EDITABLE_SECTION_TITLE}
readOnly={!rowValue.isEditable}
editText={rowValue.editText}
editUrl={rowValue.editUrl}
>
<ColumnDescEditableText
columnIndex={rowValue.index}
editable={rowValue.isEditable}
maxLength={getMaxLength('columnDescLength')}
value={rowValue.content.description}
/>
</EditableSection>
)}
{rowValue.stats && (
<div className="stat-collection-info">
<span className="stat-title">{COLUMN_STATS_TITLE} </span>
{getStatsInfoText(
rowValue.stats.start_epoch,
rowValue.stats.end_epoch
)}
</div>
)}
</div>
);
};
const ColumnList: React.FC<ColumnListProps> = ({
columns,
database,
editText,
editUrl,
openRequestDescriptionDialog,
}: ColumnListProps) => {
const formattedData: FormattedDataType[] = columns.map((item, index) => {
const hasItemStats = !!item.stats.length;
return {
content: {
title: item.name,
description: item.description,
},
type: {
type: item.col_type,
name: item.name,
database,
},
usage: hasItemStats ? item.stats[0].stat_val : '',
stats: hasItemStats ? item.stats[0] : null,
action: item.name,
isEditable: item.is_editable,
editText,
editUrl,
index,
};
});
const statsCount = formattedData.filter((item) => !!item.stats).length;
const hasStats = statsCount >= SHOW_STATS_THRESHOLD;
let formattedColumns: ReusableTableColumn[] = [
{
title: 'Name',
field: 'content',
component: ({ title, description }: ContentType) => (
<>
<div className="column-name">{title}</div>
<div className="column-desc truncated">{description}</div>
</>
),
},
{
title: 'Type',
field: 'type',
component: (type) => (
<div className="resource-type">
<ColumnType
type={type.type}
database={type.database}
columnName={type.name}
/>
</div>
),
},
];
if (hasStats) {
formattedColumns = [
...formattedColumns,
{
title: 'Usage',
field: 'usage',
horAlign: 'right',
component: (usage) => (
<p className="resource-type usage-value">{usage}</p>
),
},
];
}
if (notificationsEnabled()) {
formattedColumns = [
...formattedColumns,
{
title: '',
field: 'action',
width: 80,
horAlign: 'right',
component: (name, index) => (
<div className="actions">
<Dropdown
id={`detail-list-item-dropdown:${index}`}
pullRight
className="column-dropdown"
>
<Dropdown.Toggle noCaret>
<span className="sr-only">{MORE_BUTTON_TEXT}</span>
<img className="icon icon-more" alt="" />
</Dropdown.Toggle>
<Dropdown.Menu>
<MenuItem
onClick={() => {
openRequestDescriptionDialog(
RequestMetadataType.COLUMN_DESCRIPTION,
name
);
}}
>
{REQUEST_DESCRIPTION_TEXT}
</MenuItem>
</Dropdown.Menu>
</Dropdown>
</div>
),
},
];
}
return (
<Table
columns={formattedColumns}
data={formattedData}
options={{
rowHeight: 72,
emptyMessage: EMPTY_MESSAGE,
expandRow: ExpandedRowComponent,
onExpand: handleRowExpand,
tableClassName: 'table-detail-table',
}}
/>
);
};
export default ColumnList;
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
@import 'variables';
@import 'typography';
$description-max-width: 700px;
.column-list {
margin: 0;
}
.stat-title {
@extend %text-title-w3;
}
// Modification for Table to support dropdowns
// Extra nesting added for fixing specificity issues
.table-detail-table {
margin-bottom: 0;
.ams-table-body .actions .column-dropdown {
&.open {
background-color: $body-bg-tertiary;
.icon {
background-color: $icon-bg-dark;
}
}
.dropdown-toggle {
border: none;
border-radius: 4px;
height: 32px;
padding: 4px;
width: 32px;
.icon {
background-color: $icon-bg;
height: 22px;
margin: 0;
-webkit-mask-size: 22px;
mask-size: 22px;
width: 22px;
}
&:hover,
&:focus {
background-color: $body-bg-secondary;
.icon {
background-color: $icon-bg-dark;
}
}
}
}
.ams-table-header {
background-color: white;
}
.expanded-row-container {
padding-bottom: $spacer-2;
}
// Editable section modifications
.stat-collection-info {
padding-top: $spacer-1;
font-style: italic;
}
.editable-section-label-wrapper {
margin-bottom: 0 !important;
}
.markdown-wrapper p {
margin-top: 0;
}
.column-desc {
@extend %text-body-w3;
padding-right: $spacer-1;
max-width: $description-max-width;
.ams-table-row.has-child-expanded & {
display: none;
}
}
.column-type {
padding-right: $spacer-1;
}
.actions,
.usage-value {
padding-left: $spacer-1;
}
}
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
const defaultConfig = {
columns: [
{
col_type: 'string',
description: null,
is_editable: true,
name: 'simple_column_name_string',
sort_order: 0,
stats: [
{
end_epoch: 1600473600,
start_epoch: 1597881600,
stat_type: 'column_usage',
stat_val: '123',
},
],
},
{
col_type: 'int',
description: null,
is_editable: true,
name: 'simple_column_name_int',
sort_order: 1,
stats: [
{
end_epoch: 1600473600,
start_epoch: 1597881600,
stat_type: 'column_usage',
stat_val: '456',
},
],
},
{
col_type: 'bigint',
description: null,
is_editable: true,
name: 'simple_column_name_bigint',
sort_order: 2,
stats: [
{
end_epoch: 1600473600,
start_epoch: 1597881600,
stat_type: 'column_usage',
stat_val: '789',
},
],
},
{
col_type: 'timestamp',
description: null,
is_editable: true,
name: 'simple_column_name_timestamp',
sort_order: 8,
stats: [
{
end_epoch: 1600473600,
start_epoch: 1597881600,
stat_type: 'column_usage',
stat_val: '1011',
},
],
},
],
};
/**
* Generates test data for the table data
* @example
* let testData = new TestDataBuilder()
* .withAllComplexColumns()
* .build();
*/
function TestDataBuilder(config = {}) {
this.Klass = TestDataBuilder;
this.config = {
...defaultConfig,
...config,
};
this.withAllComplexColumns = () => {
const attr = {
columns: [
{
col_type:
'struct<trigger_event:string,backfill:boolean,graphql_version:string>',
description: null,
is_editable: true,
name: 'complex_column_name_2',
sort_order: 1,
stats: [
{
end_epoch: 1600473600,
start_epoch: 1597881600,
stat_type: 'column_usage',
stat_val: '111',
},
],
},
{
col_type: 'struct<code:string,timezone:string>',
description: null,
is_editable: true,
name: 'complex_column_name_3',
sort_order: 2,
stats: [
{
end_epoch: 1600473600,
start_epoch: 1597881600,
stat_type: 'column_usage',
stat_val: '222',
},
],
},
{
col_type:
'struct<route_id:string,shift:struct<shift_id:string,started_at:timestamp,ended_at:timestamp>>',
description: null,
is_editable: true,
name: 'complex_column_name_4',
sort_order: 3,
stats: [
{
end_epoch: 1600473600,
start_epoch: 1597881600,
stat_type: 'column_usage',
stat_val: '333',
},
],
},
],
};
return new this.Klass(attr);
};
this.withComplexColumnsNoStats = () => {
const attr = {
columns: [
{
col_type:
'struct<event_id:string,occurred_at:timestamp,sample_rate:double,__metadata__:struct<flattened:boolean,sending_service:string,streamcheck_selected_at:timestamp,is_priority:boolean,ingest_library_version:string,requires_field_values_as_strings:boolean,aic_time:bigint,fanner_time:bigint,send_to_realtime:boolean,origin_service:string,complex_persistence:boolean>,__debug_metadata__:struct<__is_empty_struct_set__:boolean>,enrichments:struct<is_simulated_ride:boolean>,logged_at:timestamp,source_pipeline:string,reporter_ip_address:string,reporter_hostname:string,http_request_id:string,event_name:string>',
description: null,
is_editable: true,
name: 'complex_column_name_1',
sort_order: 0,
stats: [],
},
{
col_type:
'struct<platform:string,device:string,app_name:string,app_version:string,platform_version:string>',
description: null,
is_editable: true,
name: 'complex_column_name_2',
sort_order: 28,
stats: [],
},
],
};
return new this.Klass(attr);
};
this.withComplexColumnsOneStat = () => {
const attr = {
columns: [
{
col_type:
'struct<event_id:string,occurred_at:timestamp,sample_rate:double,__metadata__:struct<flattened:boolean,sending_service:string,streamcheck_selected_at:timestamp,is_priority:boolean,ingest_library_version:string,requires_field_values_as_strings:boolean,aic_time:bigint,fanner_time:bigint,send_to_realtime:boolean,origin_service:string,complex_persistence:boolean>,__debug_metadata__:struct<__is_empty_struct_set__:boolean>,enrichments:struct<is_simulated_ride:boolean>,logged_at:timestamp,source_pipeline:string,reporter_ip_address:string,reporter_hostname:string,http_request_id:string,event_name:string>',
description: null,
is_editable: true,
name: 'complex_column_name_1',
sort_order: 0,
stats: [],
},
{
col_type:
'struct<platform:string,device:string,app_name:string,app_version:string,platform_version:string>',
description: null,
is_editable: true,
name: 'complex_column_name_2',
sort_order: 28,
stats: [],
},
{
col_type:
'struct<platform:string,device:string,app_name:string,app_version:string,platform_version:string>',
description: null,
is_editable: true,
name: 'complex_column_name_3',
sort_order: 28,
stats: [
{
end_epoch: 1600473600,
start_epoch: 1597881600,
stat_type: 'column_usage',
stat_val: '111',
},
],
},
],
};
return new this.Klass(attr);
};
this.withEmptyColumns = () => {
const attr = { columns: [] };
return new this.Klass(attr);
};
this.build = () => this.config;
}
export default TestDataBuilder;
// Copyright Contributors to the Amundsen project. // Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
@import 'variables'; @import 'variables';
.clickable-badge { .clickable-badge {
...@@ -27,6 +28,7 @@ ...@@ -27,6 +28,7 @@
&:focus { &:focus {
// TODO verify if this is what it is supposed to look like // TODO verify if this is what it is supposed to look like
// more round? // more round?
outline: 5px auto -webkit-focus-ring-color; outline: 5px auto -webkit-focus-ring-color;
outline-offset: -2px; outline-offset: -2px;
} }
......
...@@ -6,7 +6,7 @@ import * as React from 'react'; ...@@ -6,7 +6,7 @@ import * as React from 'react';
import { IconSizes, IconProps } from './types'; import { IconSizes, IconProps } from './types';
const DEFAULT_STROKE_COLOR = ''; const DEFAULT_STROKE_COLOR = '';
const DEFAULT_FILL_COLOR = '#D6D9DB'; const DEFAULT_FILL_COLOR = '#9191A8'; // gray40
export const DownIcon: React.FC<IconProps> = ({ export const DownIcon: React.FC<IconProps> = ({
stroke = DEFAULT_STROKE_COLOR, stroke = DEFAULT_STROKE_COLOR,
......
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import { IconSizes, IconProps } from './types';
const DEFAULT_STROKE_COLOR = '';
const DEFAULT_FILL_COLOR = '#9191A8'; // gray40
export const RightIcon: React.FC<IconProps> = ({
stroke = DEFAULT_STROKE_COLOR,
size = IconSizes.REGULAR,
fill = DEFAULT_FILL_COLOR,
}: IconProps) => {
return (
<svg width={size} height={size} viewBox="0 0 24 24">
<title>Right</title>
<defs>
<path
d="M11.836 12.968l4.05-3.897a1.02 1.02 0 011.427.014.981.981 0 01-.014 1.4l-4.73 4.553c-.18.174-.409.268-.641.283a1.026 1.026 0 01-.806-.276l-4.83-4.566a.972.972 0 01-.019-1.394 1.028 1.028 0 011.434-.02l4.129 3.903z"
id="prefix__a"
/>
</defs>
<g fill="none" fillRule="evenodd">
<mask id="prefix__b" fill="#fff">
<use xlinkHref="#prefix__a" />
</mask>
<use
fill={fill}
xlinkHref="#prefix__a"
stroke={stroke}
transform="rotate(-90 11.794 12.055)"
/>
</g>
</svg>
);
};
...@@ -6,7 +6,7 @@ import * as React from 'react'; ...@@ -6,7 +6,7 @@ import * as React from 'react';
import { IconSizes, IconProps } from './types'; import { IconSizes, IconProps } from './types';
const DEFAULT_STROKE_COLOR = ''; const DEFAULT_STROKE_COLOR = '';
const DEFAULT_FILL_COLOR = '#D6D9DB'; const DEFAULT_FILL_COLOR = '#9191A8'; // gray40
export const UpIcon: React.FC<IconProps> = ({ export const UpIcon: React.FC<IconProps> = ({
stroke = DEFAULT_STROKE_COLOR, stroke = DEFAULT_STROKE_COLOR,
......
...@@ -4,3 +4,4 @@ ...@@ -4,3 +4,4 @@
export * from './AlertIcon'; export * from './AlertIcon';
export * from './DownIcon'; export * from './DownIcon';
export * from './UpIcon'; export * from './UpIcon';
export * from './RightIcon';
...@@ -5,7 +5,7 @@ import React from 'react'; ...@@ -5,7 +5,7 @@ import React from 'react';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import StorySection from '../StorySection'; import StorySection from '../StorySection';
import { AlertIcon, DownIcon, UpIcon } from '.'; import { AlertIcon, DownIcon, UpIcon, RightIcon } from '.';
const stories = storiesOf('Attributes/Iconography', module); const stories = storiesOf('Attributes/Iconography', module);
...@@ -20,5 +20,8 @@ stories.add('SVG Icons', () => ( ...@@ -20,5 +20,8 @@ stories.add('SVG Icons', () => (
<StorySection title="Up"> <StorySection title="Up">
<UpIcon /> <UpIcon />
</StorySection> </StorySection>
<StorySection title="Right">
<RightIcon />
</StorySection>
</> </>
)); ));
...@@ -35,8 +35,10 @@ $shimmer-loader-border-size: 1px; ...@@ -35,8 +35,10 @@ $shimmer-loader-border-size: 1px;
} }
} }
@each $line in $shimmer-loader-lines { .shimmer-resource-line--1 {
.shimmer-resource-line--#{$line} { width: 90%;
width: 75%; }
}
.shimmer-resource-line--2 {
width: 75%;
} }
...@@ -623,7 +623,7 @@ describe('Table', () => { ...@@ -623,7 +623,7 @@ describe('Table', () => {
const expected = columns.length + 1; const expected = columns.length + 1;
const actual = wrapper const actual = wrapper
.find('.ams-table-body .ams-table-expanded-row .ams-table-cell') .find('.ams-table-body .ams-table-expanded-row .ams-table-cell')
.get(0).props.colSpan; .get(1).props.colSpan;
expect(actual).toEqual(expected); expect(actual).toEqual(expected);
}); });
...@@ -646,6 +646,61 @@ describe('Table', () => { ...@@ -646,6 +646,61 @@ describe('Table', () => {
}); });
}); });
}); });
describe('when emptyMessage is passed', () => {
const { columns, data } = dataBuilder.withEmptyData().build();
const TEST_EMPTY_MESSAGE = 'Test Empty Message';
describe('table header', () => {
it('renders one cell inside the header', () => {
const { wrapper } = setup({
data,
columns,
options: {
emptyMessage: TEST_EMPTY_MESSAGE,
},
});
const expected = 1;
const actual = wrapper.find(
'.ams-table-header .ams-table-heading-cell'
).length;
expect(actual).toEqual(expected);
});
});
describe('table body', () => {
it('renders one row', () => {
const { wrapper } = setup({
data,
columns,
options: {
emptyMessage: TEST_EMPTY_MESSAGE,
},
});
const expected = 1;
const actual = wrapper.find('.ams-table-row').length;
expect(actual).toEqual(expected);
});
it('renders the custom empty message', () => {
const { wrapper } = setup({
data,
columns,
options: {
emptyMessage: TEST_EMPTY_MESSAGE,
},
});
const expected = TEST_EMPTY_MESSAGE;
const actual = wrapper
.find('.ams-table-row .ams-empty-message-cell')
.text();
expect(actual).toEqual(expected);
});
});
});
}); });
}); });
...@@ -735,5 +790,202 @@ describe('Table', () => { ...@@ -735,5 +790,202 @@ describe('Table', () => {
}); });
}); });
}); });
describe('when onExpand is passed', () => {
const { columns, data } = dataBuilder.withCollapsedRow().build();
const expandRowComponent = (rowValue, index) => (
<strong>
{index}:{rowValue.value}
</strong>
);
describe('when clicking on expand button', () => {
it('calls the onExpand handler', () => {
const onExpandSpy = jest.fn();
const { wrapper } = setup({
data,
columns,
options: {
expandRow: expandRowComponent,
onExpand: onExpandSpy,
},
});
const expected = 1;
wrapper
.find('.ams-table-body .ams-table-expanding-button')
.at(0)
.simulate('click');
const actual = onExpandSpy.mock.calls.length;
expect(actual).toEqual(expected);
});
it('calls the onExpand handler with the row values and the index', () => {
const onExpandSpy = jest.fn();
const { wrapper } = setup({
data,
columns,
options: {
expandRow: expandRowComponent,
onExpand: onExpandSpy,
},
});
const expected = [data[0], 0];
wrapper
.find('.ams-table-body .ams-table-expanding-button')
.at(0)
.simulate('click');
const actual = onExpandSpy.mock.calls[0];
expect(actual).toEqual(expected);
});
});
describe('when clicking on multiple expand buttons', () => {
it('calls the onExpand handler several times', () => {
const onExpandSpy = jest.fn();
const { wrapper } = setup({
data,
columns,
options: {
expandRow: expandRowComponent,
onExpand: onExpandSpy,
},
});
const expected = 2;
wrapper
.find('.ams-table-body .ams-table-expanding-button')
.at(0)
.simulate('click');
wrapper
.find('.ams-table-body .ams-table-expanding-button')
.at(1)
.simulate('click');
const actual = onExpandSpy.mock.calls.length;
expect(actual).toEqual(expected);
});
});
describe('when clicking a second time on the expand button', () => {
it('does not call the onExpand handler', () => {
const onExpandSpy = jest.fn();
const { wrapper } = setup({
data,
columns,
options: {
expandRow: expandRowComponent,
onExpand: onExpandSpy,
},
});
const expected = 1;
wrapper
.find('.ams-table-body .ams-table-expanding-button')
.at(0)
.simulate('click');
wrapper
.find('.ams-table-body .ams-table-expanding-button')
.at(0)
.simulate('click');
const actual = onExpandSpy.mock.calls.length;
expect(actual).toEqual(expected);
});
});
});
describe('when onCollapse is passed', () => {
const { columns, data } = dataBuilder.withCollapsedRow().build();
const expandRowComponent = (rowValue, index) => (
<strong>
{index}:{rowValue.value}
</strong>
);
describe('when clicking on expand button', () => {
it('does not call the onCollapse handler', () => {
const onCollapseSpy = jest.fn();
const { wrapper } = setup({
data,
columns,
options: {
expandRow: expandRowComponent,
onCollapse: onCollapseSpy,
},
});
const expected = 0;
wrapper
.find('.ams-table-body .ams-table-expanding-button')
.at(0)
.simulate('click');
const actual = onCollapseSpy.mock.calls.length;
expect(actual).toEqual(expected);
});
});
describe('when clicking a second time on the expand button', () => {
it('calls the onCollapse handler', () => {
const onCollapseSpy = jest.fn();
const { wrapper } = setup({
data,
columns,
options: {
expandRow: expandRowComponent,
onCollapse: onCollapseSpy,
},
});
const expected = 1;
wrapper
.find('.ams-table-body .ams-table-expanding-button')
.at(0)
.simulate('click');
wrapper
.find('.ams-table-body .ams-table-expanding-button')
.at(0)
.simulate('click');
const actual = onCollapseSpy.mock.calls.length;
expect(actual).toEqual(expected);
});
it('calls the onCollapse handler with the row values and the index', () => {
const onCollapseSpy = jest.fn();
const { wrapper } = setup({
data,
columns,
options: {
expandRow: expandRowComponent,
onCollapse: onCollapseSpy,
},
});
const expected = [data[0], 0];
wrapper
.find('.ams-table-body .ams-table-expanding-button')
.at(0)
.simulate('click');
wrapper
.find('.ams-table-body .ams-table-expanding-button')
.at(0)
.simulate('click');
const actual = onCollapseSpy.mock.calls[0];
expect(actual).toEqual(expected);
});
});
});
}); });
}); });
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
import * as React from 'react'; import * as React from 'react';
import ShimmeringResourceLoader from '../ShimmeringResourceLoader'; import ShimmeringResourceLoader from '../ShimmeringResourceLoader';
import { DownIcon, UpIcon } from '../SVGIcons'; import { UpIcon, DownIcon } from '../SVGIcons';
import './styles.scss'; import './styles.scss';
...@@ -16,7 +16,6 @@ export interface TableColumn { ...@@ -16,7 +16,6 @@ export interface TableColumn {
horAlign?: TextAlignmentValues; horAlign?: TextAlignmentValues;
component?: (value: any, index: number) => React.ReactNode; component?: (value: any, index: number) => React.ReactNode;
width?: number; width?: number;
// className?: string;
// sortable?: bool (false) // sortable?: bool (false)
} }
...@@ -26,11 +25,14 @@ export interface TableOptions { ...@@ -26,11 +25,14 @@ export interface TableOptions {
numLoadingBlocks?: number; numLoadingBlocks?: number;
rowHeight?: number; rowHeight?: number;
expandRow?: (rowValue: any, index: number) => React.ReactNode; expandRow?: (rowValue: any, index: number) => React.ReactNode;
onExpand?: (rowValues: any, index: number) => void;
onCollapse?: (rowValues: any, index: number) => void;
emptyMessage?: string;
} }
export interface TableProps { export interface TableProps {
columns: TableColumn[]; columns: TableColumn[];
data: []; data: any[];
options?: TableOptions; options?: TableOptions;
} }
...@@ -49,15 +51,17 @@ type RowStyles = { ...@@ -49,15 +51,17 @@ type RowStyles = {
type EmptyRowProps = { type EmptyRowProps = {
colspan: number; colspan: number;
rowStyles: RowStyles; rowStyles: RowStyles;
emptyMessage?: string;
}; };
const EmptyRow: React.FC<EmptyRowProps> = ({ const EmptyRow: React.FC<EmptyRowProps> = ({
colspan, colspan,
rowStyles, rowStyles,
emptyMessage = DEFAULT_EMPTY_MESSAGE,
}: EmptyRowProps) => ( }: EmptyRowProps) => (
<tr className="ams-table-row" style={rowStyles}> <tr className="ams-table-row is-empty" style={rowStyles}>
<td className="ams-empty-message-cell" colSpan={colspan}> <td className="ams-empty-message-cell" colSpan={colspan}>
{DEFAULT_EMPTY_MESSAGE} {emptyMessage}
</td> </td>
</tr> </tr>
); );
...@@ -87,11 +91,17 @@ const ShimmeringBody: React.FC<ShimmeringBodyProps> = ({ ...@@ -87,11 +91,17 @@ const ShimmeringBody: React.FC<ShimmeringBodyProps> = ({
type ExpandingCellProps = { type ExpandingCellProps = {
index: number; index: number;
expandedRows: RowIndex[]; expandedRows: RowIndex[];
rowValues: any;
onClick: (index) => void; onClick: (index) => void;
onExpand?: (rowValues: any, index: number) => void;
onCollapse?: (rowValues: any, index: number) => void;
}; };
const ExpandingCell: React.FC<ExpandingCellProps> = ({ const ExpandingCell: React.FC<ExpandingCellProps> = ({
index, index,
onClick, onClick,
onExpand,
onCollapse,
rowValues,
expandedRows, expandedRows,
}: ExpandingCellProps) => { }: ExpandingCellProps) => {
const isExpanded = expandedRows.includes(index); const isExpanded = expandedRows.includes(index);
...@@ -112,6 +122,13 @@ const ExpandingCell: React.FC<ExpandingCellProps> = ({ ...@@ -112,6 +122,13 @@ const ExpandingCell: React.FC<ExpandingCellProps> = ({
: [...expandedRows, index]; : [...expandedRows, index];
onClick(newExpandedRows); onClick(newExpandedRows);
if (!isExpanded && onExpand) {
onExpand(rowValues, index);
}
if (isExpanded && onCollapse) {
onCollapse(rowValues, index);
}
}} }}
> >
<span className="sr-only">{EXPAND_ROW_TEXT}</span> <span className="sr-only">{EXPAND_ROW_TEXT}</span>
...@@ -134,13 +151,20 @@ const Table: React.FC<TableProps> = ({ ...@@ -134,13 +151,20 @@ const Table: React.FC<TableProps> = ({
numLoadingBlocks = DEFAULT_LOADING_ITEMS, numLoadingBlocks = DEFAULT_LOADING_ITEMS,
rowHeight = DEFAULT_ROW_HEIGHT, rowHeight = DEFAULT_ROW_HEIGHT,
expandRow = null, expandRow = null,
emptyMessage,
onExpand,
onCollapse,
} = options; } = options;
const fields = columns.map(({ field }) => field); const fields = columns.map(({ field }) => field);
const rowStyles = { height: `${rowHeight}px` }; const rowStyles = { height: `${rowHeight}px` };
const [expandedRows, setExpandedRows] = React.useState<RowIndex[]>([]); const [expandedRows, setExpandedRows] = React.useState<RowIndex[]>([]);
let body: React.ReactNode = ( let body: React.ReactNode = (
<EmptyRow colspan={fields.length} rowStyles={rowStyles} /> <EmptyRow
colspan={fields.length}
rowStyles={rowStyles}
emptyMessage={emptyMessage}
/>
); );
if (data.length) { if (data.length) {
...@@ -148,7 +172,11 @@ const Table: React.FC<TableProps> = ({ ...@@ -148,7 +172,11 @@ const Table: React.FC<TableProps> = ({
return ( return (
<React.Fragment key={`index:${index}`}> <React.Fragment key={`index:${index}`}>
<tr <tr
className="ams-table-row" className={`ams-table-row ${
expandRow && expandedRows.includes(index)
? 'has-child-expanded'
: ''
}`}
key={`index:${index}`} key={`index:${index}`}
style={rowStyles} style={rowStyles}
> >
...@@ -157,6 +185,9 @@ const Table: React.FC<TableProps> = ({ ...@@ -157,6 +185,9 @@ const Table: React.FC<TableProps> = ({
<ExpandingCell <ExpandingCell
index={index} index={index}
expandedRows={expandedRows} expandedRows={expandedRows}
onExpand={onExpand}
onCollapse={onCollapse}
rowValues={item}
onClick={setExpandedRows} onClick={setExpandedRows}
/> />
) : null} ) : null}
...@@ -200,6 +231,9 @@ const Table: React.FC<TableProps> = ({ ...@@ -200,6 +231,9 @@ const Table: React.FC<TableProps> = ({
}`} }`}
key={`expandedIndex:${index}`} key={`expandedIndex:${index}`}
> >
<td className="ams-table-cell">
{/* Placeholder for the collapse/expand cell */}
</td>
<td className="ams-table-cell" colSpan={fields.length + 1}> <td className="ams-table-cell" colSpan={fields.length + 1}>
{expandRow(item, index)} {expandRow(item, index)}
</td> </td>
......
...@@ -11,18 +11,24 @@ $table-header-border-width: 2px; ...@@ -11,18 +11,24 @@ $table-header-border-width: 2px;
$shimmer-block-height: 16px; $shimmer-block-height: 16px;
$shimmer-block-width: 40%; $shimmer-block-width: 40%;
$table-header-bottom-border-color: $gray15;
$table-header-background-color: $gray5;
$row-bottom-border-color: $gray10;
.ams-table { .ams-table {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
margin-bottom: $spacer-3; margin-bottom: $spacer-2;
box-sizing: border-box; box-sizing: border-box;
} }
.ams-table-header { .ams-table-header {
@extend %text-caption-w2; @extend %text-caption-w2;
background-color: $table-header-background-color;
color: $text-secondary; color: $text-secondary;
border-bottom: $table-header-border-width solid $gray15; border-bottom: $table-header-border-width solid
$table-header-bottom-border-color;
} }
.ams-table-heading-cell { .ams-table-heading-cell {
...@@ -38,13 +44,12 @@ $shimmer-block-width: 40%; ...@@ -38,13 +44,12 @@ $shimmer-block-width: 40%;
} }
} }
.ams-table-cell { .ams-table-row {
&:first-child { border-bottom: 1px solid $row-bottom-border-color;
padding-left: $spacer-3;
}
&:last-child { &.is-empty,
padding-right: $spacer-3; &.has-child-expanded {
border-bottom: 0;
} }
} }
...@@ -53,16 +58,39 @@ $shimmer-block-width: 40%; ...@@ -53,16 +58,39 @@ $shimmer-block-width: 40%;
&.is-expanded { &.is-expanded {
display: table-row; display: table-row;
border-bottom: 1px solid $row-bottom-border-color;
}
}
.ams-table-cell {
overflow: visible;
&:first-child {
padding-left: $spacer-3;
}
&:last-child {
padding-right: $spacer-3;
} }
} }
.ams-table-expanding-button { .ams-table-expanding-button {
border: 0; border: 0;
padding: 0;
background-color: white; background-color: white;
margin-right: $spacer-2;
svg { svg {
vertical-align: bottom; vertical-align: bottom;
} }
&:hover svg use {
fill: $gray60;
}
// TODO: Fix this so it is accessible
&:focus {
outline: none;
}
} }
// Loading State // Loading State
......
...@@ -96,6 +96,18 @@ stories.add('Customized Table', () => ( ...@@ -96,6 +96,18 @@ stories.add('Customized Table', () => (
options={{ rowHeight: 40 }} options={{ rowHeight: 40 }}
/> />
</StorySection> </StorySection>
<StorySection title="with Custom Empty Message">
<Table
columns={columns}
data={[]}
options={{ emptyMessage: 'Custom Empty Message Here!' }}
/>
</StorySection>
</>
));
stories.add('Collapsible Table', () => (
<>
<StorySection title="with Collapsed Rows"> <StorySection title="with Collapsed Rows">
<Table <Table
columns={columnsWithCollapsedRow} columns={columnsWithCollapsedRow}
...@@ -103,5 +115,39 @@ stories.add('Customized Table', () => ( ...@@ -103,5 +115,39 @@ stories.add('Customized Table', () => (
options={{ rowHeight: 40, expandRow: expandRowComponent }} options={{ rowHeight: 40, expandRow: expandRowComponent }}
/> />
</StorySection> </StorySection>
<StorySection
title="with onExpand handler"
text="You can open the console to see the handler being called"
>
<Table
columns={columnsWithCollapsedRow}
data={dataWithCollapsedRow}
options={{
rowHeight: 40,
expandRow: expandRowComponent,
onExpand: (rowValues, index) => {
console.log('Expanded row values:', rowValues);
console.log('Expanded row index:', index);
},
}}
/>
</StorySection>
<StorySection
title="with onCollapse handler"
text="You can open the console to see the handler being called"
>
<Table
columns={columnsWithCollapsedRow}
data={dataWithCollapsedRow}
options={{
rowHeight: 40,
expandRow: expandRowComponent,
onCollapse: (rowValues, index) => {
console.log('Collapsed row values:', rowValues);
console.log('Collapsed row index:', index);
},
}}
/>
</StorySection>
</> </>
)); ));
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
.tabs-component { .tabs-component {
.nav.nav-tabs { .nav.nav-tabs {
border-bottom: 1px solid $stroke; border-bottom: 1px solid $stroke;
margin-top: 20px; margin-top: $spacer-2;
padding: 0 12px; padding: 0 12px;
> li { > li {
......
...@@ -37,6 +37,7 @@ export function logAction(declaredProps: ActionLogParams) { ...@@ -37,6 +37,7 @@ export function logAction(declaredProps: ActionLogParams) {
const inferredProps = { const inferredProps = {
location: window.location.pathname, location: window.location.pathname,
}; };
postActionLog({ ...inferredProps, ...declaredProps }); postActionLog({ ...inferredProps, ...declaredProps });
} }
......
// Copyright Contributors to the Amundsen project. // Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
@import 'variables'; @import 'variables';
@import 'typography'; @import 'typography';
......
// Copyright Contributors to the Amundsen project. // Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
@import 'variables'; @import 'variables';
@import 'typography'; @import 'typography';
......
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import { OpenRequestAction } from 'ducks/notification/types';
import { TableColumn, RequestMetadataType } from 'interfaces';
import ColumnListItem from '../ColumnListItem';
import './styles.scss';
interface ColumnListProps {
columns?: TableColumn[];
openRequestDescriptionDialog: (
requestMetadataType: RequestMetadataType,
columnName: string
) => OpenRequestAction;
database: string;
editText?: string;
editUrl?: string;
}
const ColumnList: React.FC<ColumnListProps> = ({
columns,
database,
editText,
editUrl,
openRequestDescriptionDialog,
}: ColumnListProps) => {
if (columns.length < 1) {
return <div />;
// ToDo: return No Results Message
}
const columnList = columns.map((entry, index) => (
<ColumnListItem
openRequestDescriptionDialog={openRequestDescriptionDialog}
key={`column:${index}`}
data={entry}
database={database}
index={index}
editText={editText}
editUrl={editUrl}
/>
));
return <ul className="column-list list-group">{columnList}</ul>;
};
ColumnList.defaultProps = {
columns: [] as TableColumn[],
editText: '',
editUrl: '',
};
export default ColumnList;
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
.column-list {
margin: 0;
}
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import { shallow } from 'enzyme';
import AppConfig from 'config/config';
import EditableSection from 'components/common/EditableSection';
import * as UtilMethods from 'ducks/utilMethods';
import { RequestMetadataType } from 'interfaces/Notifications';
import ColumnStats from '../ColumnStats';
import ColumnDescEditableText from '../ColumnDescEditableText';
import { ColumnListItem, ColumnListItemProps } from '.';
import ColumnType from './ColumnType';
const logClickSpy = jest.spyOn(UtilMethods, 'logClick');
logClickSpy.mockImplementation(() => null);
const setup = (propOverrides?: Partial<ColumnListItemProps>) => {
const props = {
data: {
name: 'test_column_name',
description: 'This is a test description of this table',
is_editable: true,
col_type: 'varchar(32)',
stats: [
{
end_epoch: 1571616000,
start_epoch: 1571616000,
stat_type: 'count',
stat_val: '12345',
},
],
},
database: 'hive',
index: 0,
openRequestDescriptionDialog: jest.fn(),
editText: 'Click to edit discription in source',
editUrl: 'source/test_column_name',
...propOverrides,
};
const wrapper = shallow<ColumnListItem>(<ColumnListItem {...props} />);
return { wrapper, props };
};
describe('ColumnListItem', () => {
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 prevState = instance.state;
instance.toggleExpand(null);
expect(setStateSpy).toHaveBeenCalled();
});
});
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 ColumnType', () => {
const resourceType = wrapper.find('.resource-type');
expect(resourceType.find(ColumnType).exists()).toBe(true);
});
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);
});
describe('when expanded', () => {
it('renders column stats and editable text', () => {
instance.setState({ isExpanded: true });
const newWrapper = shallow(instance.render());
expect(newWrapper.find('.expanded-content').exists()).toBe(true);
expect(newWrapper.find(EditableSection).exists()).toBe(true);
expect(newWrapper.find(ColumnDescEditableText).exists()).toBe(true);
expect(newWrapper.find(ColumnStats).exists()).toBe(true);
});
it('renders EditableSection with non-empty description, edit text and url', () => {
instance.setState({ isExpanded: true });
const newWrapper = shallow(instance.render());
const editableSection = newWrapper.find(EditableSection);
expect(editableSection.props()).toMatchObject({
title: 'Description',
readOnly: !props.data.is_editable,
editText: props.editText,
editUrl: props.editUrl,
});
});
it('renders EditableSection with non-empty description, empty edit text and url', () => {
const { props, wrapper } = setup({
editText: '',
editUrl: '',
});
const instance = wrapper.instance();
instance.setState({ isExpanded: true });
const newWrapper = shallow(instance.render());
expect(newWrapper.find(EditableSection).exists()).toBe(true);
const editableSection = newWrapper.find(EditableSection);
expect(editableSection.props()).toMatchObject({
title: 'Description',
readOnly: !props.data.is_editable,
editText: props.editText,
editUrl: props.editUrl,
});
});
describe('when empty description', () => {
it('renders EditableSection with empty edit text and url for editable column description', () => {
const { props, wrapper } = setup({
data: {
name: 'test_column_name',
description: '',
is_editable: true,
col_type: 'varchar(32)',
stats: [
{
end_epoch: 1571616000,
start_epoch: 1571616000,
stat_type: 'count',
stat_val: '12345',
},
],
},
editText: '',
editUrl: '',
});
const instance = wrapper.instance();
instance.setState({ isExpanded: true });
const newWrapper = shallow(instance.render());
expect(newWrapper.find(EditableSection).exists()).toBe(true);
const editableSection = newWrapper.find(EditableSection);
expect(editableSection.props()).toMatchObject({
title: 'Description',
readOnly: !props.data.is_editable,
editText: props.editText,
editUrl: props.editUrl,
});
});
it('does not render EditableSection with empty edit text and url for non-editable column description', () => {
const { wrapper } = setup({
data: {
name: 'test_column_name',
description: '',
is_editable: false,
col_type: 'varchar(32)',
stats: [
{
end_epoch: 1571616000,
start_epoch: 1571616000,
stat_type: 'count',
stat_val: '12345',
},
],
},
editText: '',
editUrl: '',
});
const instance = wrapper.instance();
instance.setState({ isExpanded: true });
const newWrapper = shallow(instance.render());
expect(newWrapper.find(EditableSection).exists()).toBe(false);
});
it('renders EditableSection with non-empty edit text and url for non-editable column description', () => {
const { props, wrapper } = setup({
data: {
name: 'test_column_name',
description: '',
is_editable: false,
col_type: 'varchar(32)',
stats: [
{
end_epoch: 1571616000,
start_epoch: 1571616000,
stat_type: 'count',
stat_val: '12345',
},
],
},
editText: 'Click to edit description in source',
editUrl: 'source/test_column_name',
});
const instance = wrapper.instance();
instance.setState({ isExpanded: true });
const newWrapper = shallow(instance.render());
expect(newWrapper.find(EditableSection).exists()).toBe(true);
const editableSection = newWrapper.find(EditableSection);
expect(editableSection.props()).toMatchObject({
title: 'Description',
readOnly: !props.data.is_editable,
editText: props.editText,
editUrl: props.editUrl,
});
});
});
});
describe('when not expanded', () => {
let newWrapper;
beforeAll(() => {
instance.setState({ isExpanded: false });
newWrapper = shallow(instance.render());
});
it('does not render column stats', () => {
expect(newWrapper.find('.expanded-content').exists()).toBe(false);
expect(newWrapper.find(ColumnDescEditableText).exists()).toBe(false);
expect(newWrapper.find(ColumnStats).exists()).toBe(false);
});
it('has appropriate css for column details', () => {
expect(newWrapper.find('.column-details').hasClass('my-auto')).toBe(
true
);
});
});
});
});
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import { Dropdown, MenuItem } from 'react-bootstrap';
import { notificationsEnabled, getMaxLength } from 'config/config-utils';
import { logClick } from 'ducks/utilMethods';
import { TableColumn, RequestMetadataType } from 'interfaces';
import { OpenRequestAction } from 'ducks/notification/types';
import EditableSection from 'components/common/EditableSection';
import ColumnStats from '../ColumnStats';
import ColumnDescEditableText from '../ColumnDescEditableText';
import ColumnType from './ColumnType';
import './styles.scss';
const MORE_BUTTON_TEXT = 'More options';
const EDITABLE_SECTION_TITLE = 'Description';
export interface ColumnListItemProps {
data: TableColumn;
openRequestDescriptionDialog: (
requestMetadataType: RequestMetadataType,
columnName: string
) => OpenRequestAction;
database: string;
index: number;
editText: string;
editUrl: string;
}
interface ColumnListItemState {
isExpanded: boolean;
}
export class ColumnListItem extends React.Component<
ColumnListItemProps,
ColumnListItemState
> {
constructor(props) {
super(props);
this.state = {
isExpanded: false,
};
}
toggleExpand = (e) => {
const { data } = this.props;
if (!this.state.isExpanded) {
logClick(e, {
target_id: `column::${data.name}`,
target_type: 'column stats',
label: `${data.name} ${data.col_type}`,
});
}
if (this.shouldRenderDescription() || data.stats.length !== 0) {
this.setState((prevState) => ({
isExpanded: !prevState.isExpanded,
}));
}
};
openRequest = () => {
this.props.openRequestDescriptionDialog(
RequestMetadataType.COLUMN_DESCRIPTION,
this.props.data.name
);
};
stopPropagation = (e) => {
e.stopPropagation();
};
shouldRenderDescription = (): boolean => {
const { data, editText, editUrl } = this.props;
if (data.description) {
return true;
}
if (!editText && !editUrl && !data.is_editable) {
return false;
}
return true;
};
render() {
const { data, database } = this.props;
return (
<li className="list-group-item clickable" onClick={this.toggleExpand}>
<div className="column-list-item">
<section className="column-header">
<div
className={`column-details truncated ${
!this.state.isExpanded ? 'my-auto' : ''
}`}
>
<div className="column-name">{data.name}</div>
{!this.state.isExpanded && (
<div className="column-desc body-3 truncated">
{data.description}
</div>
)}
</div>
<div className="resource-type">
<ColumnType
columnName={data.name}
database={database}
type={data.col_type}
/>
</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
className="column-dropdown"
>
<Dropdown.Toggle noCaret>
<span className="sr-only">{MORE_BUTTON_TEXT}</span>
<img className="icon icon-more" alt="" />
</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}>
{this.shouldRenderDescription() && (
<EditableSection
title={EDITABLE_SECTION_TITLE}
readOnly={!data.is_editable}
editText={this.props.editText}
editUrl={this.props.editUrl}
>
<ColumnDescEditableText
columnIndex={this.props.index}
editable={data.is_editable}
maxLength={getMaxLength('columnDescLength')}
value={data.description}
/>
</EditableSection>
)}
</div>
<ColumnStats stats={data.stats} />
</section>
)}
</div>
</li>
);
}
}
export default ColumnListItem;
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
@import 'variables';
$column-header-height: 38px;
$column-details-cell-width: 50%;
$resource-type-cell-width: 100px;
.list-group-item .column-list-item {
padding: $spacer-2 $spacer-3;
.column-header {
display: flex;
height: $column-header-height;
.column-details {
flex-basis: $column-details-cell-width;
flex-grow: 1;
margin-right: $spacer-2;
}
.resource-type,
.badges,
.usage,
.actions {
align-items: center;
display: flex;
}
.resource-type {
flex-basis: $resource-type-cell-width;
overflow: hidden;
}
.actions {
.column-dropdown {
&.open {
background-color: $body-bg-tertiary;
.icon {
background-color: $icon-bg-dark;
}
}
.dropdown-toggle {
border: none;
border-radius: 4px;
height: 32px;
padding: 4px;
width: 32px;
.icon {
background-color: $icon-bg;
height: 22px;
margin: 0;
-webkit-mask-size: 22px;
mask-size: 22px;
width: 22px;
}
&:hover,
&:focus {
background-color: $body-bg-secondary;
.icon {
background-color: $icon-bg-dark;
}
}
}
}
}
}
.expanded-content {
margin-top: -$spacer-2;
}
.stop-propagation {
cursor: default;
}
}
...@@ -30,6 +30,7 @@ import TagInput from 'components/common/Tags/TagInput'; ...@@ -30,6 +30,7 @@ import TagInput from 'components/common/Tags/TagInput';
import EditableText from 'components/common/EditableText'; import EditableText from 'components/common/EditableText';
import LoadingSpinner from 'components/common/LoadingSpinner'; import LoadingSpinner from 'components/common/LoadingSpinner';
import EditableSection from 'components/common/EditableSection'; import EditableSection from 'components/common/EditableSection';
import ColumnList from 'components/ColumnList';
import { formatDateTimeShort } from 'utils/dateUtils'; import { formatDateTimeShort } from 'utils/dateUtils';
import { getLoggingParams } from 'utils/logUtils'; import { getLoggingParams } from 'utils/logUtils';
...@@ -41,7 +42,6 @@ import { ...@@ -41,7 +42,6 @@ import {
RequestMetadataType, RequestMetadataType,
} from 'interfaces'; } from 'interfaces';
import ColumnList from './ColumnList';
import DataPreviewButton from './DataPreviewButton'; import DataPreviewButton from './DataPreviewButton';
import ExploreButton from './ExploreButton'; import ExploreButton from './ExploreButton';
import FrequentUsers from './FrequentUsers'; import FrequentUsers from './FrequentUsers';
......
...@@ -40,4 +40,13 @@ ...@@ -40,4 +40,13 @@
width: 20px; width: 20px;
} }
} }
.nav.nav-tabs {
margin-top: $spacer-1;
padding: 0 $spacer-2;
}
.tabs-component .nav.nav-tabs > li {
margin: 0 $spacer-1;
}
} }
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