mirror of
https://github.com/apache/superset.git
synced 2026-04-21 17:14:57 +00:00
refactor(monorepo): move superset-ui to superset(stage 2) (#17552)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -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} />
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
105
superset-frontend/plugins/plugin-chart-table/src/DataTable/types/react-table.d.ts
vendored
Normal file
105
superset-frontend/plugins/plugin-chart-table/src/DataTable/types/react-table.d.ts
vendored
Normal 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> {}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 || [])]);
|
||||
}
|
||||
100
superset-frontend/plugins/plugin-chart-table/src/Styles.tsx
Normal file
100
superset-frontend/plugins/plugin-chart-table/src/Styles.tsx
Normal 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;
|
||||
}
|
||||
`;
|
||||
503
superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
Normal file
503
superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts
Normal file
198
superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts
Normal 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();
|
||||
32
superset-frontend/plugins/plugin-chart-table/src/consts.ts
Normal file
32
superset-frontend/plugins/plugin-chart-table/src/consts.ts
Normal 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,
|
||||
]);
|
||||
@@ -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;
|
||||
66
superset-frontend/plugins/plugin-chart-table/src/i18n.ts
Normal file
66
superset-frontend/plugins/plugin-chart-table/src/i18n.ts
Normal 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 |
69
superset-frontend/plugins/plugin-chart-table/src/index.ts
Normal file
69
superset-frontend/plugins/plugin-chart-table/src/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
114
superset-frontend/plugins/plugin-chart-table/src/types.ts
Normal file
114
superset-frontend/plugins/plugin-chart-table/src/types.ts
Normal 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 {};
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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]))
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user