diff --git a/superset-frontend/src/components/Table/Table.overview.mdx b/superset-frontend/src/components/Table/Table.overview.mdx index 8341db879fcb..2fa1455cd0ed 100644 --- a/superset-frontend/src/components/Table/Table.overview.mdx +++ b/superset-frontend/src/components/Table/Table.overview.mdx @@ -183,7 +183,7 @@ The table displays a set number of rows at a time, the user navigates the table The default page size and page size options for the menu are configurable via the `pageSizeOptions` and `defaultPageSize` props. NOTE: Pagination controls will only display when the data for the table has more records than the default page size. - + ``` @@ -191,6 +191,85 @@ NOTE: Pagination controls will only display when the data for the table has more --- +### Server Pagination + +The table can be configured for async data fetching to get partial data sets while showing pagination controls that let the user navigate through data. +To override the default paging, which uses `data.length` to determine the record count, populate the `recordCount` prop with the total number of records +contained in the dataset on the server being paged through. When the user navigates through the paged data it will invoke the `onChange` callback +function enabling data fetching to occur when the user changes the page. + + + +``` +interface BasicData { + name: string; + category: string; + price: number; + description?: string; + key: number; +} + +const generateData = (startIndex: number, pageSize: number): BasicData[] => { + const data: BasicData[] = []; + for (let i = 0; i < pageSize; i += 1) { + const recordIndex = startIndex + i; + data.push({ + key: recordIndex, + name: `Dynamic Record ${recordIndex}`, + category: 'Disk Storage', + price: recordIndex * 2.59, + description: 'A random description', + }); + } + return data; +}; + +const ServerPaginationTable = () => { + const [data, setData] = useState(generateData(0, 5)); + const [loading, setLoading] = useState(false); + // This should really be calculated server side for the data set being paged through + const recordCount = 5000; + + const handleChange = (pagination: TablePaginationConfig) => { + const pageSize = pagination?.pageSize ?? 5; + const current = pagination?.current ?? 0; + setLoading(true); + // simulate a fetch + setTimeout(() => { + setData(generateData(current * pageSize, pageSize)); + setLoading(false); + }, 1000); + }; + + return ( +
+ ); +}; +``` + +--- + +### Virtualization for Performance + +Table virtualization can enable viewing data with many columns and/or rows. +Virtualization can be enabled via the `virtualize` prop. + +NOTE: Row event handlers will be ignored when table is running with `virtualize={true}`. +Support for row event handlers may be added in future versions of the Table. + + + +--- + ## Integration Checklist The following specifications are required every time a table is used. These choices should be intentional based on the specific user needs for the table instance. diff --git a/superset-frontend/src/components/Table/Table.stories.tsx b/superset-frontend/src/components/Table/Table.stories.tsx index 90ee3448c67e..8ca004dc09a5 100644 --- a/superset-frontend/src/components/Table/Table.stories.tsx +++ b/superset-frontend/src/components/Table/Table.stories.tsx @@ -19,8 +19,14 @@ import React, { useState } from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import { supersetTheme, ThemeProvider } from '@superset-ui/core'; -import { Table, TableSize, SUPERSET_TABLE_COLUMN, ColumnsType } from './index'; +import { + Table, + TableSize, + SUPERSET_TABLE_COLUMN, + ColumnsType, + OnChangeFunction, + ETableAction, +} from './index'; import { numericalSort, alphabeticalSort } from './sorters'; import ButtonCell from './cell-renderers/ButtonCell'; import ActionCell from './cell-renderers/ActionCell'; @@ -62,10 +68,10 @@ export interface ExampleData { key: number; } -function generateValues(amount: number): object { +function generateValues(amount: number, row = 0): object { const cells = {}; for (let i = 0; i < amount; i += 1) { - cells[`col-${i}`] = `Text ${i}`; + cells[`col-${i}`] = i * row * 0.75; } return cells; } @@ -74,15 +80,24 @@ function generateColumns(amount: number): ColumnsType[] { const newCols: any[] = []; for (let i = 0; i < amount; i += 1) { newCols.push({ - title: `Column Header ${i}`, + title: `C${i}`, dataIndex: `col-${i}`, key: `col-${i}`, + width: 90, + render: (value: number) => ( + + ), + sorter: (a: BasicData, b: BasicData) => numericalSort(`col-${i}`, a, b), }); } return newCols as ColumnsType[]; } -const recordCount = 200; -const columnCount = 12; +const recordCount = 500; +const columnCount = 500; const randomCols: ColumnsType[] = generateColumns(columnCount); const basicData: BasicData[] = [ @@ -107,6 +122,41 @@ const basicData: BasicData[] = [ price: 49.99, description: 'Reliable and fast data storage', }, + { + key: 4, + name: '128 GB SSD', + category: 'Hardrive', + price: 49.99, + description: 'Reliable and fast data storage', + }, + { + key: 5, + name: '4GB 144mhz', + category: 'Memory', + price: 19.99, + description: 'Laptop memory', + }, + { + key: 6, + name: '1GB USB Flash Drive', + category: 'Portable Storage', + price: 9.99, + description: 'USB Flash Drive portal data storage', + }, + { + key: 7, + name: '256 GB SSD', + category: 'Hardrive', + price: 175, + description: 'Reliable and fast data storage', + }, + { + key: 8, + name: '1 TB SSD', + category: 'Hardrive', + price: 349.99, + description: 'Reliable and fast data storage', + }, ]; const basicColumns: ColumnsType = [ @@ -114,7 +164,7 @@ const basicColumns: ColumnsType = [ title: 'Name', dataIndex: 'name', key: 'name', - width: 150, + width: 100, sorter: (a: BasicData, b: BasicData) => alphabeticalSort('name', a, b), }, { @@ -128,6 +178,7 @@ const basicColumns: ColumnsType = [ dataIndex: 'price', key: 'price', sorter: (a: BasicData, b: BasicData) => numericalSort('price', a, b), + width: 100, }, { title: 'Description', @@ -141,25 +192,20 @@ const bigColumns: ColumnsType = [ title: 'Name', dataIndex: 'name', key: 'name', - render: (text: string, row: object, index: number) => ( - - ), width: 150, }, { title: 'Age', dataIndex: 'age', key: 'age', + sorter: (a: ExampleData, b: ExampleData) => numericalSort('age', a, b), + width: 75, }, { title: 'Address', dataIndex: 'address', key: 'address', + width: 100, }, ...(randomCols as ColumnsType), ]; @@ -253,17 +299,11 @@ for (let i = 0; i < recordCount; i += 1) { name: `Dynamic record ${i}`, age: 32 + i, address: `DynamoCity, Dynamic Lane no. ${i}`, - ...generateValues(columnCount), + ...generateValues(columnCount, i), }); } -export const Basic: ComponentStory = args => ( - -
-
- - -); +export const Basic: ComponentStory = args =>
; function handlers(record: object, rowIndex: number) { return { @@ -286,31 +326,150 @@ Basic.args = { columns: basicColumns, size: TableSize.SMALL, onRow: handlers, + usePagination: false, +}; + +export const Pagination: ComponentStory = args => ( +
+); + +Pagination.args = { + data: basicData, + columns: basicColumns, + size: TableSize.SMALL, pageSizeOptions: ['5', '10', '15', '20', '25'], - defaultPageSize: 10, + defaultPageSize: 5, }; -export const ManyColumns: ComponentStory = args => ( - -
-
- - +const generateData = (startIndex: number, pageSize: number): BasicData[] => { + const data: BasicData[] = []; + for (let i = 0; i < pageSize; i += 1) { + const recordIndex = startIndex + i; + data.push({ + key: recordIndex, + name: `Dynamic Record ${recordIndex}`, + category: 'Disk Storage', + price: recordIndex * 2.59, + description: 'A random description', + }); + } + return data; +}; + +const paginationColumns: ColumnsType = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + width: 100, + }, + { + title: 'Category', + dataIndex: 'category', + key: 'category', + }, + { + title: 'Price', + dataIndex: 'price', + key: 'price', + width: 100, + render: (value: number) => ( + + ), + sorter: (a: BasicData, b: BasicData) => numericalSort('price', a, b), + }, + { + title: 'Description', + dataIndex: 'description', + key: 'description', + }, + { + dataIndex: 'actions', + key: 'actions', + render: (text: string, row: object) => ( + + ), + width: 32, + fixed: 'right', + }, +]; + +export const ServerPagination: ComponentStory = args => { + const [data, setData] = useState(generateData(0, 5)); + const [loading, setLoading] = useState(false); + + const handleChange: OnChangeFunction = ( + pagination, + filters, + sorter, + extra, + ) => { + const pageSize = pagination?.pageSize ?? 5; + const current = pagination?.current ?? 0; + switch (extra?.action) { + case ETableAction.PAGINATE: { + setLoading(true); + // simulate a fetch + setTimeout(() => { + setData(generateData(current * pageSize, pageSize)); + setLoading(false); + }, 1000); + break; + } + case ETableAction.SORT: { + action(`table-sort-change: ${JSON.stringify(sorter)}`); + break; + } + case ETableAction.FILTER: { + action(`table-sort-change: ${JSON.stringify(filters)}`); + break; + } + default: { + action('table action unknown'); + break; + } + } + }; + + return ( +
+ ); +}; + +ServerPagination.args = { + columns: paginationColumns, + size: TableSize.SMALL, + pageSizeOptions: ['5', '20', '50'], + defaultPageSize: 5, +}; + +export const VirtualizedPerformance: ComponentStory = args => ( +
); -ManyColumns.args = { +VirtualizedPerformance.args = { data: bigdata, columns: bigColumns, size: TableSize.SMALL, resizable: true, reorderable: true, height: 350, + virtualize: true, + usePagination: false, }; export const Loading: ComponentStory = args => ( - -
- +
); Loading.args = { @@ -321,11 +480,7 @@ Loading.args = { }; export const ResizableColumns: ComponentStory = args => ( - -
-
- - +
); ResizableColumns.args = { @@ -362,26 +517,24 @@ export const ReorderableColumns: ComponentStory = args => { setDroppedItem(data); }; return ( - -
-
) => dragOver(ev)} - onDragLeave={(ev: React.DragEvent) => dragOut(ev)} - onDrop={(ev: React.DragEvent) => dragDrop(ev)} - style={{ - width: '100%', - height: '40px', - border: '1px solid grey', - marginBottom: '8px', - padding: '8px', - borderRadius: '4px', - }} - > - {droppedItem ?? 'Drop column here...'} -
-
+
+
) => dragOver(ev)} + onDragLeave={(ev: React.DragEvent) => dragOut(ev)} + onDrop={(ev: React.DragEvent) => dragDrop(ev)} + style={{ + width: '100%', + height: '40px', + border: '1px solid grey', + marginBottom: '8px', + padding: '8px', + borderRadius: '4px', + }} + > + {droppedItem ?? 'Drop column here...'}
- +
+ ); }; @@ -417,11 +570,7 @@ const rendererData: RendererData[] = [ ]; export const CellRenderers: ComponentStory = args => ( - -
-
- - +
); CellRenderers.args = { diff --git a/superset-frontend/src/components/Table/VirtualTable.tsx b/superset-frontend/src/components/Table/VirtualTable.tsx new file mode 100644 index 000000000000..713eca6b79a6 --- /dev/null +++ b/superset-frontend/src/components/Table/VirtualTable.tsx @@ -0,0 +1,247 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Table as AntTable } from 'antd'; +import classNames from 'classnames'; +import { useResizeDetector } from 'react-resize-detector'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { VariableSizeGrid as Grid } from 'react-window'; +import { StyledComponent } from '@emotion/styled'; +import { useTheme, styled } from '@superset-ui/core'; +import { TablePaginationConfig } from 'antd/lib/table'; +import { TableProps, TableSize, HEIGHT_OFFSET, ETableAction } from './index'; + +const StyledCell: StyledComponent = styled('div')( + ({ theme, height }) => ` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-left: ${theme.gridUnit * 2}px; + padding-right: ${theme.gridUnit}px; + border-bottom: 1px solid ${theme.colors.grayscale.light3}; + transition: background 0.3s; + line-height: ${height}px; + box-sizing: border-box; +`, +); + +const StyledTable: StyledComponent = styled(AntTable)( + ({ theme }) => ` + th.ant-table-cell { + font-weight: ${theme.typography.weights.bold}; + color: ${theme.colors.grayscale.dark1}; + user-select: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .ant-pagination-item-active { + border-color: ${theme.colors.primary.base}; + } + } +`, +); + +const SMALL = 39; +const MIDDLE = 47; + +const VirtualTable = (props: TableProps) => { + const { columns, pagination, onChange, height, scroll, size } = props; + const [tableWidth, setTableWidth] = useState(0); + const onResize = useCallback((width: number) => { + setTableWidth(width); + }, []); + const { ref } = useResizeDetector({ onResize }); + const theme = useTheme(); + + // If a column definition has no width, react-window will use this as the default column width + const DEFAULT_COL_WIDTH = theme?.gridUnit * 37 || 150; + const widthColumnCount = columns!.filter(({ width }) => !width).length; + let staticColWidthTotal = 0; + columns?.forEach(column => { + if (column.width) { + staticColWidthTotal += column.width as number; + } + }); + + let totalWidth = 0; + const defaultWidth = Math.max( + Math.floor((tableWidth - staticColWidthTotal) / widthColumnCount), + 50, + ); + + const mergedColumns = + columns?.map?.(column => { + const modifiedColumn = { ...column }; + if (!column.width) { + modifiedColumn.width = defaultWidth; + } + totalWidth += modifiedColumn.width as number; + return modifiedColumn; + }) ?? []; + + /* + * There are cases where a user could set the width of each column and the total width is less than width of + * the table. In this case we will stretch the last column to use the extra space + */ + if (totalWidth < tableWidth) { + const lastColumn = mergedColumns[mergedColumns.length - 1]; + lastColumn.width = + (lastColumn.width as number) + Math.floor(tableWidth - totalWidth); + } + + const gridRef = useRef(); + const [connectObject] = useState(() => { + const obj = {}; + Object.defineProperty(obj, 'scrollLeft', { + get: () => { + if (gridRef.current) { + return gridRef.current?.state?.scrollLeft; + } + return null; + }, + set: (scrollLeft: number) => { + if (gridRef.current) { + gridRef.current.scrollTo({ scrollLeft }); + } + }, + }); + + return obj; + }); + + const resetVirtualGrid = () => { + gridRef.current?.resetAfterIndices({ + columnIndex: 0, + shouldForceUpdate: true, + }); + }; + + useEffect(() => resetVirtualGrid, [tableWidth, columns, size]); + + /* + * antd Table has a runtime error when it tries to fire the onChange event triggered from a pageChange + * when the table body is overridden with the virtualized table. This function capture the page change event + * from within the pagination controls and proxies the onChange event payload + */ + const onPageChange = (page: number, size: number) => { + /** + * This resets vertical scroll position to 0 (top) when page changes + * We intentionally leave horizontal scroll where it was so user can focus on + * specific range of columns as they page through data + */ + gridRef.current?.scrollTo?.({ scrollTop: 0 }); + + onChange?.( + { + ...pagination, + current: page, + pageSize: size, + } as TablePaginationConfig, + {}, + {}, + { + action: ETableAction.PAGINATE, + currentDataSource: [], + }, + ); + }; + + const renderVirtualList = (rawData: object[], { ref, onScroll }: any) => { + // eslint-disable-next-line no-param-reassign + ref.current = connectObject; + const cellSize = size === TableSize.MIDDLE ? MIDDLE : SMALL; + return ( + { + const { width = DEFAULT_COL_WIDTH } = mergedColumns[index]; + return width as number; + }} + height={height ? height - HEIGHT_OFFSET : (scroll!.y as number)} + rowCount={rawData.length} + rowHeight={() => cellSize} + width={tableWidth} + onScroll={({ scrollLeft }: { scrollLeft: number }) => { + onScroll({ scrollLeft }); + }} + > + {({ + columnIndex, + rowIndex, + style, + }: { + columnIndex: number; + rowIndex: number; + style: React.CSSProperties; + }) => { + const data: any = rawData?.[rowIndex]; + // Set default content + let content = + data?.[(mergedColumns as any)?.[columnIndex]?.dataIndex]; + // Check if the column has a render function + const render = mergedColumns[columnIndex]?.render; + if (typeof render === 'function') { + // Use render function to generate formatted content using column's render function + content = render(content, data, rowIndex); + } + + return ( + + {content} + + ); + }} + + ); + }; + + const modifiedPagination = { + ...pagination, + onChange: onPageChange, + }; + + return ( +
+ +
+ ); +}; + +export default VirtualTable; diff --git a/superset-frontend/src/components/Table/index.tsx b/superset-frontend/src/components/Table/index.tsx index d5f449c75287..99d70312da05 100644 --- a/superset-frontend/src/components/Table/index.tsx +++ b/superset-frontend/src/components/Table/index.tsx @@ -18,22 +18,29 @@ */ import React, { useState, useEffect, useRef, ReactElement } from 'react'; import { Table as AntTable, ConfigProvider } from 'antd'; -import type { +import { ColumnType, ColumnGroupType, TableProps as AntTableProps, } from 'antd/es/table'; +import { PaginationProps } from 'antd/es/pagination'; +import { Key } from 'antd/lib/table/interface'; import { t, useTheme, logging } from '@superset-ui/core'; import Loading from 'src/components/Loading'; import styled, { StyledComponent } from '@emotion/styled'; import InteractiveTableUtils from './utils/InteractiveTableUtils'; +import VirtualTable from './VirtualTable'; export const SUPERSET_TABLE_COLUMN = 'superset/table-column'; export interface TableDataType { key: React.Key; } -export declare type ColumnsType = ( +export interface TablePaginationConfig extends PaginationProps { + extra?: object; +} + +export type ColumnsType = ( | ColumnGroupType | ColumnType )[]; @@ -67,6 +74,32 @@ export interface Locale { cancelSort: string; } +export type SortOrder = 'descend' | 'ascend' | null; +export interface SorterResult { + column?: ColumnType; + order?: SortOrder; + field?: Key | Key[]; + columnKey?: Key; +} + +export enum ETableAction { + PAGINATE = 'paginate', + SORT = 'sort', + FILTER = 'filter', +} + +export interface TableCurrentDataSource { + currentDataSource: RecordType[]; + action: ETableAction; +} + +export type OnChangeFunction = ( + pagination: TablePaginationConfig, + filters: Record, + sorter: SorterResult | SorterResult[], + extra: TableCurrentDataSource, +) => void; + export interface TableProps extends AntTableProps { /** * Data that will populate the each row and map to the column key. @@ -108,6 +141,10 @@ export interface TableProps extends AntTableProps { * EXPERIMENTAL: Controls if columns are re-orderable by user drag drop. */ reorderable?: boolean; + /** + * Controls if pagination is active or disabled. + */ + usePagination?: boolean; /** * Default number of rows table will display per page of data. */ @@ -134,6 +171,27 @@ export interface TableProps extends AntTableProps { * when the number of rows exceeds the visible space. */ height?: number; + /** + * Sets the table to use react-window for scroll virtualization in cases where + * there are unknown amount of columns, or many, many rows + */ + virtualize?: boolean; + /** + * Used to override page controls total record count when using server-side paging. + */ + recordCount?: number; + /** + * Invoked when the tables sorting, paging, or filtering is changed. + */ + onChange?: OnChangeFunction; +} + +interface IPaginationOptions { + hideOnSinglePage: boolean; + pageSize: number; + pageSizeOptions: string[]; + onShowSizeChange: Function; + total?: number; } export enum TableSize { @@ -143,12 +201,12 @@ export enum TableSize { const defaultRowSelection: React.Key[] = []; // This accounts for the tables header and pagination if user gives table instance a height. this is a temp solution -const HEIGHT_OFFSET = 108; +export const HEIGHT_OFFSET = 108; -const StyledTable: StyledComponent = styled(AntTable)` - ${({ theme, height }) => ` +const StyledTable: StyledComponent = styled(AntTable)( + ({ theme, height }) => ` .ant-table-body { - overflow: scroll; + overflow: auto; height: ${height ? `${height - HEIGHT_OFFSET}px` : undefined}; } @@ -161,11 +219,36 @@ const StyledTable: StyledComponent = styled(AntTable)` text-overflow: ellipsis; } + .ant-table-tbody > tr > td { + user-select: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-bottom: 1px solid ${theme.colors.grayscale.light3}; + } + .ant-pagination-item-active { border-color: ${theme.colors.primary.base}; } - `} -`; + } +`, +); + +const StyledVirtualTable: StyledComponent = styled(VirtualTable)( + ({ theme }) => ` + .virtual-table .ant-table-container:before, + .virtual-table .ant-table-container:after { + display: none; + } + .virtual-table-cell { + box-sizing: border-box; + padding: ${theme.gridUnit * 4}px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +`, +); const defaultLocale = { filterTitle: t('Filter menu'), @@ -188,6 +271,7 @@ const defaultLocale = { }; const selectionMap = {}; +const noop = () => {}; selectionMap[SelectionType.MULTI] = 'checkbox'; selectionMap[SelectionType.SINGLE] = 'radio'; selectionMap[SelectionType.DISABLED] = null; @@ -198,18 +282,22 @@ export function Table(props: TableProps) { columns, selectedRows = defaultRowSelection, handleRowSelection, - size, + size = TableSize.SMALL, selectionType = SelectionType.DISABLED, sticky = true, loading = false, resizable = false, reorderable = false, + usePagination = true, defaultPageSize = 15, pageSizeOptions = ['5', '15', '25', '50', '100'], hideData = false, emptyComponent, locale, - ...rest + height, + virtualize = false, + onChange = noop, + recordCount, } = props; const wrapperRef = useRef(null); @@ -292,32 +380,59 @@ export function Table(props: TableProps) { * The exclusion from the effect dependencies is intentional and should not be modified */ // eslint-disable-next-line react-hooks/exhaustive-deps - }, [wrapperRef, reorderable, resizable, interactiveTableUtils]); + }, [wrapperRef, reorderable, resizable, virtualize, interactiveTableUtils]); const theme = useTheme(); + const paginationSettings: IPaginationOptions | false = usePagination + ? { + hideOnSinglePage: true, + pageSize, + pageSizeOptions, + onShowSizeChange: (page: number, size: number) => setPageSize(size), + } + : false; + + /** + * When recordCount is provided it lets the user of Table control total number of pages + * independent from data.length. This allows the parent component do things like server side paging + * where the user can be shown the total mount of data they can page through, but the component can provide + * data one page at a time, and respond to the onPageChange event to fetch and set new data + */ + if (paginationSettings && recordCount) { + paginationSettings.total = recordCount; + } + + const sharedProps = { + loading: { spinning: loading ?? false, indicator: }, + hasData: hideData ? false : data, + columns: derivedColumns, + dataSource: hideData ? [undefined] : data, + size, + pagination: paginationSettings, + locale: mergedLocale, + showSorterTooltip: false, + onChange, + theme, + height, + }; + return (
- }} - hasData={hideData ? false : data} - rowSelection={selectionTypeValue ? rowSelection : undefined} - columns={derivedColumns} - dataSource={hideData ? [undefined] : data} - size={size} - sticky={sticky} - pagination={{ - hideOnSinglePage: true, - pageSize, - pageSizeOptions, - onShowSizeChange: (page: number, size: number) => setPageSize(size), - }} - showSorterTooltip={false} - locale={mergedLocale} - theme={theme} - /> + {!virtualize && ( + + )} + {virtualize && ( + + )}
);