refactor(monorepo): move superset-ui to superset(stage 2) (#17552)

This commit is contained in:
Yongjie Zhao
2021-11-30 08:29:57 +08:00
committed by GitHub
parent bfba4f1689
commit 3c41ff68a4
1315 changed files with 27755 additions and 15167 deletions

View File

@@ -0,0 +1,351 @@
/**
* 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 React, {
useCallback,
useRef,
ReactNode,
HTMLProps,
MutableRefObject,
CSSProperties,
} from 'react';
import {
useTable,
usePagination,
useSortBy,
useGlobalFilter,
PluginHook,
TableOptions,
FilterType,
IdType,
Row,
} from 'react-table';
import { matchSorter, rankings } from 'match-sorter';
import GlobalFilter, { GlobalFilterProps } from './components/GlobalFilter';
import SelectPageSize, {
SelectPageSizeProps,
SizeOption,
} from './components/SelectPageSize';
import SimplePagination from './components/Pagination';
import useSticky from './hooks/useSticky';
import { PAGE_SIZE_OPTIONS } from '../consts';
export interface DataTableProps<D extends object> extends TableOptions<D> {
tableClassName?: string;
searchInput?: boolean | GlobalFilterProps<D>['searchInput'];
selectPageSize?: boolean | SelectPageSizeProps['selectRenderer'];
pageSizeOptions?: SizeOption[]; // available page size options
maxPageItemCount?: number;
hooks?: PluginHook<D>[]; // any additional hooks
width?: string | number;
height?: string | number;
serverPagination?: boolean;
onServerPaginationChange: (pageNumber: number, pageSize: number) => void;
serverPaginationData: { pageSize?: number; currentPage?: number };
pageSize?: number;
noResults?: string | ((filterString: string) => ReactNode);
sticky?: boolean;
rowCount: number;
wrapperRef?: MutableRefObject<HTMLDivElement>;
}
export interface RenderHTMLCellProps extends HTMLProps<HTMLTableCellElement> {
cellContent: ReactNode;
}
// Be sure to pass our updateMyData and the skipReset option
export default function DataTable<D extends object>({
tableClassName,
columns,
data,
serverPaginationData,
width: initialWidth = '100%',
height: initialHeight = 300,
pageSize: initialPageSize = 0,
initialState: initialState_ = {},
pageSizeOptions = PAGE_SIZE_OPTIONS,
maxPageItemCount = 9,
sticky: doSticky,
searchInput = true,
onServerPaginationChange,
rowCount,
selectPageSize,
noResults: noResultsText = 'No data found',
hooks,
serverPagination,
wrapperRef: userWrapperRef,
...moreUseTableOptions
}: DataTableProps<D>): JSX.Element {
const tableHooks: PluginHook<D>[] = [
useGlobalFilter,
useSortBy,
usePagination,
doSticky ? useSticky : [],
hooks || [],
].flat();
const resultsSize = serverPagination ? rowCount : data.length;
const sortByRef = useRef([]); // cache initial `sortby` so sorting doesn't trigger page reset
const pageSizeRef = useRef([initialPageSize, resultsSize]);
const hasPagination = initialPageSize > 0 && resultsSize > 0; // pageSize == 0 means no pagination
const hasGlobalControl = hasPagination || !!searchInput;
const initialState = {
...initialState_,
// zero length means all pages, the `usePagination` plugin does not
// understand pageSize = 0
sortBy: sortByRef.current,
pageSize: initialPageSize > 0 ? initialPageSize : resultsSize || 10,
};
const defaultWrapperRef = useRef<HTMLDivElement>(null);
const globalControlRef = useRef<HTMLDivElement>(null);
const paginationRef = useRef<HTMLDivElement>(null);
const wrapperRef = userWrapperRef || defaultWrapperRef;
const paginationData = JSON.stringify(serverPaginationData);
const defaultGetTableSize = useCallback(() => {
if (wrapperRef.current) {
// `initialWidth` and `initialHeight` could be also parameters like `100%`
// `Number` reaturns `NaN` on them, then we fallback to computed size
const width = Number(initialWidth) || wrapperRef.current.clientWidth;
const height =
(Number(initialHeight) || wrapperRef.current.clientHeight) -
(globalControlRef.current?.clientHeight || 0) -
(paginationRef.current?.clientHeight || 0);
return { width, height };
}
return undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
initialHeight,
initialWidth,
wrapperRef,
hasPagination,
hasGlobalControl,
paginationRef,
resultsSize,
paginationData,
]);
const defaultGlobalFilter: FilterType<D> = useCallback(
(rows: Row<D>[], columnIds: IdType<D>[], filterValue: string) => {
// allow searching by "col1_value col2_value"
const joinedString = (row: Row<D>) =>
columnIds.map(x => row.values[x]).join(' ');
return matchSorter(rows, filterValue, {
keys: [...columnIds, joinedString],
threshold: rankings.ACRONYM,
}) as typeof rows;
},
[],
);
const {
getTableProps,
getTableBodyProps,
prepareRow,
headerGroups,
footerGroups,
page,
pageCount,
gotoPage,
preGlobalFilteredRows,
setGlobalFilter,
setPageSize: setPageSize_,
wrapStickyTable,
state: { pageIndex, pageSize, globalFilter: filterValue, sticky = {} },
} = useTable<D>(
{
columns,
data,
initialState,
getTableSize: defaultGetTableSize,
globalFilter: defaultGlobalFilter,
...moreUseTableOptions,
},
...tableHooks,
);
// make setPageSize accept 0
const setPageSize = (size: number) => {
if (serverPagination) {
onServerPaginationChange(0, size);
}
// keep the original size if data is empty
if (size || resultsSize !== 0) {
setPageSize_(size === 0 ? resultsSize : size);
}
};
const noResults =
typeof noResultsText === 'function'
? noResultsText(filterValue as string)
: noResultsText;
const getNoResults = () => <div className="dt-no-results">{noResults}</div>;
if (!columns || columns.length === 0) {
return (
wrapStickyTable ? wrapStickyTable(getNoResults) : getNoResults()
) as JSX.Element;
}
const shouldRenderFooter = columns.some(x => !!x.Footer);
const renderTable = () => (
<table {...getTableProps({ className: tableClassName })}>
<thead>
{headerGroups.map(headerGroup => {
const { key: headerGroupKey, ...headerGroupProps } =
headerGroup.getHeaderGroupProps();
return (
<tr key={headerGroupKey || headerGroup.id} {...headerGroupProps}>
{headerGroup.headers.map(column =>
column.render('Header', {
key: column.id,
...column.getSortByToggleProps(),
}),
)}
</tr>
);
})}
</thead>
<tbody {...getTableBodyProps()}>
{page && page.length > 0 ? (
page.map(row => {
prepareRow(row);
const { key: rowKey, ...rowProps } = row.getRowProps();
return (
<tr key={rowKey || row.id} {...rowProps}>
{row.cells.map(cell =>
cell.render('Cell', { key: cell.column.id }),
)}
</tr>
);
})
) : (
<tr>
<td className="dt-no-results" colSpan={columns.length}>
{noResults}
</td>
</tr>
)}
</tbody>
{shouldRenderFooter && (
<tfoot>
{footerGroups.map(footerGroup => {
const { key: footerGroupKey, ...footerGroupProps } =
footerGroup.getHeaderGroupProps();
return (
<tr key={footerGroupKey || footerGroup.id} {...footerGroupProps}>
{footerGroup.headers.map(column =>
column.render('Footer', { key: column.id }),
)}
</tr>
);
})}
</tfoot>
)}
</table>
);
// force update the pageSize when it's been update from the initial state
if (
pageSizeRef.current[0] !== initialPageSize ||
// when initialPageSize stays as zero, but total number of records changed,
// we'd also need to update page size
(initialPageSize === 0 && pageSizeRef.current[1] !== resultsSize)
) {
pageSizeRef.current = [initialPageSize, resultsSize];
setPageSize(initialPageSize);
}
const paginationStyle: CSSProperties = sticky.height
? {}
: { visibility: 'hidden' };
let resultPageCount = pageCount;
let resultCurrentPageSize = pageSize;
let resultCurrentPage = pageIndex;
let resultOnPageChange: (page: number) => void = gotoPage;
if (serverPagination) {
const serverPageSize = serverPaginationData.pageSize ?? initialPageSize;
resultPageCount = Math.ceil(rowCount / serverPageSize);
if (!Number.isFinite(resultPageCount)) {
resultPageCount = 0;
}
resultCurrentPageSize = serverPageSize;
const foundPageSizeIndex = pageSizeOptions.findIndex(
([option]) => option >= resultCurrentPageSize,
);
if (foundPageSizeIndex === -1) {
resultCurrentPageSize = 0;
}
resultCurrentPage = serverPaginationData.currentPage ?? 0;
resultOnPageChange = (pageNumber: number) =>
onServerPaginationChange(pageNumber, serverPageSize);
}
return (
<div
ref={wrapperRef}
style={{ width: initialWidth, height: initialHeight }}
>
{hasGlobalControl ? (
<div ref={globalControlRef} className="form-inline dt-controls">
<div className="row">
<div className="col-sm-6">
{hasPagination ? (
<SelectPageSize
total={resultsSize}
current={resultCurrentPageSize}
options={pageSizeOptions}
selectRenderer={
typeof selectPageSize === 'boolean'
? undefined
: selectPageSize
}
onChange={setPageSize}
/>
) : null}
</div>
{searchInput ? (
<div className="col-sm-6">
<GlobalFilter<D>
searchInput={
typeof searchInput === 'boolean' ? undefined : searchInput
}
preGlobalFilteredRows={preGlobalFilteredRows}
setGlobalFilter={setGlobalFilter}
filterValue={filterValue}
/>
</div>
) : null}
</div>
</div>
) : null}
{wrapStickyTable ? wrapStickyTable(renderTable) : renderTable()}
{hasPagination && resultPageCount > 1 ? (
<SimplePagination
ref={paginationRef}
style={paginationStyle}
maxPageItemCount={maxPageItemCount}
pageCount={resultPageCount}
currentPage={resultCurrentPage}
onPageChange={resultOnPageChange}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,25 @@
<!--
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.
-->
# Superset UI Data Table
Reusable data table based on [react-table](https://github.com/tannerlinsley/react-table), with
built-in support for sorting, filtering, and pagination.
Intended to be used as a standalone UI component in the future.

View File

@@ -0,0 +1,82 @@
/**
* 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 React, { ComponentType, ChangeEventHandler } from 'react';
import { Row, FilterValue } from 'react-table';
import useAsyncState from '../utils/useAsyncState';
export interface SearchInputProps {
count: number;
value: string;
onChange: ChangeEventHandler<HTMLInputElement>;
}
export interface GlobalFilterProps<D extends object> {
preGlobalFilteredRows: Row<D>[];
// filter value cannot be `undefined` otherwise React will report component
// control type undefined error
filterValue: string;
setGlobalFilter: (filterValue: FilterValue) => void;
searchInput?: ComponentType<SearchInputProps>;
}
function DefaultSearchInput({ count, value, onChange }: SearchInputProps) {
return (
<span className="dt-global-filter">
Search{' '}
<input
className="form-control input-sm"
placeholder={`${count} records...`}
value={value}
onChange={onChange}
/>
</span>
);
}
export default (React.memo as <T>(fn: T) => T)(function GlobalFilter<
D extends object,
>({
preGlobalFilteredRows,
filterValue = '',
searchInput,
setGlobalFilter,
}: GlobalFilterProps<D>) {
const count = preGlobalFilteredRows.length;
const [value, setValue] = useAsyncState(
filterValue,
(newValue: string) => {
setGlobalFilter(newValue || undefined);
},
200,
);
const SearchInput = searchInput || DefaultSearchInput;
return (
<SearchInput
count={count}
value={value}
onChange={e => {
const target = e.target as HTMLInputElement;
e.preventDefault();
setValue(target.value);
}}
/>
);
});

View File

@@ -0,0 +1,121 @@
/**
* 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 React, { CSSProperties } from 'react';
export interface PaginationProps {
pageCount: number; // number of pages
currentPage?: number; // index of current page, zero-based
maxPageItemCount?: number;
ellipsis?: string; // content for ellipsis item
onPageChange: (page: number) => void; // `page` is zero-based
style?: CSSProperties;
}
// first, ..., prev, current, next, ..., last
const MINIMAL_PAGE_ITEM_COUNT = 7;
/**
* Generate numeric page items around current page.
* - Always include first and last page
* - Add ellipsis if needed
*/
export function generatePageItems(
total: number,
current: number,
width: number,
) {
if (width < MINIMAL_PAGE_ITEM_COUNT) {
throw new Error(
`Must allow at least ${MINIMAL_PAGE_ITEM_COUNT} page items`,
);
}
if (width % 2 === 0) {
throw new Error(`Must allow odd number of page items`);
}
if (total < width) {
return [...new Array(total).keys()];
}
const left = Math.max(
0,
Math.min(total - width, current - Math.floor(width / 2)),
);
const items: (string | number)[] = new Array(width);
for (let i = 0; i < width; i += 1) {
items[i] = i + left;
}
// replace non-ending items with placeholders
if (items[0] > 0) {
items[0] = 0;
items[1] = 'prev-more';
}
if (items[items.length - 1] < total - 1) {
items[items.length - 1] = total - 1;
items[items.length - 2] = 'next-more';
}
return items;
}
export default React.memo(
React.forwardRef(function Pagination(
{
style,
pageCount,
currentPage = 0,
maxPageItemCount = 9,
onPageChange,
}: PaginationProps,
ref: React.Ref<HTMLDivElement>,
) {
const pageItems = generatePageItems(
pageCount,
currentPage,
maxPageItemCount,
);
return (
<div ref={ref} className="dt-pagination" style={style}>
<ul className="pagination pagination-sm">
{pageItems.map(item =>
typeof item === 'number' ? (
// actual page number
<li
key={item}
className={currentPage === item ? 'active' : undefined}
>
<a
href={`#page-${item}`}
role="button"
onClick={e => {
e.preventDefault();
onPageChange(item);
}}
>
{item + 1}
</a>
</li>
) : (
<li key={item} className="dt-pagination-ellipsis">
<span></span>
</li>
),
)}
</ul>
</div>
);
}),
);

View File

@@ -0,0 +1,99 @@
/**
* 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 React from 'react';
import { formatSelectOptions } from '@superset-ui/chart-controls';
export type SizeOption = [number, string];
export interface SelectPageSizeRendererProps {
current: number;
options: SizeOption[];
onChange: SelectPageSizeProps['onChange'];
}
function DefaultSelectRenderer({
current,
options,
onChange,
}: SelectPageSizeRendererProps) {
return (
<span className="dt-select-page-size form-inline">
Show{' '}
<select
className="form-control input-sm"
value={current}
onBlur={() => {}}
onChange={e => {
onChange(Number((e.target as HTMLSelectElement).value));
}}
>
{options.map(option => {
const [size, text] = Array.isArray(option)
? option
: [option, option];
return (
<option key={size} value={size}>
{text}
</option>
);
})}
</select>{' '}
entries
</span>
);
}
export interface SelectPageSizeProps extends SelectPageSizeRendererProps {
total?: number;
selectRenderer?: typeof DefaultSelectRenderer;
onChange: (pageSize: number) => void;
}
function getOptionValue(x: SizeOption) {
return Array.isArray(x) ? x[0] : x;
}
export default React.memo(function SelectPageSize({
total,
options: sizeOptions,
current: currentSize,
selectRenderer,
onChange,
}: SelectPageSizeProps) {
const sizeOptionValues = sizeOptions.map(getOptionValue);
let options = [...sizeOptions];
// insert current size to list
if (
currentSize !== undefined &&
(currentSize !== total || !sizeOptionValues.includes(0)) &&
!sizeOptionValues.includes(currentSize)
) {
options = [...sizeOptions];
options.splice(
sizeOptionValues.findIndex(x => x > currentSize),
0,
formatSelectOptions([currentSize])[0],
);
}
const current = currentSize === undefined ? sizeOptionValues[0] : currentSize;
const SelectRenderer = selectRenderer || DefaultSelectRenderer;
return (
<SelectRenderer current={current} options={options} onChange={onChange} />
);
});

View File

@@ -0,0 +1,435 @@
/**
* 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 React, {
useRef,
useMemo,
useLayoutEffect,
useCallback,
ReactNode,
ReactElement,
ComponentPropsWithRef,
CSSProperties,
UIEventHandler,
} from 'react';
import { TableInstance, Hooks } from 'react-table';
import getScrollBarSize from '../utils/getScrollBarSize';
import needScrollBar from '../utils/needScrollBar';
import useMountedMemo from '../utils/useMountedMemo';
type ReactElementWithChildren<
T extends keyof JSX.IntrinsicElements,
C extends ReactNode = ReactNode,
> = ReactElement<ComponentPropsWithRef<T> & { children: C }, T>;
type Th = ReactElementWithChildren<'th'>;
type Td = ReactElementWithChildren<'td'>;
type TrWithTh = ReactElementWithChildren<'tr', Th[]>;
type TrWithTd = ReactElementWithChildren<'tr', Td[]>;
type Thead = ReactElementWithChildren<'thead', TrWithTh>;
type Tbody = ReactElementWithChildren<'tbody', TrWithTd>;
type Tfoot = ReactElementWithChildren<'tfoot', TrWithTd>;
type Col = ReactElementWithChildren<'col', null>;
type ColGroup = ReactElementWithChildren<'colgroup', Col>;
export type Table = ReactElementWithChildren<
'table',
(Thead | Tbody | Tfoot | ColGroup)[]
>;
export type TableRenderer = () => Table;
export type GetTableSize = () => Partial<StickyState> | undefined;
export type SetStickyState = (size?: Partial<StickyState>) => void;
export enum ReducerActions {
init = 'init', // this is from global reducer
setStickyState = 'setStickyState',
}
export type ReducerAction<
T extends string,
P extends Record<string, unknown>,
> = P & { type: T };
export type ColumnWidths = number[];
export interface StickyState {
width?: number; // maximum full table width
height?: number; // maximum full table height
realHeight?: number; // actual table viewport height (header + scrollable area)
bodyHeight?: number; // scrollable area height
tableHeight?: number; // the full table height
columnWidths?: ColumnWidths;
hasHorizontalScroll?: boolean;
hasVerticalScroll?: boolean;
rendering?: boolean;
setStickyState?: SetStickyState;
}
export interface UseStickyTableOptions {
getTableSize?: GetTableSize;
}
export interface UseStickyInstanceProps {
// manipulate DOMs in <table> to make the header sticky
wrapStickyTable: (renderer: TableRenderer) => ReactNode;
// update or recompute the sticky table size
setStickyState: SetStickyState;
}
export type UseStickyState = {
sticky: StickyState;
};
const sum = (a: number, b: number) => a + b;
const mergeStyleProp = (
node: ReactElement<{ style?: CSSProperties }>,
style: CSSProperties,
) => ({
style: {
...node.props.style,
...style,
},
});
const fixedTableLayout: CSSProperties = { tableLayout: 'fixed' };
/**
* An HOC for generating sticky header and fixed-height scrollable area
*/
function StickyWrap({
sticky = {},
width: maxWidth,
height: maxHeight,
children: table,
setStickyState,
}: {
width: number;
height: number;
setStickyState: SetStickyState;
children: Table;
sticky?: StickyState; // current sticky element sizes
}) {
if (!table || table.type !== 'table') {
throw new Error('<StickyWrap> must have only one <table> element as child');
}
let thead: Thead | undefined;
let tbody: Tbody | undefined;
let tfoot: Tfoot | undefined;
React.Children.forEach(table.props.children, node => {
if (!node) {
return;
}
if (node.type === 'thead') {
thead = node;
} else if (node.type === 'tbody') {
tbody = node;
} else if (node.type === 'tfoot') {
tfoot = node;
}
});
if (!thead || !tbody) {
throw new Error(
'<table> in <StickyWrap> must contain both thead and tbody.',
);
}
const columnCount = useMemo(() => {
const headerRows = React.Children.toArray(
thead?.props.children,
).pop() as TrWithTh;
return headerRows.props.children.length;
}, [thead]);
const theadRef = useRef<HTMLTableSectionElement>(null); // original thead for layout computation
const tfootRef = useRef<HTMLTableSectionElement>(null); // original tfoot for layout computation
const scrollHeaderRef = useRef<HTMLDivElement>(null); // fixed header
const scrollFooterRef = useRef<HTMLDivElement>(null); // fixed footer
const scrollBodyRef = useRef<HTMLDivElement>(null); // main body
const scrollBarSize = getScrollBarSize();
const { bodyHeight, columnWidths } = sticky;
const needSizer =
!columnWidths ||
sticky.width !== maxWidth ||
sticky.height !== maxHeight ||
sticky.setStickyState !== setStickyState;
// update scrollable area and header column sizes when mounted
useLayoutEffect(() => {
if (!theadRef.current) {
return;
}
const bodyThead = theadRef.current;
const theadHeight = bodyThead.clientHeight;
const tfootHeight = tfootRef.current ? tfootRef.current.clientHeight : 0;
if (!theadHeight) {
return;
}
const fullTableHeight = (bodyThead.parentNode as HTMLTableElement)
.clientHeight;
const ths = bodyThead.childNodes[0]
.childNodes as NodeListOf<HTMLTableHeaderCellElement>;
const widths = Array.from(ths).map(th => th.clientWidth);
const [hasVerticalScroll, hasHorizontalScroll] = needScrollBar({
width: maxWidth,
height: maxHeight - theadHeight - tfootHeight,
innerHeight: fullTableHeight,
innerWidth: widths.reduce(sum),
scrollBarSize,
});
// real container height, include table header, footer and space for
// horizontal scroll bar
const realHeight = Math.min(
maxHeight,
hasHorizontalScroll ? fullTableHeight + scrollBarSize : fullTableHeight,
);
setStickyState({
hasVerticalScroll,
hasHorizontalScroll,
setStickyState,
width: maxWidth,
height: maxHeight,
realHeight,
tableHeight: fullTableHeight,
bodyHeight: realHeight - theadHeight - tfootHeight,
columnWidths: widths,
});
}, [maxWidth, maxHeight, setStickyState, scrollBarSize]);
let sizerTable: ReactElement | undefined;
let headerTable: ReactElement | undefined;
let footerTable: ReactElement | undefined;
let bodyTable: ReactElement | undefined;
if (needSizer) {
const theadWithRef = React.cloneElement(thead, { ref: theadRef });
const tfootWithRef = tfoot && React.cloneElement(tfoot, { ref: tfootRef });
sizerTable = (
<div
key="sizer"
style={{
height: maxHeight,
overflow: 'auto',
visibility: 'hidden',
}}
>
{React.cloneElement(table, {}, theadWithRef, tbody, tfootWithRef)}
</div>
);
}
// reuse previously column widths, will be updated by `useLayoutEffect` above
const colWidths = columnWidths?.slice(0, columnCount);
if (colWidths && bodyHeight) {
const bodyColgroup = (
<colgroup>
{colWidths.map((w, i) => (
// eslint-disable-next-line react/no-array-index-key
<col key={i} width={w} />
))}
</colgroup>
);
// header columns do not have vertical scroll bars,
// so we add scroll bar size to the last column
const headerColgroup =
sticky.hasVerticalScroll && scrollBarSize ? (
<colgroup>
{colWidths.map((x, i) => (
// eslint-disable-next-line react/no-array-index-key
<col
key={i}
width={x + (i === colWidths.length - 1 ? scrollBarSize : 0)}
/>
))}
</colgroup>
) : (
bodyColgroup
);
headerTable = (
<div
key="header"
ref={scrollHeaderRef}
style={{
overflow: 'hidden',
}}
>
{React.cloneElement(
table,
mergeStyleProp(table, fixedTableLayout),
headerColgroup,
thead,
)}
{headerTable}
</div>
);
footerTable = tfoot && (
<div
key="footer"
ref={scrollFooterRef}
style={{
overflow: 'hidden',
}}
>
{React.cloneElement(
table,
mergeStyleProp(table, fixedTableLayout),
headerColgroup,
tfoot,
)}
{footerTable}
</div>
);
const onScroll: UIEventHandler<HTMLDivElement> = e => {
if (scrollHeaderRef.current) {
scrollHeaderRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
if (scrollFooterRef.current) {
scrollFooterRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
};
bodyTable = (
<div
key="body"
ref={scrollBodyRef}
style={{
height: bodyHeight,
overflow: 'auto',
}}
onScroll={sticky.hasHorizontalScroll ? onScroll : undefined}
>
{React.cloneElement(
table,
mergeStyleProp(table, fixedTableLayout),
bodyColgroup,
tbody,
)}
</div>
);
}
return (
<div
style={{
width: maxWidth,
height: sticky.realHeight || maxHeight,
overflow: 'hidden',
}}
>
{headerTable}
{bodyTable}
{footerTable}
{sizerTable}
</div>
);
}
function useInstance<D extends object>(instance: TableInstance<D>) {
const {
dispatch,
state: { sticky },
data,
page,
rows,
getTableSize = () => undefined,
} = instance;
const setStickyState = useCallback(
(size?: Partial<StickyState>) => {
dispatch({
type: ReducerActions.setStickyState,
size,
});
},
// turning pages would also trigger a resize
// eslint-disable-next-line react-hooks/exhaustive-deps
[dispatch, getTableSize, page, rows],
);
const useStickyWrap = (renderer: TableRenderer) => {
const { width, height } =
useMountedMemo(getTableSize, [getTableSize]) || sticky;
// only change of data should trigger re-render
// eslint-disable-next-line react-hooks/exhaustive-deps
const table = useMemo(renderer, [page, rows]);
useLayoutEffect(() => {
if (!width || !height) {
setStickyState();
}
}, [width, height]);
if (!width || !height) {
return null;
}
if (data.length === 0) {
return table;
}
return (
<StickyWrap
width={width}
height={height}
sticky={sticky}
setStickyState={setStickyState}
>
{table}
</StickyWrap>
);
};
Object.assign(instance, {
setStickyState,
wrapStickyTable: useStickyWrap,
});
}
export default function useSticky<D extends object>(hooks: Hooks<D>) {
hooks.useInstance.push(useInstance);
hooks.stateReducers.push((newState, action_, prevState) => {
const action = action_ as ReducerAction<
ReducerActions,
{ size: StickyState }
>;
if (action.type === ReducerActions.init) {
return {
...newState,
sticky: {
...prevState?.sticky,
},
};
}
if (action.type === ReducerActions.setStickyState) {
const { size } = action;
if (!size) {
return { ...newState };
}
return {
...newState,
sticky: {
...prevState?.sticky,
...newState?.sticky,
...action.size,
},
};
}
return newState;
});
}
useSticky.pluginName = 'useSticky';

View File

@@ -0,0 +1,25 @@
/**
* 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.
*/
export * from './hooks/useSticky';
export * from './components/GlobalFilter';
export * from './components/Pagination';
export * from './components/SelectPageSize';
export * from './DataTable';
export { default } from './DataTable';

View File

@@ -0,0 +1,105 @@
/*
* 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.
*/
/**
* Merge typing interfaces for UseTable hooks.
*
* Ref: https://gist.github.com/ggascoigne/646e14c9d54258e40588a13aabf0102d
*/
import {
UseGlobalFiltersState,
UseGlobalFiltersOptions,
UseGlobalFiltersInstanceProps,
UsePaginationInstanceProps,
UsePaginationOptions,
UsePaginationState,
UseSortByColumnOptions,
UseSortByColumnProps,
UseSortByInstanceProps,
UseSortByOptions,
UseSortByState,
UseTableHooks,
UseSortByHooks,
Renderer,
HeaderProps,
TableFooterProps,
} from 'react-table';
import {
UseStickyState,
UseStickyTableOptions,
UseStickyInstanceProps,
} from '../hooks/useSticky';
declare module 'react-table' {
export interface TableOptions<D extends object>
extends UseExpandedOptions<D>,
UseGlobalFiltersOptions<D>,
UsePaginationOptions<D>,
UseRowSelectOptions<D>,
UseSortByOptions<D>,
UseStickyTableOptions {}
export interface TableInstance<D extends object>
extends UseColumnOrderInstanceProps<D>,
UseExpandedInstanceProps<D>,
UseGlobalFiltersInstanceProps<D>,
UsePaginationInstanceProps<D>,
UseRowSelectInstanceProps<D>,
UseRowStateInstanceProps<D>,
UseSortByInstanceProps<D>,
UseStickyInstanceProps {}
export interface TableState<D extends object>
extends UseColumnOrderState<D>,
UseExpandedState<D>,
UseGlobalFiltersState<D>,
UsePaginationState<D>,
UseRowSelectState<D>,
UseSortByState<D>,
UseStickyState {}
// Typing from @types/react-table is incomplete
interface TableSortByToggleProps {
style?: React.CSSProperties;
title?: string;
onClick?: React.MouseEventHandler;
}
export interface ColumnInterface<D extends object>
extends UseGlobalFiltersColumnOptions<D>,
UseSortByColumnOptions<D> {
// must define as a new property because it's not possible to override
// the existing `Header` renderer option
Header?: Renderer<TableSortByToggleProps & HeaderProps<D>>;
Footer?: Renderer<TableFooterProps<D>>;
}
export interface ColumnInstance<D extends object>
extends UseGlobalFiltersColumnOptions<D>,
UseSortByColumnProps<D> {
getSortByToggleProps: (
props?: Partial<TableSortByToggleProps>,
) => TableSortByToggleProps;
}
export interface Hooks<D extends object>
extends UseTableHooks<D>,
UseSortByHooks<D> {}
}

View File

@@ -0,0 +1,32 @@
/**
* 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 { SetDataMaskHook } from '@superset-ui/core';
export const updateExternalFormData = (
setDataMask: SetDataMaskHook = () => {},
pageNumber: number,
pageSize: number,
) =>
setDataMask({
ownState: {
currentPage: pageNumber,
pageSize,
},
});

View File

@@ -0,0 +1,48 @@
/**
* 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.
*/
let cached: number | undefined;
const css = (x: TemplateStringsArray) => x.join('\n');
export default function getScrollBarSize(forceRefresh = false) {
if (typeof document === 'undefined') {
return 0;
}
if (cached === undefined || forceRefresh) {
const inner = document.createElement('div');
const outer = document.createElement('div');
inner.style.cssText = css`
width: auto;
height: 100%;
overflow: scroll;
`;
outer.style.cssText = css`
position: absolute;
visibility: hidden;
overflow: hidden;
width: 100px;
height: 50px;
`;
outer.append(inner);
document.body.append(outer);
cached = outer.clientWidth - inner.clientWidth;
outer.remove();
}
return cached;
}

View File

@@ -0,0 +1,39 @@
/**
* 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.
*/
/**
* Whether a container need scroll bars when in another container.
*/
export default function needScrollBar({
width,
height,
innerHeight,
innerWidth,
scrollBarSize,
}: {
width: number;
height: number;
innerHeight: number;
scrollBarSize: number;
innerWidth: number;
}): [boolean, boolean] {
const hasVerticalScroll = innerHeight > height;
const hasHorizontalScroll =
innerWidth > width - (hasVerticalScroll ? scrollBarSize : 0);
return [hasVerticalScroll, hasHorizontalScroll];
}

View File

@@ -0,0 +1,51 @@
/**
* 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 { useRef, useState } from 'react';
import { useAsyncDebounce } from 'react-table';
// useAsyncDebounce in dist build of `react-table` requires regeneratorRuntime
import 'regenerator-runtime/runtime';
/**
* Hook useState to allow always return latest initialValue
*/
export default function useAsyncState<T, F extends (newValue: T) => unknown>(
initialValue: T,
callback: F,
wait = 200,
) {
const [value, setValue] = useState(initialValue);
const valueRef = useRef(initialValue);
const onChange = useAsyncDebounce(callback, wait);
// sync updated initialValue
if (valueRef.current !== initialValue) {
valueRef.current = initialValue;
if (value !== initialValue) {
setValue(initialValue);
}
}
const setBoth = (newValue: T) => {
setValue(newValue);
onChange(newValue);
};
return [value, setBoth] as [typeof value, typeof setValue];
}

View File

@@ -0,0 +1,40 @@
/**
* 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 { useLayoutEffect, useRef, useMemo } from 'react';
/**
* Execute a memoized callback only when mounted. Execute again when factory updated.
* Returns undefined if not mounted yet.
*/
export default function useMountedMemo<T>(
factory: () => T,
deps?: unknown[],
): T | undefined {
const mounted = useRef<typeof factory>();
useLayoutEffect(() => {
mounted.current = factory;
});
return useMemo(() => {
if (mounted.current) {
return factory();
}
return undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mounted.current, mounted.current === factory, ...(deps || [])]);
}