Unverified Commit 36aebd28 authored by Marcos Iglesias's avatar Marcos Iglesias Committed by GitHub

feat: Adds sortable table detail page (#691)

Signed-off-by: 's avatarMarcos Iglesias Valle <golodhros@gmail.com>
parent 7a14f5f8
......@@ -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"],
[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:3110699653": [
[220, 22, 13, "Type \'unknown\' is not assignable to type \'ReactNode\'.\\n Type \'unknown\' is not assignable to type \'ReactPortal\'.", "971959308"]
"js/components/common/Table/index.tsx:489040313": [
[235, 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": [
[63, 22, 6, "Type \'undefined\' is not assignable to type \'GetAllTagsRequest\'.", "1979467425"],
......@@ -124,10 +124,10 @@ exports[`strict null compilation`] = {
[93, 4, 11, "Type \'Tag[]\' is not assignable to type \'never[]\'.", "255414113"],
[98, 4, 9, "Type \'Tag[]\' is not assignable to type \'never[]\'.", "3803340896"]
],
"js/config/config-utils.ts:1027600130": [
"js/config/config-utils.ts:1798174672": [
[86, 4, 25, "\'style\' is specified more than once, so this usage will be overwritten.", "1214862559"]
],
"js/config/index.spec.ts:1695312294": [
"js/config/index.spec.ts:3155903042": [
[17, 6, 61, "Object is possibly \'undefined\'.", "1496333578"],
[51, 6, 61, "Object is possibly \'undefined\'.", "1496333578"]
],
......@@ -246,12 +246,6 @@ exports[`strict null compilation`] = {
"js/fixtures/globalState.ts:3931474038": [
[69, 4, 12, "Type \'null\' is not assignable to type \'string\'.", "124336133"]
],
"js/fixtures/metadata/table.ts:2912527294": [
[52, 6, 11, "Type \'null\' is not assignable to type \'string\'.", "1848305091"],
[171, 4, 11, "Type \'null\' is not assignable to type \'string\'.", "1848305091"],
[183, 4, 11, "Type \'null\' is not assignable to type \'string\'.", "1848305091"],
[195, 4, 11, "Type \'null\' is not assignable to type \'string\'.", "1848305091"]
],
"js/fixtures/mockRouter.ts:3563515077": [
[30, 6, 4, "Type \'null\' is not assignable to type \'{ (path: string, state?: {} | null | undefined): void; (location: LocationDescriptorObject<{} | null | undefined>): void; }\'.", "2088074939"],
[31, 6, 7, "Type \'null\' is not assignable to type \'{ (path: string, state?: {} | null | undefined): void; (location: LocationDescriptorObject<{} | null | undefined>): void; }\'.", "1364993353"],
......@@ -329,8 +323,8 @@ exports[`strict null compilation`] = {
"js/pages/TableDetailPage/SourceLink/index.spec.tsx:4194369848": [
[42, 21, 100, "Object is possibly \'null\'.", "1316242242"]
],
"js/pages/TableDetailPage/TableDashboardResourceList/index.tsx:3147978263": [
[64, 2, 15, "No overload matches this call.\\n The last overload gave the following error.\\n Argument of type \'(state: GlobalState) => { dashboards: DashboardResource[]; isLoading: boolean; errorText: string | undefined; }\' is not assignable to parameter of type \'MapStateToPropsParam<StateFromProps, OwnProps, {}>\'.\\n Type \'(state: GlobalState) => { dashboards: DashboardResource[]; isLoading: boolean; errorText: string | undefined; }\' is not assignable to type \'MapStateToPropsFactory<StateFromProps, OwnProps, {}>\'.\\n Type \'{ dashboards: DashboardResource[]; isLoading: boolean; errorText: string | undefined; }\' is not assignable to type \'MapStateToProps<StateFromProps, OwnProps, {}>\'.\\n Type \'{ dashboards: DashboardResource[]; isLoading: boolean; errorText: string | undefined; }\' provides no match for the signature \'(state: {}, ownProps: OwnProps): StateFromProps\'.", "1389821531"]
"js/pages/TableDetailPage/TableDashboardResourceList/index.tsx:3276822301": [
[65, 2, 15, "No overload matches this call.\\n The last overload gave the following error.\\n Argument of type \'(state: GlobalState) => { dashboards: DashboardResource[]; isLoading: boolean; errorText: string | undefined; }\' is not assignable to parameter of type \'MapStateToPropsParam<StateFromProps, OwnProps, {}>\'.\\n Type \'(state: GlobalState) => { dashboards: DashboardResource[]; isLoading: boolean; errorText: string | undefined; }\' is not assignable to type \'MapStateToPropsFactory<StateFromProps, OwnProps, {}>\'.\\n Type \'{ dashboards: DashboardResource[]; isLoading: boolean; errorText: string | undefined; }\' is not assignable to type \'MapStateToProps<StateFromProps, OwnProps, {}>\'.\\n Type \'{ dashboards: DashboardResource[]; isLoading: boolean; errorText: string | undefined; }\' provides no match for the signature \'(state: {}, ownProps: OwnProps): StateFromProps\'.", "1389821531"]
],
"js/pages/TableDetailPage/TableOwnerEditor/index.spec.tsx:3400494524": [
[28, 10, 6, "Type \'{ [x: string]: { manager_id: null; manager_fullname: null; manager_email: null; profile_url: string; role_name: null; display_name: null; github_username: null; team_name: null; last_name: null; full_name: null; ... 6 more ...; user_id: string; }; }\' is not assignable to type \'OwnerDict\'.\\n Index signatures are incompatible.\\n Type \'{ manager_id: null; manager_fullname: null; manager_email: null; profile_url: string; role_name: null; display_name: null; github_username: null; team_name: null; last_name: null; full_name: null; ... 6 more ...; user_id: string; }\' is not assignable to type \'User\'.", "1719502071"]
......@@ -341,16 +335,13 @@ exports[`strict null compilation`] = {
"js/pages/TableDetailPage/WatermarkLabel/index.tsx:1354016727": [
[80, 34, 3, "Argument of type \'string | null\' is not assignable to parameter of type \'string\'.\\n Type \'null\' is not assignable to type \'string\'.", "193412913"]
],
"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"]
"js/pages/TableDetailPage/index.spec.tsx:120018363": [
[33, 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:3027031293": [
[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"],
[279, 16, 7, "Type \'string | null\' is not assignable to type \'string | undefined\'.\\n Type \'null\' is not assignable to type \'string | undefined\'.", "3817619378"],
[327, 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"],
[341, 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"],
[346, 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/pages/TableDetailPage/index.tsx:987689418": [
[181, 10, 13, "Type \'null\' is not assignable to type \'((newValue: string, onSuccess?: (() => any) | undefined, onFailure?: (() => any) | undefined) => void) | undefined\'.", "67794331"],
[236, 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"],
[325, 16, 7, "Type \'string | null\' is not assignable to type \'string | undefined\'.\\n Type \'null\' is not assignable to type \'string | undefined\'.", "3817619378"]
],
"js/utils/navigationUtils.ts:1127210474": [
[19, 50, 21, "Type \'undefined\' cannot be used as an index type.", "602535635"]
......
......@@ -7,7 +7,11 @@ import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import { mocked } from 'ts-jest/utils';
import { notificationsEnabled } from 'config/config-utils';
import { SortDirection } from 'interfaces';
import {
notificationsEnabled,
getTableSortCriterias,
} from 'config/config-utils';
import globalState from 'fixtures/globalState';
import ColumnList, { ColumnListProps } from '.';
......@@ -19,6 +23,7 @@ import TestDataBuilder from './testDataBuilder';
jest.mock('config/config-utils');
const mockedNotificationsEnabled = mocked(notificationsEnabled, true);
const mockedGetTableSortCriterias = mocked(getTableSortCriterias, true);
const dataBuilder = new TestDataBuilder();
const middlewares = [];
const mockStore = configureStore(middlewares);
......@@ -46,6 +51,18 @@ const setup = (propOverrides?: Partial<ColumnListProps>) => {
};
describe('ColumnList', () => {
mockedGetTableSortCriterias.mockReturnValue({
sort_order: {
name: 'Table Default',
key: 'sort_order',
direction: SortDirection.ascending,
},
usage: {
name: 'Usage Count',
key: 'usage',
direction: SortDirection.descending,
},
});
mockedNotificationsEnabled.mockReturnValue(true);
describe('render', () => {
......@@ -96,6 +113,69 @@ describe('ColumnList', () => {
expect(actual).toEqual(expected);
});
describe('when usage sorting is passed', () => {
it('should sort the data by that value', () => {
const { wrapper } = setup({
columns,
sortBy: {
name: 'Usage',
key: 'usage',
direction: SortDirection.descending,
},
});
const expected = 'simple_column_name_timestamp';
const actual = wrapper
.find('.table-detail-table .ams-table-row')
.at(0)
.find('.column-name')
.text();
expect(actual).toEqual(expected);
});
});
describe('when default sorting is passed', () => {
it('should sort the data by that value', () => {
const { wrapper } = setup({
columns,
sortBy: {
name: 'Default',
key: 'sort_order',
direction: SortDirection.ascending,
},
});
const expected = 'simple_column_name_string';
const actual = wrapper
.find('.table-detail-table .ams-table-row')
.at(0)
.find('.column-name')
.text();
expect(actual).toEqual(expected);
});
});
describe('when name sorting is passed', () => {
it('should sort the data by name', () => {
const { wrapper } = setup({
columns,
sortBy: {
name: 'Name',
key: 'name',
direction: SortDirection.descending,
},
});
const expected = 'simple_column_name_bigint';
const actual = wrapper
.find('.table-detail-table .ams-table-row')
.at(0)
.find('.column-name')
.text();
expect(actual).toEqual(expected);
});
});
});
describe('when complex type columns are passed', () => {
......@@ -152,6 +232,39 @@ describe('ColumnList', () => {
});
});
describe('when columns with serveral stats including usage are passed', () => {
const { columns } = dataBuilder.withSeveralStats().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 usage sorting is passed', () => {
it('should sort the data by that value', () => {
const { wrapper } = setup({
columns,
sortBy: {
name: 'Usage',
key: 'usage',
direction: SortDirection.ascending,
},
});
const expected = 'complex_column_name_2';
const actual = wrapper
.find('.table-detail-table .ams-table-row')
.at(0)
.find('.column-name')
.text();
expect(actual).toEqual(expected);
});
});
});
describe('when notifications are not enabled', () => {
const { columns } = dataBuilder.build();
......
......@@ -9,11 +9,22 @@ import { OpenRequestAction } from 'ducks/notification/types';
import EditableSection from 'components/common/EditableSection';
import Table, {
TableColumn as ReusableTableColumn,
TextAlignmentValues,
} from 'components/common/Table';
import { logAction } from 'ducks/utilMethods';
import { notificationsEnabled, getMaxLength } from 'config/config-utils';
import { TableColumn, RequestMetadataType } from 'interfaces';
import {
notificationsEnabled,
getMaxLength,
getTableSortCriterias,
} from 'config/config-utils';
import {
TableColumn,
RequestMetadataType,
SortCriteria,
SortDirection,
} from 'interfaces';
import ColumnType from './ColumnType';
import ColumnDescEditableText from './ColumnDescEditableText';
......@@ -38,6 +49,7 @@ export interface ColumnListProps {
database: string;
editText?: string;
editUrl?: string;
sortBy?: SortCriteria;
}
type ContentType = {
......@@ -61,12 +73,14 @@ type StatType = {
type FormattedDataType = {
content: ContentType;
type: DatatypeType;
usage: string | null;
usage: number | null;
stats: StatType | null;
action: string;
editText?: string;
editUrl?: string;
index: number;
name: string;
sort_order: string;
isEditable: boolean;
};
......@@ -75,7 +89,52 @@ type ExpandedRowProps = {
index: number;
};
// TODO: Move this into the configuration once we have more info about the rest of stats
const USAGE_STAT_TYPE = 'column_usage';
const SHOW_STATS_THRESHOLD = 1;
const DEFAULT_SORTING: SortCriteria = {
name: 'Table Default',
key: 'sort_order',
direction: SortDirection.ascending,
};
const getSortingFunction = (
formattedData: FormattedDataType[],
sortBy: SortCriteria
) => {
const numberSortingFunction = (a, b) => {
return b[sortBy.key] - a[sortBy.key];
};
const stringSortingFunction = (a, b) => {
if (a[sortBy.key] && b[sortBy.key]) {
return a[sortBy.key].localeCompare(b[sortBy.key]);
}
return null;
};
if (!formattedData.length) {
return numberSortingFunction;
}
return Number.isInteger(formattedData[0][sortBy.key])
? numberSortingFunction
: stringSortingFunction;
};
const getUsageStat = (item) => {
const hasItemStats = !!item.stats.length;
if (hasItemStats) {
const usageStat = item.stats.find((s) => {
return s.stat_type === USAGE_STAT_TYPE;
});
return usageStat ? +usageStat.stat_val : null;
}
return null;
};
const handleRowExpand = (rowValues) => {
logAction({
......@@ -139,6 +198,7 @@ const ColumnList: React.FC<ColumnListProps> = ({
editText,
editUrl,
openRequestDescriptionDialog,
sortBy = DEFAULT_SORTING,
}: ColumnListProps) => {
const formattedData: FormattedDataType[] = columns.map((item, index) => {
const hasItemStats = !!item.stats.length;
......@@ -153,9 +213,11 @@ const ColumnList: React.FC<ColumnListProps> = ({
name: item.name,
database,
},
usage: hasItemStats ? item.stats[0].stat_val : '',
sort_order: item.sort_order,
usage: getUsageStat(item),
stats: hasItemStats ? item.stats[0] : null,
action: item.name,
name: item.name,
isEditable: item.is_editable,
editText,
editUrl,
......@@ -163,7 +225,14 @@ const ColumnList: React.FC<ColumnListProps> = ({
};
});
const statsCount = formattedData.filter((item) => !!item.stats).length;
const hasStats = statsCount >= SHOW_STATS_THRESHOLD;
const hasUsageStat =
getTableSortCriterias().usage && statsCount >= SHOW_STATS_THRESHOLD;
let formattedAndOrderedData = formattedData.sort(
getSortingFunction(formattedData, sortBy)
);
if (sortBy.direction === SortDirection.ascending) {
formattedAndOrderedData = formattedAndOrderedData.reverse();
}
let formattedColumns: ReusableTableColumn[] = [
{
......@@ -191,13 +260,13 @@ const ColumnList: React.FC<ColumnListProps> = ({
},
];
if (hasStats) {
if (hasUsageStat) {
formattedColumns = [
...formattedColumns,
{
title: 'Usage',
field: 'usage',
horAlign: 'right',
horAlign: TextAlignmentValues.right,
component: (usage) => (
<p className="resource-type usage-value">{usage}</p>
),
......@@ -212,7 +281,7 @@ const ColumnList: React.FC<ColumnListProps> = ({
title: '',
field: 'action',
width: 80,
horAlign: 'right',
horAlign: TextAlignmentValues.right,
component: (name, index) => (
<div className="actions">
<Dropdown
......@@ -246,7 +315,7 @@ const ColumnList: React.FC<ColumnListProps> = ({
return (
<Table
columns={formattedColumns}
data={formattedData}
data={formattedAndOrderedData}
options={{
rowHeight: 72,
emptyMessage: EMPTY_MESSAGE,
......
......@@ -99,7 +99,6 @@ $description-max-width-small: 300px;
}
.usage-value {
font-variant-numeric: tabular-nums;
font-family: $text-heading-font-family;
font-family: $font-family-monospace-code;
}
}
......@@ -206,6 +206,68 @@ function TestDataBuilder(config = {}) {
return new this.Klass(attr);
};
this.withSeveralStats = () => {
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: 'test_stat',
stat_val: '000',
},
{
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.withEmptyColumns = () => {
const attr = { columns: [] };
......
......@@ -8,8 +8,14 @@ import { UpIcon, DownIcon } from '../SVGIcons';
import './styles.scss';
type TextAlignmentValues = 'left' | 'right' | 'center';
// export type SortDirection = 'asc' | 'desc';
// export type SortCriteria = { key: string; direction: SortDirection };
export enum TextAlignmentValues {
left = 'left',
right = 'right',
center = 'center',
}
export interface TableColumn {
title: string;
field: string;
......@@ -41,7 +47,7 @@ const EXPAND_ROW_TEXT = 'Expand Row';
const DEFAULT_LOADING_ITEMS = 3;
const DEFAULT_ROW_HEIGHT = 30;
const EXPANDING_CELL_WIDTH = '70px';
const DEFAULT_TEXT_ALIGNMENT = 'left';
const DEFAULT_TEXT_ALIGNMENT = TextAlignmentValues.left;
const DEFAULT_CELL_WIDTH = 'auto';
const ALIGNEMENT_TO_CLASS_MAP = {
left: 'is-left-aligned',
......@@ -201,9 +207,9 @@ const Table: React.FC<TableProps> = ({
) : null}
{Object.entries(item)
.filter(([key]) => fields.includes(key))
.map(([key, value], index) => {
.map(([key, value], rowIndex) => {
const columnInfo = columns.find(({ field }) => field === key);
const horAlign = columnInfo
const horAlign: TextAlignmentValues = columnInfo
? columnInfo.horAlign || DEFAULT_TEXT_ALIGNMENT
: DEFAULT_TEXT_ALIGNMENT;
const width =
......@@ -216,7 +222,7 @@ const Table: React.FC<TableProps> = ({
// TODO: Improve the typing of this
let cellContent: React.ReactNode | typeof value = value;
if (columnInfo && columnInfo.component) {
cellContent = columnInfo.component(value, index);
cellContent = columnInfo.component(value, rowIndex);
}
return (
......@@ -224,7 +230,7 @@ const Table: React.FC<TableProps> = ({
className={`ams-table-cell ${getCellAlignmentClass(
horAlign
)}`}
key={`index:${index}`}
key={`index:${rowIndex}`}
style={cellStyle}
>
{cellContent}
......
import { AppConfig } from './config-types';
import { FilterType, ResourceType } from '../interfaces';
import { FilterType, ResourceType, SortDirection } from '../interfaces';
const configDefault: AppConfig = {
badges: {},
......@@ -162,6 +162,18 @@ const configDefault: AppConfig = {
type: FilterType.INPUT_SELECT,
},
],
sortCriterias: {
sort_order: {
name: 'Table Default',
key: 'sort_order',
direction: SortDirection.ascending,
},
name: {
name: 'Alphabetical',
key: 'name',
direction: SortDirection.descending,
},
},
supportedDescriptionSources: {
github: {
displayName: 'Github',
......
import { FilterType, ResourceType } from '../interfaces';
import { FilterType, ResourceType, SortCriteria } from '../interfaces';
/**
* AppConfig and AppConfigCustom should share the same definition, except each field in AppConfigCustom
......@@ -130,6 +130,13 @@ type DescriptionSourceConfig = {
[id: string]: { displayName: string; iconPath: string };
};
/**
* Shows criterias to sort tables
*/
type SortCriteriaConfig = {
[key: string]: SortCriteria;
};
/**
* Base interface for all possible ResourceConfig objects
*
......@@ -144,6 +151,7 @@ interface BaseResourceConfig {
interface TableResourceConfig extends BaseResourceConfig {
supportedDescriptionSources?: DescriptionSourceConfig;
sortCriterias?: SortCriteriaConfig;
}
export enum BadgeStyle {
......
......@@ -146,6 +146,19 @@ export function getCuratedTags(): string[] {
return AppConfig.browse.curatedTags;
}
/**
* Returns a list of table sort options
*/
export function getTableSortCriterias() {
const config = AppConfig.resourceConfig[ResourceType.table];
if (config && config.sortCriterias) {
return config.sortCriterias;
}
return {};
}
/**
* Checks if nav links are active
*/
......
......@@ -78,6 +78,15 @@ describe('getFilterConfigByResource', () => {
});
});
describe('getTableSortCriterias', () => {
it('returns the sorting criterias for tables', () => {
const expectedValue =
AppConfig.resourceConfig[ResourceType.table].sortCriterias;
expect(ConfigUtils.getTableSortCriterias()).toBe(expectedValue);
});
});
describe('getBadgeConfig', () => {
AppConfig.badges = {
test_1: {
......
......@@ -18,6 +18,7 @@ export const tableMetadata: TableMetadata = {
col_type: 'bigint',
description: 'Test Value',
is_editable: true,
sort_order: '0',
name: 'ride_id',
stats: [
{
......@@ -45,13 +46,15 @@ export const tableMetadata: TableMetadata = {
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,
sort_order: '1',
name: 'ds',
stats: [],
},
{
col_type: 'string',
description: null,
description: 'Route_id Description',
is_editable: true,
sort_order: '2',
name: 'route_id',
stats: [
{
......@@ -169,7 +172,7 @@ export const tableMetadata: TableMetadata = {
export const relatedDashboards: DashboardResource[] = [
{
group_name: 'Test Group 1',
description: null,
description: 'Test Group 1 Description',
cluster: 'gold',
group_url: 'https://app.mode.com/testCompany/spaces/1234',
uri: 'mode_dashboard://gold.1234/23445asb',
......@@ -181,7 +184,7 @@ export const relatedDashboards: DashboardResource[] = [
},
{
group_name: 'Test Group 2',
description: null,
description: 'Test Group 2 Description',
cluster: 'gold',
group_url: 'https://app.mode.com/testCompany/spaces/345asd',
uri: 'mode_dashboard://gold.345asd/asdfas001',
......@@ -193,7 +196,7 @@ export const relatedDashboards: DashboardResource[] = [
},
{
group_name: 'Test Group 3',
description: null,
description: 'Test Group 3 Description',
cluster: 'gold',
group_url: 'https://app.mode.com/testCompany/spaces/casdg80',
uri: 'mode_dashboard://gold.casdg80/123566',
......
......@@ -42,6 +42,16 @@ export interface TableResource extends Resource {
badges?: any[]; // TODO replace with new badges later @allisonsuarez
}
export enum SortDirection {
ascending = 'asc',
descending = 'desc',
}
export interface SortCriteria {
name: string;
key: string;
direction: SortDirection;
}
export interface UserResource extends Resource, PeopleUser {
type: ResourceType.user;
}
......
......@@ -61,6 +61,7 @@ export interface TableColumn {
description: string;
is_editable: boolean;
col_type: string;
sort_order: string;
stats: TableColumnStats[];
}
......
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import { Dropdown } from 'react-bootstrap';
import { mount } from 'enzyme';
import { SortDirection } from 'interfaces';
import ListSortingDropdown, { ListSortingDropdownProps } from '.';
const DEFAULT_SORTING = {
sort_order: {
name: 'Table Default',
key: 'sort_order',
direction: SortDirection.ascending,
},
};
const USAGE_SORTING = {
usage: {
name: 'Usage Count',
key: 'usage',
direction: SortDirection.descending,
},
};
const setup = (propOverrides?: Partial<ListSortingDropdownProps>) => {
const props = {
options: {},
...propOverrides,
};
const wrapper = mount<typeof ListSortingDropdown>(
<ListSortingDropdown {...props} />
);
return { props, wrapper };
};
describe('ListSortingDropdown', () => {
describe('render', () => {
it('renders without issues', () => {
expect(() => {
setup();
}).not.toThrow();
});
describe('when no options are passed', () => {
it('does not render the component', () => {
const { wrapper } = setup();
const expected = 0;
const actual = wrapper.find('.list-sorting-dropdown').length;
expect(actual).toEqual(expected);
});
});
describe('when one option is passed', () => {
it('renders a DropDown component', () => {
const { wrapper } = setup({ options: DEFAULT_SORTING });
const expected = 1;
const actual = wrapper.find(Dropdown).length;
expect(actual).toEqual(expected);
});
it('renders one item', () => {
const { wrapper } = setup({ options: DEFAULT_SORTING });
const expected = 1;
const actual = wrapper.find('.list-sorting-dropdown .radio-label')
.length;
expect(actual).toEqual(expected);
});
it('is selected by default', () => {
const { wrapper } = setup({ options: DEFAULT_SORTING });
const expected = true;
const actual = wrapper
.find('.list-sorting-dropdown .radio-label input')
.prop('checked');
expect(actual).toEqual(expected);
});
});
describe('when two options are passed', () => {
it('renders a DropDown component', () => {
const { wrapper } = setup({
options: { ...DEFAULT_SORTING, ...USAGE_SORTING },
});
const expected = 1;
const actual = wrapper.find(Dropdown).length;
expect(actual).toEqual(expected);
});
it('renders two items', () => {
const { wrapper } = setup({
options: { ...DEFAULT_SORTING, ...USAGE_SORTING },
});
const expected = 2;
const actual = wrapper.find('.list-sorting-dropdown .radio-label')
.length;
expect(actual).toEqual(expected);
});
it('selects the first one by default', () => {
const { wrapper } = setup({
options: { ...DEFAULT_SORTING, ...USAGE_SORTING },
});
const expected = true;
const actual = wrapper
.find('.list-sorting-dropdown .radio-label input')
.at(0)
.prop('checked');
expect(actual).toEqual(expected);
});
});
});
describe('lifetime', () => {
describe('when selecting an option', () => {
it('should make it the selected', () => {
const { wrapper } = setup({
options: { ...DEFAULT_SORTING, ...USAGE_SORTING },
});
const expected = true;
wrapper
.find('.list-sorting-dropdown .radio-label input')
.at(1)
.simulate('change', { target: { value: 'usage' } });
const actual = wrapper
.find('.list-sorting-dropdown .radio-label input')
.at(1)
.prop('checked');
expect(actual).toEqual(expected);
});
it('should call the onChange handler', () => {
const onChangeSpy = jest.fn();
const { wrapper } = setup({
onChange: onChangeSpy,
options: { ...DEFAULT_SORTING, ...USAGE_SORTING },
});
const expected = 1;
wrapper
.find('.list-sorting-dropdown .radio-label input')
.at(1)
.simulate('change', { target: { value: 'usage' } });
const actual = onChangeSpy.mock.calls.length;
expect(actual).toEqual(expected);
});
it('should call the onChange handler with the proper value', () => {
const onChangeSpy = jest.fn();
const { wrapper } = setup({
onChange: onChangeSpy,
options: { ...DEFAULT_SORTING, ...USAGE_SORTING },
});
const expected = ['usage'];
wrapper
.find('.list-sorting-dropdown .radio-label input')
.at(1)
.simulate('change', { target: { value: 'usage' } });
const actual = onChangeSpy.mock.calls[0];
expect(actual).toEqual(expected);
});
});
});
});
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import { Dropdown } from 'react-bootstrap';
import { SortCriteria } from 'interfaces';
import { SORT_BY_DROPDOWN_TITLE, SORT_BY_MENU_TITLE_TEXT } from '../constants';
import './styles.scss';
type Criterias = { [key: string]: SortCriteria };
export interface ListSortingDropdownProps {
options: Criterias;
onChange?: (value) => void;
}
type OptionType = string;
const TableReportsDropdown: React.FC<ListSortingDropdownProps> = ({
options,
onChange,
}: ListSortingDropdownProps) => {
const criterias = Object.entries(options);
if (criterias.length < 1) {
return null;
}
const [selectedOption, setSelectedOption] = React.useState<OptionType>(
criterias[0][1].key
);
const [isOpen, setOpen] = React.useState(false);
const handleChange = (e) => {
const { value } = e.target;
setSelectedOption(value);
setOpen(false);
if (onChange) {
onChange(value);
}
};
return (
<Dropdown
className="list-sorting-dropdown"
id="list-sorting-dropdown"
pullRight
open={isOpen}
onToggle={() => {
setOpen(!isOpen);
}}
>
<Dropdown.Toggle className="btn btn-default list-sorting-dropdown-button">
{SORT_BY_DROPDOWN_TITLE}
</Dropdown.Toggle>
<Dropdown.Menu className="list-sorting-dropdown-menu">
<h5 className="list-sorting-dropdown-menu-title">
{SORT_BY_MENU_TITLE_TEXT}
</h5>
{criterias.map(([_, { key, name }]) => (
<li key={name}>
<label className="list-sorting-dropdown-menu-item radio-label">
<input
type="radio"
name="sort-option"
value={key}
checked={selectedOption === key}
onChange={handleChange}
/>
<span className="list-sorting-dropdown-menu-item-text">
{name}
</span>
</label>
</li>
))}
</Dropdown.Menu>
</Dropdown>
);
};
export default TableReportsDropdown;
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
@import 'typography';
.list-sorting-dropdown {
.list-sorting-dropdown-button {
border: 0;
}
.list-sorting-dropdown-menu {
padding: $spacer-2;
}
.list-sorting-dropdown-menu-title {
@extend %text-caption-w2;
padding-bottom: $spacer-1;
}
.list-sorting-dropdown-menu-item {
&:hover {
cursor: pointer;
}
}
.list-sorting-dropdown-menu-item-text {
@extend %text-body-w3;
}
}
......@@ -22,7 +22,7 @@ interface StateFromProps {
errorText: string;
}
export type TableDashboardResourceListProps = OwnProps & StateFromProps;
export type TableDashboardResourceListProps = StateFromProps & OwnProps;
export class TableDashboardResourceList extends React.Component<
TableDashboardResourceListProps
......@@ -54,6 +54,7 @@ export class TableDashboardResourceList extends React.Component<
export const mapStateToProps = (state: GlobalState) => {
const relatedDashboards = state.tableMetadata.dashboards;
return {
dashboards: relatedDashboards ? relatedDashboards.dashboards : [],
isLoading: relatedDashboards ? relatedDashboards.isLoading : true,
......
......@@ -7,3 +7,5 @@ export const FREQ_USERS_TITLE = 'Frequent Users';
export const LAST_UPDATED_TITLE = 'Last Updated';
export const OWNERS_TITLE = 'Owners';
export const TAG_TITLE = 'Tags';
export const SORT_BY_DROPDOWN_TITLE = 'Sort by';
export const SORT_BY_MENU_TITLE_TEXT = 'Sort by';
......@@ -17,6 +17,7 @@ import { TableDetail, TableDetailProps, MatchProps } from '.';
jest.mock('config/config-utils', () => ({
indexDashboardsEnabled: jest.fn(),
getTableSortCriterias: jest.fn(),
}));
const setup = (
......
......@@ -17,6 +17,7 @@ import {
getDescriptionSourceDisplayName,
getMaxLength,
getSourceIconClass,
getTableSortCriterias,
indexDashboardsEnabled,
issueTrackingEnabled,
notificationsEnabled,
......@@ -40,6 +41,7 @@ import {
ResourceType,
TableMetadata,
RequestMetadataType,
SortCriteria,
} from 'interfaces';
import DataPreviewButton from './DataPreviewButton';
......@@ -57,6 +59,7 @@ import WriterLink from './WriterLink';
import TableReportsDropdown from './ResourceReportsDropdown';
import RequestDescriptionText from './RequestDescriptionText';
import RequestMetadataForm from './RequestMetadataForm';
import ListSortingDropdown from './ListSortingDropdown';
import * as Constants from './constants';
......@@ -65,8 +68,12 @@ import './styles.scss';
const SERVER_ERROR_CODE = 500;
const DASHBOARDS_PER_PAGE = 10;
const TABLE_SOURCE = 'table_page';
const SORT_CRITERIAS = {
...getTableSortCriterias(),
};
const COLUMN_TAB_KEY = 'columns';
export interface StateFromProps {
export interface PropsFromState {
isLoading: boolean;
isLoadingDashboards: boolean;
numRelatedDashboards: number;
......@@ -92,7 +99,7 @@ export interface MatchProps {
table: string;
}
export type TableDetailProps = StateFromProps &
export type TableDetailProps = PropsFromState &
DispatchFromProps &
RouteComponentProps<MatchProps>;
......@@ -105,13 +112,24 @@ const ErrorMessage = () => {
);
};
export interface StateProps {
sortedBy: SortCriteria;
currentTab: string;
}
export class TableDetail extends React.Component<
TableDetailProps & RouteComponentProps<any>
TableDetailProps & RouteComponentProps<any>,
StateProps
> {
private key: string;
private didComponentMount: boolean = false;
state = {
sortedBy: SORT_CRITERIAS.sort_order,
currentTab: COLUMN_TAB_KEY,
};
componentDidMount() {
const { index, source } = getLoggingParams(this.props.location.search);
......@@ -125,6 +143,7 @@ export class TableDetail extends React.Component<
if (this.key !== newKey) {
const { index, source } = getLoggingParams(this.props.location.search);
this.key = newKey;
this.props.getTableData(this.key, index, source);
}
......@@ -147,7 +166,9 @@ export class TableDetail extends React.Component<
return `${params.database}://${params.cluster}.${params.schema}/${params.table}`;
}
renderProgrammaticDesc = (descriptions: ProgrammaticDescription[]) => {
renderProgrammaticDesc = (
descriptions: ProgrammaticDescription[] | undefined
) => {
if (!descriptions) {
return null;
}
......@@ -164,6 +185,20 @@ export class TableDetail extends React.Component<
));
};
handleSortingChange = (sortValue) => {
this.toggleSort(SORT_CRITERIAS[sortValue]);
};
toggleSort = (sorting: SortCriteria) => {
const { sortedBy } = this.state;
if (sorting !== sortedBy) {
this.setState({
sortedBy: sorting,
});
}
};
renderTabs(editText, editUrl) {
const tabInfo: TabInfo[] = [];
const {
......@@ -172,6 +207,7 @@ export class TableDetail extends React.Component<
tableData,
openRequestDescriptionDialog,
} = this.props;
const { sortedBy } = this.state;
// Default Column content
tabInfo.push({
......@@ -182,6 +218,7 @@ export class TableDetail extends React.Component<
database={tableData.database}
editText={editText}
editUrl={editUrl}
sortBy={sortedBy}
/>
),
key: 'columns',
......@@ -209,11 +246,20 @@ export class TableDetail extends React.Component<
});
}
return <TabsComponent tabs={tabInfo} defaultTab="columns" />;
return (
<TabsComponent
tabs={tabInfo}
defaultTab="columns"
onSelect={(key) => {
this.setState({ currentTab: key });
}}
/>
);
}
render() {
const { isLoading, statusCode, tableData } = this.props;
const { currentTab } = this.state;
let innerContent;
// We want to avoid rendering the previous table's metadata before new data is fetched in componentDidMount
......@@ -348,6 +394,12 @@ export class TableDetail extends React.Component<
)}
</aside>
<main className="right-panel">
{currentTab === COLUMN_TAB_KEY && (
<ListSortingDropdown
options={SORT_CRITERIAS}
onChange={this.handleSortingChange}
/>
)}
{this.renderTabs(editText, editUrl)}
</main>
</div>
......@@ -386,7 +438,7 @@ export const mapDispatchToProps = (dispatch: any) => {
);
};
export default connect<StateFromProps, DispatchFromProps>(
export default connect<PropsFromState, DispatchFromProps>(
mapStateToProps,
mapDispatchToProps
)(TableDetail);
......@@ -42,11 +42,21 @@
}
.nav.nav-tabs {
margin-top: $spacer-1;
margin-top: $spacer-2;
padding: 0 $spacer-2;
}
.tabs-component .nav.nav-tabs > li {
margin: 0 $spacer-1;
}
.right-panel {
position: relative;
}
.list-sorting-dropdown {
right: $spacer-3;
top: $spacer-2;
position: absolute;
}
}
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