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 || [])]);
}

View File

@@ -0,0 +1,100 @@
/*
* 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 { styled } from '@superset-ui/core';
export default styled.div`
table {
width: 100%;
min-width: auto;
max-width: none;
margin: 0;
}
th,
td {
min-width: 4.3em;
}
thead > tr > th {
padding-right: 1.4em;
position: relative;
background: ${({ theme: { colors } }) => colors.grayscale.light5};
text-align: left;
}
th svg {
color: ${({ theme: { colors } }) => colors.grayscale.light2};
position: relative;
vertical-align: middle;
margin: 0 ${({ theme: { gridUnit } }) => gridUnit}px;
}
th.is-sorted svg {
color: ${({ theme: { colors } }) => colors.grayscale.base};
}
.table > tbody > tr:first-of-type > td,
.table > tbody > tr:first-of-type > th {
border-top: 0;
}
.dt-controls {
padding-bottom: 0.65em;
}
.dt-metric {
text-align: right;
}
.dt-totals {
font-weight: bold;
}
.dt-is-null {
color: ${({ theme: { colors } }) => colors.grayscale.light1};
}
td.dt-is-filter {
cursor: pointer;
}
td.dt-is-filter:hover {
background-color: ${({ theme: { colors } }) => colors.secondary.light4};
}
td.dt-is-active-filter,
td.dt-is-active-filter:hover {
background-color: ${({ theme: { colors } }) => colors.secondary.light3};
}
.dt-global-filter {
float: right;
}
.dt-pagination {
text-align: right;
/* use padding instead of margin so clientHeight can capture it */
padding-top: 0.5em;
}
.dt-pagination .pagination {
margin: 0;
}
.pagination > li > span.dt-pagination-ellipsis:focus,
.pagination > li > span.dt-pagination-ellipsis:hover {
background: ${({ theme: { colors } }) => colors.grayscale.light5};
}
.dt-no-results {
text-align: center;
padding: 1em 0.6em;
}
`;

View File

@@ -0,0 +1,503 @@
/**
* 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, useCallback, useMemo } from 'react';
import {
ColumnInstance,
ColumnWithLooseAccessor,
DefaultSortTypes,
} from 'react-table';
import { extent as d3Extent, max as d3Max } from 'd3-array';
import { FaSort } from '@react-icons/all-files/fa/FaSort';
import { FaSortDown as FaSortDesc } from '@react-icons/all-files/fa/FaSortDown';
import { FaSortUp as FaSortAsc } from '@react-icons/all-files/fa/FaSortUp';
import {
DataRecord,
DataRecordValue,
DTTM_ALIAS,
ensureIsArray,
GenericDataType,
getTimeFormatterForGranularity,
t,
tn,
} from '@superset-ui/core';
import { DataColumnMeta, TableChartTransformedProps } from './types';
import DataTable, {
DataTableProps,
SearchInputProps,
SelectPageSizeRendererProps,
SizeOption,
} from './DataTable';
import Styles from './Styles';
import { formatColumnValue } from './utils/formatValue';
import { PAGE_SIZE_OPTIONS } from './consts';
import { updateExternalFormData } from './DataTable/utils/externalAPIs';
type ValueRange = [number, number];
/**
* Return sortType based on data type
*/
function getSortTypeByDataType(dataType: GenericDataType): DefaultSortTypes {
if (dataType === GenericDataType.TEMPORAL) {
return 'datetime';
}
if (dataType === GenericDataType.STRING) {
return 'alphanumeric';
}
return 'basic';
}
/**
* Cell background to render columns as horizontal bar chart
*/
function cellBar({
value,
valueRange,
colorPositiveNegative = false,
alignPositiveNegative,
}: {
value: number;
valueRange: ValueRange;
colorPositiveNegative: boolean;
alignPositiveNegative: boolean;
}) {
const [minValue, maxValue] = valueRange;
const r = colorPositiveNegative && value < 0 ? 150 : 0;
if (alignPositiveNegative) {
const perc = Math.abs(Math.round((value / maxValue) * 100));
// The 0.01 to 0.001 is a workaround for what appears to be a
// CSS rendering bug on flat, transparent colors
return (
`linear-gradient(to right, rgba(${r},0,0,0.2), rgba(${r},0,0,0.2) ${perc}%, ` +
`rgba(0,0,0,0.01) ${perc}%, rgba(0,0,0,0.001) 100%)`
);
}
const posExtent = Math.abs(Math.max(maxValue, 0));
const negExtent = Math.abs(Math.min(minValue, 0));
const tot = posExtent + negExtent;
const perc1 = Math.round(
(Math.min(negExtent + value, negExtent) / tot) * 100,
);
const perc2 = Math.round((Math.abs(value) / tot) * 100);
// The 0.01 to 0.001 is a workaround for what appears to be a
// CSS rendering bug on flat, transparent colors
return (
`linear-gradient(to right, rgba(0,0,0,0.01), rgba(0,0,0,0.001) ${perc1}%, ` +
`rgba(${r},0,0,0.2) ${perc1}%, rgba(${r},0,0,0.2) ${perc1 + perc2}%, ` +
`rgba(0,0,0,0.01) ${perc1 + perc2}%, rgba(0,0,0,0.001) 100%)`
);
}
function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) {
const { isSorted, isSortedDesc } = column;
let sortIcon = <FaSort />;
if (isSorted) {
sortIcon = isSortedDesc ? <FaSortDesc /> : <FaSortAsc />;
}
return sortIcon;
}
function SearchInput({ count, value, onChange }: SearchInputProps) {
return (
<span className="dt-global-filter">
{t('Search')}{' '}
<input
className="form-control input-sm"
placeholder={tn('search.num_records', count)}
value={value}
onChange={onChange}
/>
</span>
);
}
function SelectPageSize({
options,
current,
onChange,
}: SelectPageSizeRendererProps) {
return (
<span className="dt-select-page-size form-inline">
{t('page_size.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>{' '}
{t('page_size.entries')}
</span>
);
}
export default function TableChart<D extends DataRecord = DataRecord>(
props: TableChartTransformedProps<D> & {
sticky?: DataTableProps<D>['sticky'];
},
) {
const {
timeGrain,
height,
width,
data,
totals,
isRawRecords,
rowCount = 0,
columns: columnsMeta,
alignPositiveNegative: defaultAlignPN = false,
colorPositiveNegative: defaultColorPN = false,
includeSearch = false,
pageSize = 0,
serverPagination = false,
serverPaginationData,
setDataMask,
showCellBars = true,
emitFilter = false,
sortDesc = false,
filters,
sticky = true, // whether to use sticky header
columnColorFormatters,
} = props;
const timestampFormatter = useCallback(
value => getTimeFormatterForGranularity(timeGrain)(value),
[timeGrain],
);
const handleChange = useCallback(
(filters: { [x: string]: DataRecordValue[] }) => {
if (!emitFilter) {
return;
}
const groupBy = Object.keys(filters);
const groupByValues = Object.values(filters);
const labelElements: string[] = [];
groupBy.forEach(col => {
const isTimestamp = col === DTTM_ALIAS;
const filterValues = ensureIsArray(filters?.[col]);
if (filterValues.length) {
const valueLabels = filterValues.map(value =>
isTimestamp ? timestampFormatter(value) : value,
);
labelElements.push(`${valueLabels.join(', ')}`);
}
});
setDataMask({
extraFormData: {
filters:
groupBy.length === 0
? []
: groupBy.map(col => {
const val = ensureIsArray(filters?.[col]);
if (!val.length)
return {
col,
op: 'IS NULL',
};
return {
col,
op: 'IN',
val: val.map(el =>
el instanceof Date ? el.getTime() : el!,
),
grain: col === DTTM_ALIAS ? timeGrain : undefined,
};
}),
},
filterState: {
label: labelElements.join(', '),
value: groupByValues.length ? groupByValues : null,
filters: filters && Object.keys(filters).length ? filters : null,
},
});
},
[emitFilter, setDataMask],
);
// only take relevant page size options
const pageSizeOptions = useMemo(() => {
const getServerPagination = (n: number) => n <= rowCount;
return PAGE_SIZE_OPTIONS.filter(([n]) =>
serverPagination ? getServerPagination(n) : n <= 2 * data.length,
) as SizeOption[];
}, [data.length, rowCount, serverPagination]);
const getValueRange = useCallback(
function getValueRange(key: string, alignPositiveNegative: boolean) {
if (typeof data?.[0]?.[key] === 'number') {
const nums = data.map(row => row[key]) as number[];
return (
alignPositiveNegative
? [0, d3Max(nums.map(Math.abs))]
: d3Extent(nums)
) as ValueRange;
}
return null;
},
[data],
);
const isActiveFilterValue = useCallback(
function isActiveFilterValue(key: string, val: DataRecordValue) {
return !!filters && filters[key]?.includes(val);
},
[filters],
);
function getEmitTarget(col: string) {
const meta = columnsMeta?.find(x => x.key === col);
return meta?.config?.emitTarget || col;
}
const toggleFilter = useCallback(
function toggleFilter(key: string, val: DataRecordValue) {
let updatedFilters = { ...(filters || {}) };
const target = getEmitTarget(key);
if (filters && isActiveFilterValue(target, val)) {
updatedFilters = {};
} else {
updatedFilters = {
[target]: [val],
};
}
if (
Array.isArray(updatedFilters[target]) &&
updatedFilters[target].length === 0
) {
delete updatedFilters[target];
}
handleChange(updatedFilters);
},
[filters, handleChange, isActiveFilterValue],
);
const getSharedStyle = (column: DataColumnMeta): CSSProperties => {
const { isNumeric, config = {} } = column;
const textAlign = config.horizontalAlign
? config.horizontalAlign
: isNumeric
? 'right'
: 'left';
return {
textAlign,
};
};
const getColumnConfigs = useCallback(
(column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => {
const { key, label, isNumeric, dataType, isMetric, config = {} } = column;
const isFilter = !isNumeric && emitFilter;
const columnWidth = Number.isNaN(Number(config.columnWidth))
? config.columnWidth
: Number(config.columnWidth);
// inline style for both th and td cell
const sharedStyle: CSSProperties = getSharedStyle(column);
const alignPositiveNegative =
config.alignPositiveNegative === undefined
? defaultAlignPN
: config.alignPositiveNegative;
const colorPositiveNegative =
config.colorPositiveNegative === undefined
? defaultColorPN
: config.colorPositiveNegative;
const hasColumnColorFormatters =
isNumeric &&
Array.isArray(columnColorFormatters) &&
columnColorFormatters.length > 0;
const valueRange =
!hasColumnColorFormatters &&
(config.showCellBars === undefined
? showCellBars
: config.showCellBars) &&
(isMetric || isRawRecords) &&
getValueRange(key, alignPositiveNegative);
let className = '';
if (isFilter) {
className += ' dt-is-filter';
}
return {
id: String(i), // to allow duplicate column keys
// must use custom accessor to allow `.` in column names
// typing is incorrect in current version of `@types/react-table`
// so we ask TS not to check.
accessor: ((datum: D) => datum[key]) as never,
Cell: ({ value }: { value: DataRecordValue }) => {
const [isHtml, text] = formatColumnValue(column, value);
const html = isHtml ? { __html: text } : undefined;
let backgroundColor;
if (hasColumnColorFormatters) {
columnColorFormatters!
.filter(formatter => formatter.column === column.key)
.forEach(formatter => {
const formatterResult = formatter.getColorFromValue(
value as number,
);
if (formatterResult) {
backgroundColor = formatterResult;
}
});
}
const cellProps = {
// show raw number in title in case of numeric values
title: typeof value === 'number' ? String(value) : undefined,
onClick:
emitFilter && !valueRange
? () => toggleFilter(key, value)
: undefined,
className: [
className,
value == null ? 'dt-is-null' : '',
isActiveFilterValue(key, value) ? ' dt-is-active-filter' : '',
].join(' '),
style: {
...sharedStyle,
background:
backgroundColor ||
(valueRange
? cellBar({
value: value as number,
valueRange,
alignPositiveNegative,
colorPositiveNegative,
})
: undefined),
},
};
if (html) {
// eslint-disable-next-line react/no-danger
return <td {...cellProps} dangerouslySetInnerHTML={html} />;
}
// If cellProps renderes textContent already, then we don't have to
// render `Cell`. This saves some time for large tables.
return <td {...cellProps}>{text}</td>;
},
Header: ({ column: col, onClick, style }) => (
<th
title="Shift + Click to sort by multiple columns"
className={[className, col.isSorted ? 'is-sorted' : ''].join(' ')}
style={{
...sharedStyle,
...style,
}}
onClick={onClick}
>
{/* can't use `columnWidth &&` because it may also be zero */}
{config.columnWidth ? (
// column width hint
<div
style={{
width: columnWidth,
height: 0.01,
}}
/>
) : null}
{label}
<SortIcon column={col} />
</th>
),
Footer: totals ? (
i === 0 ? (
<th>{t('Totals')}</th>
) : (
<td style={sharedStyle}>
<strong>{formatColumnValue(column, totals[key])[1]}</strong>
</td>
)
) : undefined,
sortDescFirst: sortDesc,
sortType: getSortTypeByDataType(dataType),
};
},
[
defaultAlignPN,
defaultColorPN,
emitFilter,
getValueRange,
isActiveFilterValue,
isRawRecords,
showCellBars,
sortDesc,
toggleFilter,
totals,
columnColorFormatters,
],
);
const columns = useMemo(
() => columnsMeta.map(getColumnConfigs),
[columnsMeta, getColumnConfigs],
);
const handleServerPaginationChange = (
pageNumber: number,
pageSize: number,
) => {
updateExternalFormData(setDataMask, pageNumber, pageSize);
};
return (
<Styles>
<DataTable<D>
columns={columns}
data={data}
rowCount={rowCount}
tableClassName="table table-striped table-condensed"
pageSize={pageSize}
serverPaginationData={serverPaginationData}
pageSizeOptions={pageSizeOptions}
width={width}
height={height}
serverPagination={serverPagination}
onServerPaginationChange={handleServerPaginationChange}
// 9 page items in > 340px works well even for 100+ pages
maxPageItemCount={width > 340 ? 9 : 7}
noResults={(filter: string) =>
t(filter ? 'No matching records found' : 'No records found')
}
searchInput={includeSearch && SearchInput}
selectPageSize={pageSize !== null && SelectPageSize}
// not in use in Superset, but needed for unit tests
sticky={sticky}
/>
</Styles>
);
}

View File

@@ -0,0 +1,198 @@
/**
* 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 {
buildQueryContext,
ensureIsArray,
getMetricLabel,
QueryMode,
QueryObject,
removeDuplicates,
} from '@superset-ui/core';
import { PostProcessingRule } from '@superset-ui/core/src/query/types/PostProcessing';
import { BuildQuery } from '@superset-ui/core/src/chart/registries/ChartBuildQueryRegistrySingleton';
import { TableChartFormData } from './types';
import { updateExternalFormData } from './DataTable/utils/externalAPIs';
/**
* Infer query mode from form data. If `all_columns` is set, then raw records mode,
* otherwise defaults to aggregation mode.
*
* The same logic is used in `controlPanel` with control values as well.
*/
export function getQueryMode(formData: TableChartFormData) {
const { query_mode: mode } = formData;
if (mode === QueryMode.aggregate || mode === QueryMode.raw) {
return mode;
}
const rawColumns = formData?.all_columns;
const hasRawColumns = rawColumns && rawColumns.length > 0;
return hasRawColumns ? QueryMode.raw : QueryMode.aggregate;
}
const buildQuery: BuildQuery<TableChartFormData> = (
formData: TableChartFormData,
options,
) => {
const { percent_metrics: percentMetrics, order_desc: orderDesc = false } =
formData;
const queryMode = getQueryMode(formData);
const sortByMetric = ensureIsArray(formData.timeseries_limit_metric)[0];
let formDataCopy = formData;
// never include time in raw records mode
if (queryMode === QueryMode.raw) {
formDataCopy = {
...formData,
include_time: false,
};
}
return buildQueryContext(formDataCopy, baseQueryObject => {
let { metrics, orderby = [] } = baseQueryObject;
let postProcessing: PostProcessingRule[] = [];
if (queryMode === QueryMode.aggregate) {
metrics = metrics || [];
// orverride orderby with timeseries metric when in aggregation mode
if (sortByMetric) {
orderby = [[sortByMetric, !orderDesc]];
} else if (metrics?.length > 0) {
// default to ordering by first metric in descending order
// when no "sort by" metric is set (regargless if "SORT DESC" is set to true)
orderby = [[metrics[0], false]];
}
// add postprocessing for percent metrics only when in aggregation mode
if (percentMetrics && percentMetrics.length > 0) {
const percentMetricLabels = removeDuplicates(
percentMetrics.map(getMetricLabel),
);
metrics = removeDuplicates(
metrics.concat(percentMetrics),
getMetricLabel,
);
postProcessing = [
{
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(x => `%${x}`),
},
},
];
}
}
const moreProps: Partial<QueryObject> = {};
const ownState = options?.ownState ?? {};
if (formDataCopy.server_pagination) {
moreProps.row_limit =
ownState.pageSize ?? formDataCopy.server_page_length;
moreProps.row_offset =
(ownState.currentPage ?? 0) * (ownState.pageSize ?? 0);
}
let queryObject = {
...baseQueryObject,
orderby,
metrics,
post_processing: postProcessing,
...moreProps,
};
if (
formData.server_pagination &&
options?.extras?.cachedChanges?.[formData.slice_id] &&
JSON.stringify(options?.extras?.cachedChanges?.[formData.slice_id]) !==
JSON.stringify(queryObject.filters)
) {
queryObject = { ...queryObject, row_offset: 0 };
updateExternalFormData(
options?.hooks?.setDataMask,
0,
queryObject.row_limit ?? 0,
);
}
// Because we use same buildQuery for all table on the page we need split them by id
options?.hooks?.setCachedChanges({
[formData.slice_id]: queryObject.filters,
});
const extraQueries: QueryObject[] = [];
if (
metrics?.length &&
formData.show_totals &&
queryMode === QueryMode.aggregate
) {
extraQueries.push({
...queryObject,
columns: [],
row_limit: 0,
row_offset: 0,
post_processing: [],
});
}
const interactiveGroupBy = formData.extra_form_data?.interactive_groupby;
if (interactiveGroupBy && queryObject.columns) {
queryObject.columns = [
...new Set([...queryObject.columns, ...interactiveGroupBy]),
];
}
if (formData.server_pagination) {
return [
{ ...queryObject },
{
...queryObject,
row_limit: 0,
row_offset: 0,
post_processing: [],
is_rowcount: true,
},
...extraQueries,
];
}
return [queryObject, ...extraQueries];
});
};
// Use this closure to cache changing of external filters, if we have server pagination we need reset page to 0, after
// external filter changed
export const cachedBuildQuery = (): BuildQuery<TableChartFormData> => {
let cachedChanges: any = {};
const setCachedChanges = (newChanges: any) => {
cachedChanges = { ...cachedChanges, ...newChanges };
};
return (formData, options) =>
buildQuery(
{ ...formData },
{
extras: { cachedChanges },
ownState: options?.ownState ?? {},
hooks: {
...options?.hooks,
setDataMask: () => {},
setCachedChanges,
},
},
);
};
export default cachedBuildQuery();

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 { formatSelectOptions } from '@superset-ui/chart-controls';
import { addLocaleData, t } from '@superset-ui/core';
import i18n from './i18n';
addLocaleData(i18n);
export const PAGE_SIZE_OPTIONS = formatSelectOptions<number>([
[0, t('page_size.all')],
10,
20,
50,
100,
200,
]);

View File

@@ -0,0 +1,506 @@
/* eslint-disable camelcase */
/**
* 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 {
addLocaleData,
ChartDataResponseResult,
ensureIsArray,
FeatureFlag,
GenericDataType,
isFeatureEnabled,
QueryFormColumn,
QueryMode,
smartDateFormatter,
t,
} from '@superset-ui/core';
import {
ColumnOption,
ControlConfig,
ControlPanelConfig,
ControlPanelsContainerProps,
ControlStateMapping,
D3_TIME_FORMAT_OPTIONS,
QueryModeLabel,
sections,
sharedControls,
ControlPanelState,
ExtraControlProps,
ControlState,
emitFilterControl,
} from '@superset-ui/chart-controls';
import i18n from './i18n';
import { PAGE_SIZE_OPTIONS } from './consts';
addLocaleData(i18n);
function getQueryMode(controls: ControlStateMapping): QueryMode {
const mode = controls?.query_mode?.value;
if (mode === QueryMode.aggregate || mode === QueryMode.raw) {
return mode as QueryMode;
}
const rawColumns = controls?.all_columns?.value as
| QueryFormColumn[]
| undefined;
const hasRawColumns = rawColumns && rawColumns.length > 0;
return hasRawColumns ? QueryMode.raw : QueryMode.aggregate;
}
/**
* Visibility check
*/
function isQueryMode(mode: QueryMode) {
return ({ controls }: Pick<ControlPanelsContainerProps, 'controls'>) =>
getQueryMode(controls) === mode;
}
const isAggMode = isQueryMode(QueryMode.aggregate);
const isRawMode = isQueryMode(QueryMode.raw);
const validateAggControlValues = (
controls: ControlStateMapping,
values: any[],
) => {
const areControlsEmpty = values.every(val => ensureIsArray(val).length === 0);
return areControlsEmpty && isAggMode({ controls })
? [t('Group By, Metrics or Percentage Metrics must have a value')]
: [];
};
const queryMode: ControlConfig<'RadioButtonControl'> = {
type: 'RadioButtonControl',
label: t('Query mode'),
default: null,
options: [
[QueryMode.aggregate, QueryModeLabel[QueryMode.aggregate]],
[QueryMode.raw, QueryModeLabel[QueryMode.raw]],
],
mapStateToProps: ({ controls }) => ({ value: getQueryMode(controls) }),
rerender: ['all_columns', 'groupby', 'metrics', 'percent_metrics'],
};
const all_columns: typeof sharedControls.groupby = {
type: 'SelectControl',
label: t('Columns'),
description: t('Columns to display'),
multi: true,
freeForm: true,
allowAll: true,
commaChoosesOption: false,
default: [],
optionRenderer: c => <ColumnOption showType column={c} />,
valueRenderer: c => <ColumnOption column={c} />,
valueKey: 'column_name',
mapStateToProps: ({ datasource, controls }, controlState) => ({
options: datasource?.columns || [],
queryMode: getQueryMode(controls),
externalValidationErrors:
isRawMode({ controls }) && ensureIsArray(controlState.value).length === 0
? [t('must have a value')]
: [],
}),
visibility: isRawMode,
};
const dnd_all_columns: typeof sharedControls.groupby = {
type: 'DndColumnSelect',
label: t('Columns'),
description: t('Columns to display'),
default: [],
mapStateToProps({ datasource, controls }, controlState) {
const newState: ExtraControlProps = {};
if (datasource) {
const options = datasource.columns;
newState.options = Object.fromEntries(
options.map(option => [option.column_name, option]),
);
}
newState.queryMode = getQueryMode(controls);
newState.externalValidationErrors =
isRawMode({ controls }) && ensureIsArray(controlState.value).length === 0
? [t('must have a value')]
: [];
return newState;
},
visibility: isRawMode,
};
const percent_metrics: typeof sharedControls.metrics = {
type: 'MetricsControl',
label: t('Percentage metrics'),
description: t(
'Metrics for which percentage of total are to be displayed. Calculated from only data within the row limit.',
),
multi: true,
visibility: isAggMode,
mapStateToProps: ({ datasource, controls }, controlState) => ({
columns: datasource?.columns || [],
savedMetrics: datasource?.metrics || [],
datasource,
datasourceType: datasource?.type,
queryMode: getQueryMode(controls),
externalValidationErrors: validateAggControlValues(controls, [
controls.groupby?.value,
controls.metrics?.value,
controlState.value,
]),
}),
rerender: ['groupby', 'metrics'],
default: [],
validators: [],
};
const dnd_percent_metrics = {
...percent_metrics,
type: 'DndMetricSelect',
};
const config: ControlPanelConfig = {
controlPanelSections: [
sections.legacyTimeseriesTime,
{
label: t('Query'),
expanded: true,
controlSetRows: [
[
{
name: 'query_mode',
config: queryMode,
},
],
[
{
name: 'groupby',
override: {
visibility: isAggMode,
mapStateToProps: (
state: ControlPanelState,
controlState: ControlState,
) => {
const { controls } = state;
const originalMapStateToProps =
sharedControls?.groupby?.mapStateToProps;
const newState =
originalMapStateToProps?.(state, controlState) ?? {};
newState.externalValidationErrors = validateAggControlValues(
controls,
[
controls.metrics?.value,
controls.percent_metrics?.value,
controlState.value,
],
);
return newState;
},
rerender: ['metrics', 'percent_metrics'],
},
},
],
[
{
name: 'metrics',
override: {
validators: [],
visibility: isAggMode,
mapStateToProps: (
{ controls, datasource, form_data }: ControlPanelState,
controlState: ControlState,
) => ({
columns: datasource?.columns.filter(c => c.filterable) || [],
savedMetrics: datasource?.metrics || [],
// current active adhoc metrics
selectedMetrics:
form_data.metrics ||
(form_data.metric ? [form_data.metric] : []),
datasource,
externalValidationErrors: validateAggControlValues(controls, [
controls.groupby?.value,
controls.percent_metrics?.value,
controlState.value,
]),
}),
rerender: ['groupby', 'percent_metrics'],
},
},
{
name: 'all_columns',
config: isFeatureEnabled(FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP)
? dnd_all_columns
: all_columns,
},
],
[
{
name: 'percent_metrics',
config: {
...(isFeatureEnabled(FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP)
? dnd_percent_metrics
: percent_metrics),
},
},
],
[
{
name: 'timeseries_limit_metric',
override: {
visibility: isAggMode,
},
},
{
name: 'order_by_cols',
config: {
type: 'SelectControl',
label: t('Ordering'),
description: t('Order results by selected columns'),
multi: true,
default: [],
mapStateToProps: ({ datasource }) => ({
choices: datasource?.order_by_choices || [],
}),
visibility: isRawMode,
},
},
],
isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) ||
isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS)
? [
{
name: 'server_pagination',
config: {
type: 'CheckboxControl',
label: t('Server pagination'),
description: t(
'Enable server side pagination of results (experimental feature)',
),
default: false,
},
},
]
: [],
[
{
name: 'row_limit',
override: {
visibility: ({ controls }: ControlPanelsContainerProps) =>
!controls?.server_pagination?.value,
},
},
{
name: 'server_page_length',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Server Page Length'),
default: 10,
choices: PAGE_SIZE_OPTIONS,
description: t('Rows per page, 0 means no pagination'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.server_pagination?.value),
},
},
],
[
{
name: 'include_time',
config: {
type: 'CheckboxControl',
label: t('Include time'),
description: t(
'Whether to include the time granularity as defined in the time section',
),
default: false,
visibility: isAggMode,
},
},
{
name: 'order_desc',
config: {
type: 'CheckboxControl',
label: t('Sort descending'),
default: true,
description: t('Whether to sort descending or ascending'),
visibility: isAggMode,
},
},
],
[
{
name: 'show_totals',
config: {
type: 'CheckboxControl',
label: t('Show totals'),
default: false,
description: t(
'Show total aggregations of selected metrics. Note that row limit does not apply to the result.',
),
visibility: isAggMode,
},
},
],
['adhoc_filters'],
emitFilterControl,
],
},
{
label: t('Options'),
expanded: true,
controlSetRows: [
[
{
name: 'table_timestamp_format',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Timestamp format'),
default: smartDateFormatter.id,
renderTrigger: true,
clearable: false,
choices: D3_TIME_FORMAT_OPTIONS,
description: t('D3 time format for datetime columns'),
},
},
],
[
{
name: 'page_length',
config: {
type: 'SelectControl',
freeForm: true,
renderTrigger: true,
label: t('Page length'),
default: null,
choices: PAGE_SIZE_OPTIONS,
description: t('Rows per page, 0 means no pagination'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
!controls?.server_pagination?.value,
},
},
null,
],
[
{
name: 'include_search',
config: {
type: 'CheckboxControl',
label: t('Search box'),
renderTrigger: true,
default: false,
description: t('Whether to include a client-side search box'),
},
},
{
name: 'show_cell_bars',
config: {
type: 'CheckboxControl',
label: t('Cell bars'),
renderTrigger: true,
default: true,
description: t(
'Whether to display a bar chart background in table columns',
),
},
},
],
[
{
name: 'align_pn',
config: {
type: 'CheckboxControl',
label: t('Align +/-'),
renderTrigger: true,
default: false,
description: t(
'Whether to align background charts with both positive and negative values at 0',
),
},
},
{
name: 'color_pn',
config: {
type: 'CheckboxControl',
label: t('Color +/-'),
renderTrigger: true,
default: true,
description: t(
'Whether to colorize numeric values by if they are positive or negative',
),
},
},
],
[
{
name: 'column_config',
config: {
type: 'ColumnConfigControl',
label: t('Customize columns'),
description: t('Further customize how to display each column'),
renderTrigger: true,
mapStateToProps(explore, control, chart) {
return {
queryResponse: chart?.queriesResponse?.[0] as
| ChartDataResponseResult
| undefined,
emitFilter: explore?.controls?.table_filter?.value,
};
},
},
},
],
[
{
name: 'conditional_formatting',
config: {
type: 'ConditionalFormattingControl',
renderTrigger: true,
label: t('Conditional formatting'),
description: t(
'Apply conditional color formatting to numeric columns',
),
mapStateToProps(explore, control, chart) {
const verboseMap = explore?.datasource?.verbose_map ?? {};
const { colnames, coltypes } =
chart?.queriesResponse?.[0] ?? {};
const numericColumns =
Array.isArray(colnames) && Array.isArray(coltypes)
? colnames
.filter(
(colname: string, index: number) =>
coltypes[index] === GenericDataType.NUMERIC,
)
.map(colname => ({
value: colname,
label: verboseMap[colname] ?? colname,
}))
: [];
return {
columnOptions: numericColumns,
verboseMap,
};
},
},
},
],
],
},
],
};
export default config;

View File

@@ -0,0 +1,66 @@
/*
* 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 { Locale } from '@superset-ui/core';
const en = {
'Query Mode': [''],
Aggregate: [''],
'Raw Records': [''],
'Emit Filter Events': [''],
'Show Cell Bars': [''],
'page_size.show': ['Show'],
'page_size.all': ['All'],
'page_size.entries': ['entries'],
'table.previous_page': ['Previous'],
'table.next_page': ['Next'],
'search.num_records': ['%s record', '%s records...'],
};
const translations: Partial<Record<Locale, typeof en>> = {
en,
fr: {
'Query Mode': [''],
Aggregate: [''],
'Raw Records': [''],
'Emit Filter Events': [''],
'Show Cell Bars': [''],
'page_size.show': ['Afficher'],
'page_size.all': ['tous'],
'page_size.entries': ['entrées'],
'table.previous_page': ['Précédent'],
'table.next_page': ['Suivante'],
'search.num_records': ['%s enregistrement', '%s enregistrements...'],
},
zh: {
'Query Mode': ['查询模式'],
Aggregate: ['分组聚合'],
'Raw Records': ['原始数据'],
'Emit Filter Events': ['关联看板过滤器'],
'Show Cell Bars': ['为指标添加条状图背景'],
'page_size.show': ['每页显示'],
'page_size.all': ['全部'],
'page_size.entries': ['条'],
'table.previous_page': ['上一页'],
'table.next_page': ['下一页'],
'search.num_records': ['%s条记录...'],
},
};
export default translations;

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,69 @@
/**
* 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 { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
import example1 from './images/Table.jpg';
import example2 from './images/Table2.jpg';
import example3 from './images/Table3.jpg';
import controlPanel from './controlPanel';
import buildQuery from './buildQuery';
import { TableChartFormData, TableChartProps } from './types';
// must export something for the module to be exist in dev mode
export { default as __hack__ } from './types';
export * from './types';
const metadata = new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART],
category: t('Table'),
canBeAnnotationTypes: ['EVENT', 'INTERVAL'],
description: t(
'Classic row-by-column spreadsheet like view of a dataset. Use tables to showcase a view into the underlying data or to show aggregated metrics.',
),
exampleGallery: [{ url: example1 }, { url: example2 }, { url: example3 }],
name: t('Table'),
tags: [
t('Additive'),
t('Business'),
t('Formattable'),
t('Pattern'),
t('Popular'),
t('Report'),
t('Sequential'),
t('Tabular'),
t('Description'),
],
thumbnail,
});
export default class TableChartPlugin extends ChartPlugin<
TableChartFormData,
TableChartProps
> {
constructor() {
super({
loadChart: () => import('./TableChart'),
metadata,
transformProps,
controlPanel,
buildQuery,
});
}
}

View File

@@ -0,0 +1,271 @@
/**
* 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 memoizeOne from 'memoize-one';
import {
DataRecord,
extractTimegrain,
GenericDataType,
getMetricLabel,
getNumberFormatter,
getTimeFormatter,
getTimeFormatterForGranularity,
NumberFormats,
QueryMode,
smartDateFormatter,
TimeFormats,
TimeFormatter,
} from '@superset-ui/core';
import { getColorFormatters } from '@superset-ui/chart-controls';
import isEqualColumns from './utils/isEqualColumns';
import DateWithFormatter from './utils/DateWithFormatter';
import {
DataColumnMeta,
TableChartProps,
TableChartTransformedProps,
} from './types';
const { PERCENT_3_POINT } = NumberFormats;
const { DATABASE_DATETIME } = TimeFormats;
function isNumeric(key: string, data: DataRecord[] = []) {
return data.every(
x => x[key] === null || x[key] === undefined || typeof x[key] === 'number',
);
}
const processDataRecords = memoizeOne(function processDataRecords(
data: DataRecord[] | undefined,
columns: DataColumnMeta[],
) {
if (!data || !data[0]) {
return data || [];
}
const timeColumns = columns.filter(
column => column.dataType === GenericDataType.TEMPORAL,
);
if (timeColumns.length > 0) {
return data.map(x => {
const datum = { ...x };
timeColumns.forEach(({ key, formatter }) => {
// Convert datetime with a custom date class so we can use `String(...)`
// formatted value for global search, and `date.getTime()` for sorting.
datum[key] = new DateWithFormatter(x[key], {
formatter: formatter as TimeFormatter,
});
});
return datum;
});
}
return data;
});
const processColumns = memoizeOne(function processColumns(
props: TableChartProps,
) {
const {
datasource: { columnFormats, verboseMap },
rawFormData: {
table_timestamp_format: tableTimestampFormat,
metrics: metrics_,
percent_metrics: percentMetrics_,
column_config: columnConfig = {},
},
queriesData,
} = props;
const granularity = extractTimegrain(props.rawFormData);
const { data: records, colnames, coltypes } = queriesData[0] || {};
// convert `metrics` and `percentMetrics` to the key names in `data.records`
const metrics = (metrics_ ?? []).map(getMetricLabel);
const rawPercentMetrics = (percentMetrics_ ?? []).map(getMetricLabel);
// column names for percent metrics always starts with a '%' sign.
const percentMetrics = rawPercentMetrics.map((x: string) => `%${x}`);
const metricsSet = new Set(metrics);
const percentMetricsSet = new Set(percentMetrics);
const rawPercentMetricsSet = new Set(rawPercentMetrics);
const columns: DataColumnMeta[] = (colnames || [])
.filter(
key =>
// if a metric was only added to percent_metrics, they should not show up in the table.
!(rawPercentMetricsSet.has(key) && !metricsSet.has(key)),
)
.map((key: string, i) => {
const label = verboseMap?.[key] || key;
const dataType = coltypes[i];
const config = columnConfig[key] || {};
// for the purpose of presentation, only numeric values are treated as metrics
// because users can also add things like `MAX(str_col)` as a metric.
const isMetric = metricsSet.has(key) && isNumeric(key, records);
const isPercentMetric = percentMetricsSet.has(key);
const isTime = dataType === GenericDataType.TEMPORAL;
const savedFormat = columnFormats?.[key];
const numberFormat = config.d3NumberFormat || savedFormat;
let formatter;
if (isTime || config.d3TimeFormat) {
// string types may also apply d3-time format
// pick adhoc format first, fallback to column level formats defined in
// datasource
const customFormat = config.d3TimeFormat || savedFormat;
const timeFormat = customFormat || tableTimestampFormat;
// When format is "Adaptive Formatting" (smart_date)
if (timeFormat === smartDateFormatter.id) {
if (granularity) {
// time column use formats based on granularity
formatter = getTimeFormatterForGranularity(granularity);
} else if (customFormat) {
// other columns respect the column-specific format
formatter = getTimeFormatter(customFormat);
} else if (isNumeric(key, records)) {
// if column is numeric values, it is considered a timestamp64
formatter = getTimeFormatter(DATABASE_DATETIME);
} else {
// if no column-specific format, print cell as is
formatter = String;
}
} else if (timeFormat) {
formatter = getTimeFormatter(timeFormat);
}
} else if (isPercentMetric) {
// percent metrics have a default format
formatter = getNumberFormatter(numberFormat || PERCENT_3_POINT);
} else if (isMetric || numberFormat) {
formatter = getNumberFormatter(numberFormat);
}
return {
key,
label,
dataType,
isNumeric: dataType === GenericDataType.NUMERIC,
isMetric,
isPercentMetric,
formatter,
config,
};
});
return [metrics, percentMetrics, columns] as [
typeof metrics,
typeof percentMetrics,
typeof columns,
];
},
isEqualColumns);
/**
* Automatically set page size based on number of cells.
*/
const getPageSize = (
pageSize: number | string | null | undefined,
numRecords: number,
numColumns: number,
) => {
if (typeof pageSize === 'number') {
// NaN is also has typeof === 'number'
return pageSize || 0;
}
if (typeof pageSize === 'string') {
return Number(pageSize) || 0;
}
// when pageSize not set, automatically add pagination if too many records
return numRecords * numColumns > 5000 ? 200 : 0;
};
const transformProps = (
chartProps: TableChartProps,
): TableChartTransformedProps => {
const {
height,
width,
rawFormData: formData,
queriesData = [],
filterState,
ownState: serverPaginationData = {},
hooks: { onAddFilter: onChangeFilter, setDataMask = () => {} },
} = chartProps;
const {
align_pn: alignPositiveNegative = true,
color_pn: colorPositiveNegative = true,
show_cell_bars: showCellBars = true,
include_search: includeSearch = false,
page_length: pageLength,
emit_filter: emitFilter,
server_pagination: serverPagination = false,
server_page_length: serverPageLength = 10,
order_desc: sortDesc = false,
query_mode: queryMode,
show_totals: showTotals,
conditional_formatting: conditionalFormatting,
} = formData;
const timeGrain = extractTimegrain(formData);
const [metrics, percentMetrics, columns] = processColumns(chartProps);
let baseQuery;
let countQuery;
let totalQuery;
let rowCount;
if (serverPagination) {
[baseQuery, countQuery, totalQuery] = queriesData;
rowCount = (countQuery?.data?.[0]?.rowcount as number) ?? 0;
} else {
[baseQuery, totalQuery] = queriesData;
rowCount = baseQuery?.rowcount ?? 0;
}
const data = processDataRecords(baseQuery?.data, columns);
const totals =
showTotals && queryMode === QueryMode.aggregate
? totalQuery?.data[0]
: undefined;
const columnColorFormatters =
getColorFormatters(conditionalFormatting, data) ?? [];
return {
height,
width,
isRawRecords: queryMode === QueryMode.raw,
data,
totals,
columns,
serverPagination,
metrics,
percentMetrics,
serverPaginationData,
setDataMask,
alignPositiveNegative,
colorPositiveNegative,
showCellBars,
sortDesc,
includeSearch,
rowCount,
pageSize: serverPagination
? serverPageLength
: getPageSize(pageLength, data.length, columns.length),
filters: filterState.filters,
emitFilter,
onChangeFilter,
columnColorFormatters,
timeGrain,
};
};
export default transformProps;

View File

@@ -0,0 +1,114 @@
/**
* 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 {
NumberFormatter,
TimeFormatter,
TimeGranularity,
QueryFormMetric,
ChartProps,
DataRecord,
DataRecordValue,
DataRecordFilters,
GenericDataType,
QueryMode,
ChartDataResponseResult,
QueryFormData,
SetDataMaskHook,
} from '@superset-ui/core';
import { ColorFormatters, ColumnConfig } from '@superset-ui/chart-controls';
export type CustomFormatter = (value: DataRecordValue) => string;
export interface DataColumnMeta {
// `key` is what is called `label` in the input props
key: string;
// `label` is verbose column name used for rendering
label: string;
dataType: GenericDataType;
formatter?: TimeFormatter | NumberFormatter | CustomFormatter;
isMetric?: boolean;
isPercentMetric?: boolean;
isNumeric?: boolean;
config?: ColumnConfig;
}
export interface TableChartData {
records: DataRecord[];
columns: string[];
}
export type TableChartFormData = QueryFormData & {
align_pn?: boolean;
color_pn?: boolean;
include_time?: boolean;
include_search?: boolean;
query_mode?: QueryMode;
page_length?: string | number | null; // null means auto-paginate
metrics?: QueryFormMetric[] | null;
percent_metrics?: QueryFormMetric[] | null;
timeseries_limit_metric?: QueryFormMetric[] | QueryFormMetric | null;
groupby?: QueryFormMetric[] | null;
all_columns?: QueryFormMetric[] | null;
order_desc?: boolean;
show_cell_bars?: boolean;
table_timestamp_format?: string;
emit_filter?: boolean;
time_grain_sqla?: TimeGranularity;
column_config?: Record<string, ColumnConfig>;
};
export interface TableChartProps extends ChartProps {
ownCurrentState: {
pageSize?: number;
currentPage?: number;
};
rawFormData: TableChartFormData;
queriesData: ChartDataResponseResult[];
}
export interface TableChartTransformedProps<D extends DataRecord = DataRecord> {
timeGrain?: TimeGranularity;
height: number;
width: number;
rowCount?: number;
serverPagination: boolean;
serverPaginationData: { pageSize?: number; currentPage?: number };
setDataMask: SetDataMaskHook;
isRawRecords?: boolean;
data: D[];
totals?: D;
columns: DataColumnMeta[];
metrics?: (keyof D)[];
percentMetrics?: (keyof D)[];
pageSize?: number;
showCellBars?: boolean;
sortDesc?: boolean;
includeSearch?: boolean;
alignPositiveNegative?: boolean;
colorPositiveNegative?: boolean;
tableTimestampFormat?: string;
// These are dashboard filters, don't be confused with in-chart search filter
// enabled by `includeSearch`
filters?: DataRecordFilters;
emitFilter?: boolean;
onChangeFilter?: ChartProps['hooks']['onAddFilter'];
columnColorFormatters?: ColorFormatters;
}
export default {};

View File

@@ -0,0 +1,60 @@
/**
* 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 { DataRecordValue, TimeFormatFunction } from '@superset-ui/core';
const REGEXP_TIMESTAMP_NO_TIMEZONE = /T(\d{2}:){2}\d{2}$/;
/**
* Extended Date object with a custom formatter, and retains the original input
* when the formatter is simple `String(..)`.
*/
export default class DateWithFormatter extends Date {
formatter: TimeFormatFunction;
input: DataRecordValue;
constructor(
input: DataRecordValue,
{
formatter = String,
forceUTC = true,
}: { formatter?: TimeFormatFunction; forceUTC?: boolean } = {},
) {
let value = input;
// assuming timestamps without a timezone is in UTC time
if (
forceUTC &&
typeof value === 'string' &&
REGEXP_TIMESTAMP_NO_TIMEZONE.test(value)
) {
value = `${value}Z`;
}
super(value as string);
this.input = input;
this.formatter = formatter;
this.toString = (): string => {
if (this.formatter === String) {
return String(this.input);
}
return this.formatter ? this.formatter(this) : Date.toString.call(this);
};
}
}

View File

@@ -0,0 +1,45 @@
/**
* 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 default function extent<T = number | string | Date | undefined | null>(
values: T[],
) {
let min: T | undefined;
let max: T | undefined;
// eslint-disable-next-line no-restricted-syntax
for (const value of values) {
if (value !== null) {
if (min === undefined) {
if (value !== undefined) {
min = value;
max = value;
}
} else {
if (min > value) {
min = value;
}
if (max !== undefined) {
if (max < value) {
max = value;
}
}
}
}
}
return [min, max];
}

View File

@@ -0,0 +1,94 @@
/**
* 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 { FilterXSS, getDefaultWhiteList } from 'xss';
import {
DataRecordValue,
GenericDataType,
getNumberFormatter,
} from '@superset-ui/core';
import { DataColumnMeta } from '../types';
const xss = new FilterXSS({
whiteList: {
...getDefaultWhiteList(),
span: ['style', 'class', 'title'],
div: ['style', 'class'],
a: ['style', 'class', 'href', 'title', 'target'],
img: ['style', 'class', 'src', 'alt', 'title', 'width', 'height'],
video: [
'autoplay',
'controls',
'loop',
'preload',
'src',
'height',
'width',
'muted',
],
},
stripIgnoreTag: true,
css: false,
});
function isProbablyHTML(text: string) {
return /<[^>]+>/.test(text);
}
/**
* Format text for cell value.
*/
function formatValue(
formatter: DataColumnMeta['formatter'],
value: DataRecordValue,
): [boolean, string] {
// render undefined as empty string
if (value === undefined) {
return [false, ''];
}
// render null as `N/A`
if (value === null) {
return [false, 'N/A'];
}
if (formatter) {
// in case percent metric can specify percent format in the future
return [false, formatter(value as number)];
}
if (typeof value === 'string') {
return isProbablyHTML(value) ? [true, xss.process(value)] : [false, value];
}
return [false, value.toString()];
}
export function formatColumnValue(
column: DataColumnMeta,
value: DataRecordValue,
) {
const { dataType, formatter, config = {} } = column;
const isNumber = dataType === GenericDataType.NUMERIC;
const smallNumberFormatter =
config.d3SmallNumberFormat === undefined
? formatter
: getNumberFormatter(config.d3SmallNumberFormat);
return formatValue(
isNumber && typeof value === 'number' && Math.abs(value) < 1
? smallNumberFormatter
: formatter,
value,
);
}

View File

@@ -0,0 +1,31 @@
/**
* 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 default function isEqualArray<T extends unknown[] | undefined | null>(
arrA: T,
arrB: T,
) {
return (
arrA === arrB ||
(!arrA && !arrB) ||
(arrA &&
arrB &&
arrA.length === arrB.length &&
arrA.every((x, i) => x === arrB[i]))
);
}

View File

@@ -0,0 +1,43 @@
/**
* 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 isEqualArray from './isEqualArray';
import { TableChartProps } from '../types';
export default function isEqualColumns(
propsA: TableChartProps[],
propsB: TableChartProps[],
) {
const a = propsA[0];
const b = propsB[0];
return (
a.datasource.columnFormats === b.datasource.columnFormats &&
a.datasource.verboseMap === b.datasource.verboseMap &&
a.formData.tableTimestampFormat === b.formData.tableTimestampFormat &&
a.formData.timeGrainSqla === b.formData.timeGrainSqla &&
JSON.stringify(a.formData.columnConfig || null) ===
JSON.stringify(b.formData.columnConfig || null) &&
isEqualArray(a.formData.metrics, b.formData.metrics) &&
isEqualArray(a.queriesData?.[0]?.colnames, b.queriesData?.[0]?.colnames) &&
isEqualArray(a.queriesData?.[0]?.coltypes, b.queriesData?.[0]?.coltypes) &&
JSON.stringify(a.formData.extraFilters || null) ===
JSON.stringify(b.formData.extraFilters || null) &&
JSON.stringify(a.formData.extraFormData || null) ===
JSON.stringify(b.formData.extraFormData || null)
);
}