-
) => 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 && (
+
+ )}
);