feat(viz-type): Ag grid table plugin Integration (#33517)

Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Amaan Nawab <nelsondrew07@gmail.com>
Co-authored-by: Levis Mbote <111055098+LevisNgigi@users.noreply.github.com>
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
Co-authored-by: Paul Rhodes <withnale@users.noreply.github.com>
Co-authored-by: Vitor Avila <96086495+Vitor-Avila@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
Co-authored-by: Sam Firke <sfirke@users.noreply.github.com>
This commit is contained in:
amaannawab923
2025-07-07 19:20:44 +05:30
committed by GitHub
parent 0fc4119728
commit 0a5941edd7
47 changed files with 5468 additions and 0 deletions

View File

@@ -36,6 +36,7 @@
"@superset-ui/legacy-plugin-chart-world-map": "file:./plugins/legacy-plugin-chart-world-map",
"@superset-ui/legacy-preset-chart-deckgl": "file:./plugins/legacy-preset-chart-deckgl",
"@superset-ui/legacy-preset-chart-nvd3": "file:./plugins/legacy-preset-chart-nvd3",
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
"@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram",
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
@@ -13640,6 +13641,10 @@
"resolved": "plugins/legacy-preset-chart-nvd3",
"link": true
},
"node_modules/@superset-ui/plugin-chart-ag-grid-table": {
"resolved": "plugins/plugin-chart-ag-grid-table",
"link": true
},
"node_modules/@superset-ui/plugin-chart-cartodiagram": {
"resolved": "plugins/plugin-chart-cartodiagram",
"link": true
@@ -60047,6 +60052,40 @@
"@types/trusted-types": "^2.0.7"
}
},
"plugins/plugin-chart-ag-grid-table": {
"name": "@superset-ui/plugin-chart-ag-grid-table",
"version": "0.20.3",
"license": "Apache-2.0",
"dependencies": {
"@react-icons/all-files": "^4.1.0",
"@types/d3-array": "^2.9.0",
"@types/react-table": "^7.7.20",
"ag-grid-community": "^33.1.1",
"ag-grid-react": "^33.1.1",
"classnames": "^2.5.1",
"d3-array": "^2.4.0",
"lodash": "^4.17.21",
"memoize-one": "^5.2.1",
"react-table": "^7.8.0",
"regenerator-runtime": "^0.14.1",
"xss": "^1.0.15"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "*",
"@testing-library/user-event": "*",
"@types/classnames": "*",
"@types/react": "*",
"match-sorter": "^6.3.3",
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
},
"plugins/plugin-chart-cartodiagram": {
"name": "@superset-ui/plugin-chart-cartodiagram",
"version": "0.0.1",

View File

@@ -109,6 +109,7 @@
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
"@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud",
"@superset-ui/switchboard": "file:./packages/superset-ui-switchboard",
"@types/d3-format": "^3.0.1",

View File

@@ -55,6 +55,7 @@ export enum VizType {
Step = 'echarts_timeseries_step',
Sunburst = 'sunburst_v2',
Table = 'table',
TableAgGrid = 'ag-grid-table',
TimePivot = 'time_pivot',
TimeTable = 'time_table',
Timeseries = 'echarts_timeseries',

View File

@@ -60,6 +60,8 @@ export enum FeatureFlag {
SlackEnableAvatars = 'SLACK_ENABLE_AVATARS',
EnableDashboardScreenshotEndpoints = 'ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS',
EnableDashboardDownloadWebDriverScreenshot = 'ENABLE_DASHBOARD_DOWNLOAD_WEBDRIVER_SCREENSHOT',
TableV2TimeComparisonEnabled = 'TABLE_V2_TIME_COMPARISON_ENABLED',
AgGridTableEnabled = 'AG_GRID_TABLE_ENABLED',
}
export type ScheduleQueriesProps = {

View File

@@ -0,0 +1,58 @@
{
"name": "@superset-ui/plugin-chart-ag-grid-table",
"version": "0.20.3",
"description": "Superset Chart - Table",
"keywords": [
"superset"
],
"homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-ag-grid-table#readme",
"bugs": {
"url": "https://github.com/apache/superset/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/apache/superset.git",
"directory": "superset-frontend/plugins/plugin-chart-ag-grid-table"
},
"license": "Apache-2.0",
"author": "Superset",
"sideEffects": false,
"main": "lib/index.js",
"module": "esm/index.js",
"files": [
"esm",
"lib"
],
"dependencies": {
"@react-icons/all-files": "^4.1.0",
"@types/d3-array": "^2.9.0",
"@types/react-table": "^7.7.20",
"ag-grid-community": "^33.1.1",
"ag-grid-react": "^33.1.1",
"classnames": "^2.5.1",
"d3-array": "^2.4.0",
"lodash": "^4.17.21",
"memoize-one": "^5.2.1",
"react-table": "^7.8.0",
"regenerator-runtime": "^0.14.1",
"xss": "^1.0.15"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "*",
"@testing-library/user-event": "*",
"@types/classnames": "*",
"@types/react": "*",
"match-sorter": "^6.3.3",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,187 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable theme-colors/no-literal-colors */
/*
* 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 { t } from '@superset-ui/core';
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
import FilterIcon from './Filter';
import KebabMenu from './KebabMenu';
import {
CustomColDef,
CustomHeaderParams,
SortState,
UserProvidedColDef,
} from '../../types';
import CustomPopover from './CustomPopover';
import {
Container,
FilterIconWrapper,
HeaderContainer,
HeaderLabel,
MenuContainer,
SortIconWrapper,
} from '../../styles';
const getSortIcon = (sortState: SortState[], colId: string | null) => {
if (!sortState?.length || !colId) return null;
const { colId: currentCol, sort } = sortState[0];
if (currentCol === colId) {
return sort === 'asc' ? (
<ArrowUpOutlined />
) : sort === 'desc' ? (
<ArrowDownOutlined />
) : null;
}
return null;
};
const CustomHeader: React.FC<CustomHeaderParams> = ({
displayName,
enableSorting,
setSort,
context,
column,
api,
}) => {
const { initialSortState, onColumnHeaderClicked } = context;
const colId = column?.getColId();
const colDef = column?.getColDef() as CustomColDef;
const userColDef = column.getUserProvidedColDef() as UserProvidedColDef;
const isPercentMetric = colDef?.context?.isPercentMetric;
const [isFilterVisible, setFilterVisible] = useState(false);
const [isMenuVisible, setMenuVisible] = useState(false);
const filterRef = useRef<HTMLDivElement>(null);
const isFilterActive = column?.isFilterActive();
const currentSort = initialSortState?.[0];
const isMain = userColDef?.isMain;
const isTimeComparison = !isMain && userColDef?.timeComparisonKey;
const sortKey = isMain ? colId.replace('Main', '').trim() : colId;
// Sorting logic
const clearSort = () => {
onColumnHeaderClicked({ column: { colId: sortKey, sort: null } });
setSort(null, false);
};
const applySort = (direction: 'asc' | 'desc') => {
onColumnHeaderClicked({ column: { colId: sortKey, sort: direction } });
setSort(direction, false);
};
const getNextSortDirection = (): 'asc' | 'desc' | null => {
if (currentSort?.colId !== colId) return 'asc';
if (currentSort?.sort === 'asc') return 'desc';
return null;
};
const toggleSort = () => {
if (!enableSorting || isTimeComparison) return;
const next = getNextSortDirection();
if (next) applySort(next);
else clearSort();
};
const handleFilterClick = async (e: React.MouseEvent) => {
e.stopPropagation();
setFilterVisible(!isFilterVisible);
const filterInstance = await api.getColumnFilterInstance<any>(column);
const filterEl = filterInstance?.eGui;
if (filterEl && filterRef.current) {
filterRef.current.innerHTML = '';
filterRef.current.appendChild(filterEl);
}
};
const handleMenuClick = (e: React.MouseEvent) => {
e.stopPropagation();
setMenuVisible(!isMenuVisible);
};
const isCurrentColSorted = currentSort?.colId === colId;
const currentDirection = isCurrentColSorted ? currentSort?.sort : null;
const shouldShowAsc =
!isTimeComparison && (!currentDirection || currentDirection === 'desc');
const shouldShowDesc =
!isTimeComparison && (!currentDirection || currentDirection === 'asc');
const menuContent = (
<MenuContainer>
{shouldShowAsc && (
<div onClick={() => applySort('asc')} className="menu-item">
<ArrowUpOutlined /> {t('Sort Ascending')}
</div>
)}
{shouldShowDesc && (
<div onClick={() => applySort('desc')} className="menu-item">
<ArrowDownOutlined /> {t('Sort Descending')}
</div>
)}
{currentSort && currentSort?.colId === colId && (
<div onClick={clearSort} className="menu-item">
<span style={{ fontSize: 16 }}></span> {t('Clear Sort')}
</div>
)}
</MenuContainer>
);
return (
<Container>
<HeaderContainer onClick={toggleSort} className="custom-header">
<HeaderLabel>{displayName}</HeaderLabel>
<SortIconWrapper>
{getSortIcon(initialSortState, colId)}
</SortIconWrapper>
</HeaderContainer>
<CustomPopover
content={<div ref={filterRef} />}
isOpen={isFilterVisible}
onClose={() => setFilterVisible(false)}
>
<FilterIconWrapper
className="header-filter"
onClick={handleFilterClick}
isFilterActive={isFilterActive}
>
<FilterIcon />
</FilterIconWrapper>
</CustomPopover>
{!isPercentMetric && !isTimeComparison && (
<CustomPopover
content={menuContent}
isOpen={isMenuVisible}
onClose={() => setMenuVisible(false)}
>
<div className="three-dots-menu" onClick={handleMenuClick}>
<KebabMenu />
</div>
</CustomPopover>
)}
</Container>
);
};
export default CustomHeader;

View File

@@ -0,0 +1,104 @@
/**
* 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 { useEffect, useRef, useState, cloneElement } from 'react';
import { PopoverContainer, PopoverWrapper } from '../../styles';
interface Props {
content: React.ReactNode;
children: React.ReactElement;
isOpen: boolean;
onClose: () => void;
}
const CustomPopover: React.FC<Props> = ({
content,
children,
isOpen,
onClose,
}) => {
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const updatePosition = () => {
const rect = triggerRef.current?.getBoundingClientRect();
if (rect) {
const popoverWidth = popoverRef.current?.offsetWidth || 200;
const windowWidth = window.innerWidth;
const rightEdgePosition = rect.left + 10 + 160 + popoverWidth;
// Check if popover would spill out of the window
const shouldUseOffset = rightEdgePosition <= windowWidth;
setPosition({
top: rect.bottom + 8,
left: Math.max(
0,
rect.right -
(popoverRef.current?.offsetWidth || 0) +
(shouldUseOffset ? 170 : 0),
),
});
}
};
if (isOpen) {
updatePosition();
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', updatePosition);
window.addEventListener('resize', updatePosition);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', updatePosition);
window.removeEventListener('resize', updatePosition);
};
}, [isOpen]);
const handleClickOutside = (event: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
!triggerRef.current?.contains(event.target as Node)
) {
onClose();
}
};
return (
<PopoverWrapper>
{cloneElement(children, { ref: triggerRef })}
{isOpen && (
<PopoverContainer
ref={popoverRef}
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
{content}
</PopoverContainer>
)}
</PopoverWrapper>
);
};
export default CustomPopover;

View File

@@ -0,0 +1,27 @@
/**
* 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.
*/
const FilterIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<rect x="3" y="6" width="18" height="2" rx="1" />
<rect x="6" y="11" width="12" height="2" rx="1" />
<rect x="9" y="16" width="6" height="2" rx="1" />
</svg>
);
export default FilterIcon;

View File

@@ -0,0 +1,33 @@
/**
* 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.
*/
const KebabMenu = ({ size = 14 }) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="8" cy="3" r="1.2" />
<circle cx="8" cy="8" r="1.2" />
<circle cx="8" cy="13" r="1.2" />
</svg>
);
export default KebabMenu;

View File

@@ -0,0 +1,139 @@
/**
* 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.
*/
/* eslint-disable theme-colors/no-literal-colors */
import { t } from '@superset-ui/core';
import {
VerticalLeftOutlined,
VerticalRightOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons';
import { Select } from '@superset-ui/core/components';
import {
PaginationContainer,
SelectWrapper,
PageInfo,
PageCount,
PageButton,
ButtonGroup,
} from '../../styles';
interface PaginationProps {
currentPage: number;
pageSize: number;
totalRows: number;
pageSizeOptions: number[];
onServerPaginationChange: (pageNumber: number, pageSize: number) => void;
onServerPageSizeChange: (pageSize: number) => void;
sliceId: number;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage = 0,
pageSize = 10,
totalRows = 0,
pageSizeOptions = [10, 20, 50, 100, 200],
onServerPaginationChange = () => {},
onServerPageSizeChange = () => {},
sliceId,
}) => {
const totalPages = Math.ceil(totalRows / pageSize);
const startRow = currentPage * pageSize + 1;
const endRow = Math.min((currentPage + 1) * pageSize, totalRows);
const handleNextPage = (disabled: boolean) => () => {
if (disabled) return;
onServerPaginationChange(currentPage + 1, pageSize);
};
const handlePrevPage = (disabled: boolean) => () => {
if (disabled) return;
onServerPaginationChange(currentPage - 1, pageSize);
};
const handleNavigateToFirstPage = (disabled: boolean) => () => {
if (disabled) return;
onServerPaginationChange(0, pageSize);
};
const handleNavigateToLastPage = (disabled: boolean) => () => {
if (disabled) return;
onServerPaginationChange(totalPages - 1, pageSize);
};
const selectOptions = pageSizeOptions.map(size => ({
value: size,
label: size,
}));
return (
<PaginationContainer>
<span>{t('Page Size:')}</span>
<SelectWrapper>
<Select
value={`${pageSize}`}
options={selectOptions}
onChange={(value: string) => {
onServerPageSizeChange(Number(value));
}}
getPopupContainer={() =>
document.getElementById(`chart-id-${sliceId}`) as HTMLElement
}
/>
</SelectWrapper>
<PageInfo>
<span>{startRow}</span> {t('to')} <span>{endRow}</span> {t('of')}{' '}
<span>{totalRows}</span>
</PageInfo>
<ButtonGroup>
<PageButton
onClick={handleNavigateToFirstPage(currentPage === 0)}
disabled={currentPage === 0}
>
<VerticalRightOutlined />
</PageButton>
<PageButton
onClick={handlePrevPage(currentPage === 0)}
disabled={currentPage === 0}
>
<LeftOutlined />
</PageButton>
<PageCount>
{t('Page')} <span>{currentPage + 1}</span> {t('of')}{' '}
<span>{totalPages}</span>
</PageCount>
<PageButton
onClick={handleNextPage(!!(currentPage >= totalPages - 1))}
disabled={currentPage >= totalPages - 1}
>
<RightOutlined />
</PageButton>
<PageButton
onClick={handleNavigateToLastPage(!!(currentPage >= totalPages - 1))}
disabled={currentPage >= totalPages - 1}
>
<VerticalLeftOutlined />
</PageButton>
</ButtonGroup>
</PaginationContainer>
);
};
export default Pagination;

View File

@@ -0,0 +1,46 @@
/**
* 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 { SearchOption } from '../../types';
import { StyledSelect } from '../../styles';
interface SearchSelectDropdownProps {
/** The currently selected search column value */
value?: string;
/** Callback triggered when a new search column is selected */
onChange: (searchCol: string) => void;
/** Available search column options to populate the dropdown */
searchOptions: SearchOption[];
}
function SearchSelectDropdown({
value,
onChange,
searchOptions,
}: SearchSelectDropdownProps) {
return (
<StyledSelect
className="search-select"
value={value || (searchOptions?.[0]?.value ?? '')}
options={searchOptions}
onChange={onChange}
/>
);
}
export default SearchSelectDropdown;

View File

@@ -0,0 +1,109 @@
/**
* 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.
*/
/* eslint-disable import/no-extraneous-dependencies */
import { useState } from 'react';
import { Dropdown, Menu } from 'antd';
import { TableOutlined, DownOutlined, CheckOutlined } from '@ant-design/icons';
import { t } from '@superset-ui/core';
import { InfoText, ColumnLabel, CheckIconWrapper } from '../../styles';
interface ComparisonColumn {
key: string;
label: string;
}
interface TimeComparisonVisibilityProps {
comparisonColumns: ComparisonColumn[];
selectedComparisonColumns: string[];
onSelectionChange: (selectedColumns: string[]) => void;
}
const TimeComparisonVisibility: React.FC<TimeComparisonVisibilityProps> = ({
comparisonColumns,
selectedComparisonColumns,
onSelectionChange,
}) => {
const [showComparisonDropdown, setShowComparisonDropdown] = useState(false);
const allKey = comparisonColumns[0].key;
const handleOnClick = (data: any) => {
const { key } = data;
// Toggle 'All' key selection
if (key === allKey) {
onSelectionChange([allKey]);
} else if (selectedComparisonColumns.includes(allKey)) {
onSelectionChange([key]);
} else {
// Toggle selection for other keys
onSelectionChange(
selectedComparisonColumns.includes(key)
? selectedComparisonColumns.filter((k: string) => k !== key) // Deselect if already selected
: [...selectedComparisonColumns, key],
); // Select if not already selected
}
};
const handleOnBlur = () => {
if (selectedComparisonColumns.length === 3) {
onSelectionChange([comparisonColumns[0].key]);
}
};
return (
<Dropdown
placement="bottomRight"
visible={showComparisonDropdown}
onVisibleChange={(flag: boolean) => {
setShowComparisonDropdown(flag);
}}
overlay={
<Menu
multiple
onClick={handleOnClick}
onBlur={handleOnBlur}
selectedKeys={selectedComparisonColumns}
>
<InfoText>
{t(
'Select columns that will be displayed in the table. You can multiselect columns.',
)}
</InfoText>
{comparisonColumns.map((column: ComparisonColumn) => (
<Menu.Item key={column.key}>
<ColumnLabel>{column.label}</ColumnLabel>
<CheckIconWrapper>
{selectedComparisonColumns.includes(column.key) && (
<CheckOutlined />
)}
</CheckIconWrapper>
</Menu.Item>
))}
</Menu>
}
trigger={['click']}
>
<span>
<TableOutlined /> <DownOutlined />
</span>
</Dropdown>
);
};
export default TimeComparisonVisibility;

View File

@@ -0,0 +1,359 @@
/* 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 {
useCallback,
useMemo,
useRef,
memo,
useState,
ChangeEvent,
useEffect,
} from 'react';
import {
AllCommunityModule,
ClientSideRowModelModule,
type ColDef,
ModuleRegistry,
GridReadyEvent,
GridState,
CellClickedEvent,
IMenuActionParams,
themeQuartz,
} from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { type FunctionComponent } from 'react';
import { JsonObject, DataRecordValue, DataRecord } from '@superset-ui/core';
import { SearchOutlined } from '@ant-design/icons';
import { debounce, isEqual } from 'lodash';
import Pagination from './components/Pagination';
import SearchSelectDropdown from './components/SearchSelectDropdown';
import { SearchOption, SortByItem } from '../types';
import getInitialSortState, { shouldSort } from '../utils/getInitialSortState';
import { PAGE_SIZE_OPTIONS } from '../consts';
export interface AgGridTableProps {
gridTheme?: string;
isDarkMode?: boolean;
gridHeight?: number;
updateInterval?: number;
data?: any[];
onGridReady?: (params: GridReadyEvent) => void;
colDefsFromProps: any[];
includeSearch: boolean;
allowRearrangeColumns: boolean;
pagination: boolean;
pageSize: number;
serverPagination?: boolean;
rowCount?: number;
onServerPaginationChange: (pageNumber: number, pageSize: number) => void;
serverPaginationData: JsonObject;
onServerPageSizeChange: (pageSize: number) => void;
searchOptions: SearchOption[];
onSearchColChange: (searchCol: string) => void;
onSearchChange: (searchText: string) => void;
onSortChange: (sortBy: SortByItem[]) => void;
id: number;
percentMetrics: string[];
serverPageLength: number;
hasServerPageLengthChanged: boolean;
handleCrossFilter: (event: CellClickedEvent | IMenuActionParams) => void;
isActiveFilterValue: (key: string, val: DataRecordValue) => boolean;
renderTimeComparisonDropdown: () => JSX.Element | null;
cleanedTotals: DataRecord;
showTotals: boolean;
width: number;
}
ModuleRegistry.registerModules([AllCommunityModule, ClientSideRowModelModule]);
const isSearchFocused = new Map<string, boolean>();
const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
({
gridHeight,
data = [],
colDefsFromProps,
includeSearch,
allowRearrangeColumns,
pagination,
pageSize,
serverPagination,
rowCount,
onServerPaginationChange,
serverPaginationData,
onServerPageSizeChange,
searchOptions,
onSearchColChange,
onSearchChange,
onSortChange,
id,
percentMetrics,
serverPageLength,
hasServerPageLengthChanged,
handleCrossFilter,
isActiveFilterValue,
renderTimeComparisonDropdown,
cleanedTotals,
showTotals,
width,
}) => {
const gridRef = useRef<AgGridReact>(null);
const inputRef = useRef<HTMLInputElement>(null);
const rowData = useMemo(() => data, [data]);
const containerRef = useRef<HTMLDivElement>(null);
const searchId = `search-${id}`;
const gridInitialState: GridState = {
...(serverPagination && {
sort: {
sortModel: getInitialSortState(serverPaginationData?.sortBy || []),
},
}),
};
const defaultColDef = useMemo<ColDef>(
() => ({
flex: 1,
filter: true,
enableRowGroup: true,
enableValue: true,
sortable: true,
resizable: true,
minWidth: 100,
}),
[],
);
// Memoize container style
const containerStyles = useMemo(
() => ({
height: gridHeight,
width,
}),
[gridHeight, width],
);
const [quickFilterText, setQuickFilterText] = useState<string>();
const [searchValue, setSearchValue] = useState(
serverPaginationData?.searchText || '',
);
const debouncedSearch = useMemo(
() =>
debounce((value: string) => {
onSearchChange(value);
}, 500),
[onSearchChange],
);
useEffect(
() =>
// Cleanup debounced search
() => {
debouncedSearch.cancel();
},
[debouncedSearch],
);
useEffect(() => {
if (
serverPagination &&
isSearchFocused.get(searchId) &&
document.activeElement !== inputRef.current
) {
inputRef.current?.focus();
}
}, [searchValue, serverPagination, searchId]);
const handleSearchFocus = useCallback(() => {
isSearchFocused.set(searchId, true);
}, [searchId]);
const handleSearchBlur = useCallback(() => {
isSearchFocused.set(searchId, false);
}, [searchId]);
const onFilterTextBoxChanged = useCallback(
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
if (serverPagination) {
setSearchValue(value);
debouncedSearch(value);
} else {
setQuickFilterText(value);
}
},
[serverPagination, debouncedSearch, searchId],
);
const handleColSort = (colId: string, sortDir: string) => {
const isSortable = shouldSort({
colId,
sortDir,
percentMetrics,
serverPagination: !!serverPagination,
gridInitialState,
});
if (!isSortable) return;
if (sortDir == null) {
onSortChange([]);
return;
}
onSortChange([
{
id: colId,
key: colId,
desc: sortDir === 'desc',
},
]);
};
const handleColumnHeaderClick = useCallback(
params => {
const colId = params?.column?.colId;
const sortDir = params?.column?.sort;
handleColSort(colId, sortDir);
},
[serverPagination, gridInitialState, percentMetrics, onSortChange],
);
useEffect(() => {
if (
hasServerPageLengthChanged &&
serverPaginationData?.pageSize &&
!isEqual(serverPaginationData?.pageSize, serverPageLength)
) {
// Explore editor handling
// if user updates server page length from control panel &
// if server page length & ownState pageSize are not equal
// they must be resynced
onServerPageSizeChange(serverPageLength);
}
}, [hasServerPageLengthChanged]);
const onGridReady = (params: GridReadyEvent) => {
// This will make columns fill the grid width
params.api.sizeColumnsToFit();
};
return (
<div
className="ag-theme-quartz"
style={containerStyles}
ref={containerRef}
>
<div className="dropdown-controls-container">
{renderTimeComparisonDropdown && (
<div className="time-comparison-dropdown">
{renderTimeComparisonDropdown()}
</div>
)}
{includeSearch && (
<div className="search-container">
{serverPagination && (
<div className="search-by-text-container">
<span className="search-by-text"> Search by :</span>
<SearchSelectDropdown
onChange={onSearchColChange}
searchOptions={searchOptions}
value={serverPaginationData?.searchColumn || ''}
/>
</div>
)}
<div className="input-wrapper">
<div className="input-container">
<SearchOutlined />
<input
ref={inputRef}
value={
serverPagination ? searchValue : quickFilterText || ''
}
type="text"
id="filter-text-box"
placeholder="Search"
onInput={onFilterTextBoxChanged}
onFocus={handleSearchFocus}
onBlur={handleSearchBlur}
/>
</div>
</div>
</div>
)}
</div>
<AgGridReact
ref={gridRef}
onGridReady={onGridReady}
theme={themeQuartz}
className="ag-container"
rowData={rowData}
headerHeight={36}
rowHeight={30}
columnDefs={colDefsFromProps}
defaultColDef={defaultColDef}
onColumnGroupOpened={params => params.api.sizeColumnsToFit()}
rowSelection="multiple"
animateRows
onCellClicked={handleCrossFilter}
initialState={gridInitialState}
suppressAggFuncInHeader
rowGroupPanelShow="always"
enableCellTextSelection
quickFilterText={serverPagination ? '' : quickFilterText}
suppressMovableColumns={!allowRearrangeColumns}
pagination={pagination}
paginationPageSize={pageSize}
paginationPageSizeSelector={PAGE_SIZE_OPTIONS}
suppressDragLeaveHidesColumns
pinnedBottomRowData={showTotals ? [cleanedTotals] : undefined}
context={{
onColumnHeaderClicked: handleColumnHeaderClick,
initialSortState: getInitialSortState(
serverPaginationData?.sortBy || [],
),
isActiveFilterValue,
}}
/>
{serverPagination && (
<Pagination
currentPage={serverPaginationData?.currentPage || 0}
pageSize={
hasServerPageLengthChanged
? serverPageLength
: serverPaginationData?.pageSize || 10
}
totalRows={rowCount || 0}
pageSizeOptions={[10, 20, 50, 100, 200]}
onServerPaginationChange={onServerPaginationChange}
onServerPageSizeChange={onServerPageSizeChange}
sliceId={id}
/>
)}
</div>
);
},
);
AgGridDataTable.displayName = 'AgGridDataTable';
export default memo(AgGridDataTable);

View File

@@ -0,0 +1,292 @@
/**
* 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 {
DataRecord,
DataRecordValue,
GenericDataType,
getTimeFormatterForGranularity,
t,
} from '@superset-ui/core';
import { useCallback, useEffect, useState, useMemo } from 'react';
import { isEqual } from 'lodash';
import { CellClickedEvent, IMenuActionParams } from 'ag-grid-community';
import {
AgGridTableChartTransformedProps,
InputColumn,
SearchOption,
SortByItem,
} from './types';
import AgGridDataTable from './AgGridTable';
import { updateTableOwnState } from './utils/externalAPIs';
import TimeComparisonVisibility from './AgGridTable/components/TimeComparisonVisibility';
import { useColDefs } from './utils/useColDefs';
import { getCrossFilterDataMask } from './utils/getCrossFilterDataMask';
import { StyledChartContainer } from './styles';
const getGridHeight = (height: number, includeSearch: boolean | undefined) => {
let calculatedGridHeight = height;
if (includeSearch) {
calculatedGridHeight -= 16;
}
return calculatedGridHeight - 80;
};
export default function TableChart<D extends DataRecord = DataRecord>(
props: AgGridTableChartTransformedProps<D> & {},
) {
const {
height,
columns,
data,
includeSearch,
allowRearrangeColumns,
pageSize,
serverPagination,
rowCount,
setDataMask,
serverPaginationData,
slice_id,
percentMetrics,
hasServerPageLengthChanged,
serverPageLength,
emitCrossFilters,
filters,
timeGrain,
isRawRecords,
alignPositiveNegative,
showCellBars,
isUsingTimeComparison,
colorPositiveNegative,
totals,
showTotals,
columnColorFormatters,
basicColorFormatters,
width,
} = props;
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
useEffect(() => {
const options = columns
.filter(col => col?.dataType === GenericDataType.String)
.map(column => ({
value: column.key,
label: column.label,
}));
if (!isEqual(options, searchOptions)) {
setSearchOptions(options || []);
}
}, [columns]);
const comparisonColumns = [
{ key: 'all', label: t('Display all') },
{ key: '#', label: '#' },
{ key: '△', label: '△' },
{ key: '%', label: '%' },
];
const [selectedComparisonColumns, setSelectedComparisonColumns] = useState([
comparisonColumns?.[0]?.key,
]);
const filteredColumns = useMemo(() => {
if (!isUsingTimeComparison) {
return columns;
}
if (
selectedComparisonColumns.length === 0 ||
selectedComparisonColumns.includes('all')
) {
return columns?.filter(col => col?.config?.visible !== false);
}
return columns
.filter(
col =>
!col.originalLabel ||
(col?.label || '').includes('Main') ||
selectedComparisonColumns.includes(col.label),
)
.filter(col => col?.config?.visible !== false);
}, [columns, selectedComparisonColumns]);
const colDefs = useColDefs({
columns: isUsingTimeComparison
? (filteredColumns as InputColumn[])
: (columns as InputColumn[]),
data,
serverPagination,
isRawRecords,
defaultAlignPN: alignPositiveNegative,
showCellBars,
colorPositiveNegative,
totals,
columnColorFormatters,
allowRearrangeColumns,
basicColorFormatters,
isUsingTimeComparison,
emitCrossFilters,
alignPositiveNegative,
slice_id,
});
const gridHeight = getGridHeight(height, includeSearch);
const isActiveFilterValue = useCallback(
function isActiveFilterValue(key: string, val: DataRecordValue) {
return !!filters && filters[key]?.includes(val);
},
[filters],
);
const timestampFormatter = useCallback(
value => getTimeFormatterForGranularity(timeGrain)(value),
[timeGrain],
);
const toggleFilter = useCallback(
(event: CellClickedEvent | IMenuActionParams) => {
if (
emitCrossFilters &&
event.column &&
!(
event.column.getColDef().context?.isMetric ||
event.column.getColDef().context?.isPercentMetric
)
) {
const crossFilterProps = {
key: event.column.getColId(),
value: event.value,
filters,
timeGrain,
isActiveFilterValue,
timestampFormatter,
};
setDataMask(getCrossFilterDataMask(crossFilterProps).dataMask);
}
},
[emitCrossFilters, setDataMask, filters, timeGrain],
);
const handleServerPaginationChange = useCallback(
(pageNumber: number, pageSize: number) => {
const modifiedOwnState = {
...serverPaginationData,
currentPage: pageNumber,
pageSize,
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[setDataMask],
);
const handlePageSizeChange = useCallback(
(pageSize: number) => {
const modifiedOwnState = {
...serverPaginationData,
currentPage: 0,
pageSize,
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[setDataMask],
);
const handleChangeSearchCol = (searchCol: string) => {
if (!isEqual(searchCol, serverPaginationData?.searchColumn)) {
const modifiedOwnState = {
...(serverPaginationData || {}),
searchColumn: searchCol,
searchText: '',
};
updateTableOwnState(setDataMask, modifiedOwnState);
}
};
const handleSearch = useCallback(
(searchText: string) => {
const modifiedOwnState = {
...(serverPaginationData || {}),
searchColumn:
serverPaginationData?.searchColumn || searchOptions[0]?.value,
searchText,
currentPage: 0, // Reset to first page when searching
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[setDataMask, searchOptions],
);
const handleSortByChange = useCallback(
(sortBy: SortByItem[]) => {
if (!serverPagination) return;
const modifiedOwnState = {
...serverPaginationData,
sortBy,
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[setDataMask, serverPagination],
);
const renderTimeComparisonVisibility = (): JSX.Element => (
<TimeComparisonVisibility
comparisonColumns={comparisonColumns}
selectedComparisonColumns={selectedComparisonColumns}
onSelectionChange={setSelectedComparisonColumns}
/>
);
return (
<StyledChartContainer height={height}>
<AgGridDataTable
gridHeight={gridHeight}
data={data || []}
colDefsFromProps={colDefs}
includeSearch={!!includeSearch}
allowRearrangeColumns={!!allowRearrangeColumns}
pagination={!!pageSize && !serverPagination}
pageSize={pageSize || 0}
serverPagination={serverPagination}
rowCount={rowCount}
onServerPaginationChange={handleServerPaginationChange}
onServerPageSizeChange={handlePageSizeChange}
serverPaginationData={serverPaginationData}
searchOptions={searchOptions}
onSearchColChange={handleChangeSearchCol}
onSearchChange={handleSearch}
onSortChange={handleSortByChange}
id={slice_id}
handleCrossFilter={toggleFilter}
percentMetrics={percentMetrics}
serverPageLength={serverPageLength}
hasServerPageLengthChanged={hasServerPageLengthChanged}
isActiveFilterValue={isActiveFilterValue}
renderTimeComparisonDropdown={
isUsingTimeComparison ? renderTimeComparisonVisibility : () => null
}
cleanedTotals={totals || {}}
showTotals={showTotals}
width={width}
/>
</StyledChartContainer>
);
}

View File

@@ -0,0 +1,340 @@
/**
* 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 {
AdhocColumn,
buildQueryContext,
ensureIsArray,
getMetricLabel,
isPhysicalColumn,
QueryFormOrderBy,
QueryMode,
QueryObject,
removeDuplicates,
PostProcessingRule,
BuildQuery,
} from '@superset-ui/core';
import {
isTimeComparison,
timeCompareOperator,
} from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash';
import { TableChartFormData } from './types';
import { updateTableOwnState } from './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,
extra_form_data,
} = formData;
const queryMode = getQueryMode(formData);
const sortByMetric = ensureIsArray(formData.timeseries_limit_metric)[0];
const time_grain_sqla =
extra_form_data?.time_grain_sqla || formData.time_grain_sqla;
let formDataCopy = formData;
// never include time in raw records mode
if (queryMode === QueryMode.Raw) {
formDataCopy = {
...formData,
include_time: false,
};
}
const addComparisonPercentMetrics = (metrics: string[], suffixes: string[]) =>
metrics.reduce<string[]>((acc, metric) => {
const newMetrics = suffixes.map(suffix => `${metric}__${suffix}`);
return acc.concat([metric, ...newMetrics]);
}, []);
return buildQueryContext(formDataCopy, baseQueryObject => {
let { metrics, orderby = [], columns = [] } = baseQueryObject;
const { extras = {} } = baseQueryObject;
let postProcessing: PostProcessingRule[] = [];
const nonCustomNorInheritShifts = ensureIsArray(
formData.time_compare,
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
const customOrInheritShifts = ensureIsArray(formData.time_compare).filter(
(shift: string) => shift === 'custom' || shift === 'inherit',
);
let timeOffsets: string[] = [];
// Shifts for non-custom or non inherit time comparison
if (
isTimeComparison(formData, baseQueryObject) &&
!isEmpty(nonCustomNorInheritShifts)
) {
timeOffsets = nonCustomNorInheritShifts;
}
// Shifts for custom or inherit time comparison
if (
isTimeComparison(formData, baseQueryObject) &&
!isEmpty(customOrInheritShifts)
) {
if (customOrInheritShifts.includes('custom')) {
timeOffsets = timeOffsets.concat([formData.start_date_offset]);
}
if (customOrInheritShifts.includes('inherit')) {
timeOffsets = timeOffsets.concat(['inherit']);
}
}
let temporalColumnAdded = false;
let temporalColumn = null;
if (queryMode === QueryMode.Aggregate) {
metrics = metrics || [];
// override 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 (regardless 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 percentMetricsLabelsWithTimeComparison = isTimeComparison(
formData,
baseQueryObject,
)
? addComparisonPercentMetrics(
percentMetrics.map(getMetricLabel),
timeOffsets,
)
: percentMetrics.map(getMetricLabel);
const percentMetricLabels = removeDuplicates(
percentMetricsLabelsWithTimeComparison,
);
metrics = removeDuplicates(
metrics.concat(percentMetrics),
getMetricLabel,
);
postProcessing = [
{
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(x => `%${x}`),
},
},
];
}
// Add the operator for the time comparison if some is selected
if (!isEmpty(timeOffsets)) {
postProcessing.push(timeCompareOperator(formData, baseQueryObject));
}
const temporalColumnsLookup = formData?.temporal_columns_lookup;
// Filter out the column if needed and prepare the temporal column object
columns = columns.filter(col => {
const shouldBeAdded =
isPhysicalColumn(col) &&
time_grain_sqla &&
temporalColumnsLookup?.[col];
if (shouldBeAdded && !temporalColumnAdded) {
temporalColumn = {
timeGrain: time_grain_sqla,
columnType: 'BASE_AXIS',
sqlExpression: col,
label: col,
expressionType: 'SQL',
} as AdhocColumn;
temporalColumnAdded = true;
return false; // Do not include this in the output; it's added separately
}
return true;
});
// So we ensure the temporal column is added first
if (temporalColumn) {
columns = [temporalColumn, ...columns];
}
}
const moreProps: Partial<QueryObject> = {};
const ownState = options?.ownState ?? {};
// Build Query flag to check if its for either download as csv, excel or json
const isDownloadQuery =
['csv', 'xlsx'].includes(formData?.result_format || '') ||
(formData?.result_format === 'json' &&
formData?.result_type === 'results');
if (isDownloadQuery) {
moreProps.row_limit = Number(formDataCopy.row_limit) || 0;
moreProps.row_offset = 0;
}
if (!isDownloadQuery && formDataCopy.server_pagination) {
const pageSize = ownState.pageSize ?? formDataCopy.server_page_length;
const currentPage = ownState.currentPage ?? 0;
moreProps.row_limit = pageSize;
moreProps.row_offset = currentPage * pageSize;
}
// getting sort by in case of server pagination from own state
let sortByFromOwnState: QueryFormOrderBy[] | undefined;
if (Array.isArray(ownState?.sortBy) && ownState?.sortBy.length > 0) {
const sortByItem = ownState?.sortBy[0];
sortByFromOwnState = [[sortByItem?.key, !sortByItem?.desc]];
}
let queryObject = {
...baseQueryObject,
columns,
extras,
orderby:
formData.server_pagination && sortByFromOwnState
? sortByFromOwnState
: orderby,
metrics,
post_processing: postProcessing,
time_offsets: timeOffsets,
...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 };
const modifiedOwnState = {
...(options?.ownState || {}),
currentPage: 0,
pageSize: queryObject.row_limit ?? 0,
};
updateTableOwnState(options?.hooks?.setDataMask, modifiedOwnState);
}
// 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: [],
order_desc: undefined, // we don't need orderby stuff here,
orderby: undefined, // because this query will be used for get total aggregation.
});
}
const interactiveGroupBy = formData.extra_form_data?.interactive_groupby;
if (interactiveGroupBy && queryObject.columns) {
queryObject.columns = [
...new Set([...queryObject.columns, ...interactiveGroupBy]),
];
}
if (formData.server_pagination) {
// Add search filter if search text exists
if (ownState.searchText && ownState?.searchColumn) {
queryObject = {
...queryObject,
filters: [
...(queryObject.filters || []),
{
col: ownState?.searchColumn,
op: 'ILIKE',
val: `${ownState.searchText}%`,
},
],
};
}
}
// Now since row limit control is always visible even
// in case of server pagination
// we must use row limit from form data
if (formData.server_pagination && !isDownloadQuery) {
return [
{ ...queryObject },
{
...queryObject,
time_offsets: [],
row_limit: Number(formData?.row_limit) ?? 0,
row_offset: 0,
post_processing: [],
is_rowcount: true,
},
...extraQueries,
];
}
return [queryObject, ...extraQueries];
});
};
// Use this closure to cache changing of external filters, if we have server pagination we need reset page to 0, after
// external filter changed
export const cachedBuildQuery = (): BuildQuery<TableChartFormData> => {
let cachedChanges: any = {};
const setCachedChanges = (newChanges: any) => {
cachedChanges = { ...cachedChanges, ...newChanges };
};
return (formData, options) =>
buildQuery(
{ ...formData },
{
extras: { cachedChanges },
ownState: options?.ownState ?? {},
hooks: {
...options?.hooks,
setDataMask: () => {},
setCachedChanges,
},
},
);
};
export default cachedBuildQuery();

View File

@@ -0,0 +1,33 @@
/**
* 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 } from '@superset-ui/core';
import i18n from './i18n';
addLocaleData(i18n);
export const SERVER_PAGE_SIZE_OPTIONS = formatSelectOptions<number>([
10, 20, 50, 100, 200,
]);
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100, 200];
export const CUSTOM_AGG_FUNCS = {
queryTotal: 'Metric total',
};

View File

@@ -0,0 +1,753 @@
/* 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 {
ColumnMeta,
ColumnOption,
ControlConfig,
ControlPanelConfig,
ControlPanelsContainerProps,
ControlPanelState,
ControlState,
ControlStateMapping,
D3_TIME_FORMAT_OPTIONS,
Dataset,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
defineSavedMetrics,
formatSelectOptions,
getStandardizedControls,
QueryModeLabel,
sections,
sharedControls,
} from '@superset-ui/chart-controls';
import {
ensureIsArray,
FeatureFlag,
GenericDataType,
getMetricLabel,
isAdhocColumn,
isFeatureEnabled,
isPhysicalColumn,
legacyValidateInteger,
QueryFormColumn,
QueryFormMetric,
QueryMode,
SMART_DATE_ID,
t,
validateMaxValue,
validateServerPagination,
} from '@superset-ui/core';
import { isEmpty, last } from 'lodash';
import { PAGE_SIZE_OPTIONS, SERVER_PAGE_SIZE_OPTIONS } from './consts';
import { ColorSchemeEnum } from './types';
/**
* Generate comparison column names for a given column.
*/
const generateComparisonColumns = (colname: string) => [
`${t('Main')} ${colname}`,
`# ${colname}`,
`${colname}`,
`% ${colname}`,
];
/**
* Generate column types for the comparison columns.
*/
const generateComparisonColumnTypes = (count: number) =>
Array(count).fill(GenericDataType.Numeric);
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;
}
const processComparisonColumns = (columns: any[], suffix: string) =>
columns
.map(col => {
if (!col.label.includes(suffix)) {
return [
{
label: `${t('Main')} ${col.label}`,
value: `${t('Main')} ${col.value}`,
},
{
label: `# ${col.label}`,
value: `# ${col.value}`,
},
{
label: `${col.label}`,
value: `${col.value}`,
},
{
label: `% ${col.label}`,
value: `% ${col.value}`,
},
];
}
return [];
})
.flat();
/**
* 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 allColumnsControl: typeof sharedControls.groupby = {
...sharedControls.groupby,
label: t('Columns'),
description: t('Columns to display'),
multi: true,
freeForm: true,
allowAll: true,
commaChoosesOption: false,
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,
resetOnHide: false,
};
const percentMetricsControl: typeof sharedControls.metrics = {
...sharedControls.metrics,
label: t('Percentage metrics'),
description: t(
'Select one or many metrics to display, that will be displayed in the percentages of total. ' +
'Percentage metrics will be calculated only from data within the row limit. ' +
'You can use an aggregation function on a column or write custom SQL to create a percentage metric.',
),
visibility: isAggMode,
resetOnHide: false,
mapStateToProps: ({ datasource, controls }, controlState) => ({
columns: datasource?.columns || [],
savedMetrics: defineSavedMetrics(datasource),
datasource,
datasourceType: datasource?.type,
queryMode: getQueryMode(controls),
externalValidationErrors: validateAggControlValues(controls, [
controls.groupby?.value,
controls.metrics?.value,
controlState?.value,
]),
}),
rerender: ['groupby', 'metrics'],
default: [],
validators: [],
};
/*
Options for row limit control
*/
export const ROW_LIMIT_OPTIONS_TABLE = [
10, 50, 100, 250, 500, 1000, 5000, 10000, 50000, 100000, 150000, 200000,
250000, 300000, 350000, 400000, 450000, 500000,
];
const config: ControlPanelConfig = {
controlPanelSections: [
{
label: t('Query'),
expanded: true,
controlSetRows: [
[
{
name: 'query_mode',
config: queryMode,
},
],
[
{
name: 'groupby',
override: {
visibility: isAggMode,
resetOnHide: false,
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, controlState.value],
);
return newState;
},
rerender: ['metrics', 'percent_metrics'],
},
},
],
[
{
name: 'time_grain_sqla',
config: {
...sharedControls.time_grain_sqla,
visibility: ({ controls }) => {
const dttmLookup = Object.fromEntries(
ensureIsArray(controls?.groupby?.options).map(option => [
option.column_name,
option.is_dttm,
]),
);
return ensureIsArray(controls?.groupby.value)
.map(selection => {
if (isAdhocColumn(selection)) {
return true;
}
if (isPhysicalColumn(selection)) {
return !!dttmLookup[selection];
}
return false;
})
.some(Boolean);
},
},
},
'temporal_columns_lookup',
],
[
{
name: 'metrics',
override: {
validators: [],
visibility: isAggMode,
resetOnHide: false,
mapStateToProps: (
{ controls, datasource, form_data }: ControlPanelState,
controlState: ControlState,
) => ({
columns: datasource?.columns[0]?.hasOwnProperty('filterable')
? (datasource as Dataset)?.columns?.filter(
(c: ColumnMeta) => c.filterable,
)
: datasource?.columns,
savedMetrics: defineSavedMetrics(datasource),
// current active adhoc metrics
selectedMetrics:
form_data.metrics ||
(form_data.metric ? [form_data.metric] : []),
datasource,
externalValidationErrors: validateAggControlValues(controls, [
controls.groupby?.value,
controlState.value,
]),
}),
rerender: ['groupby'],
},
},
{
name: 'all_columns',
config: allColumnsControl,
},
],
[
{
name: 'percent_metrics',
config: percentMetricsControl,
},
],
['adhoc_filters'],
[
{
name: 'timeseries_limit_metric',
override: {
visibility: isAggMode,
resetOnHide: false,
},
},
{
name: 'order_by_cols',
config: {
type: 'SelectControl',
label: t('Ordering'),
description: t('Order results by selected columns'),
multi: true,
default: [],
mapStateToProps: ({ datasource }) => ({
choices: datasource?.hasOwnProperty('order_by_choices')
? (datasource as Dataset)?.order_by_choices
: datasource?.columns || [],
}),
visibility: isRawMode,
resetOnHide: false,
},
},
],
[
{
name: 'order_desc',
config: {
type: 'CheckboxControl',
label: t('Sort descending'),
default: true,
description: t(
'If enabled, this control sorts the results/values descending, otherwise it sorts the results ascending.',
),
visibility: ({ controls }: ControlPanelsContainerProps) => {
const hasSortMetric = Boolean(
controls?.timeseries_limit_metric?.value,
);
return hasSortMetric && isAggMode({ controls });
},
resetOnHide: false,
},
},
],
[
{
name: 'server_pagination',
config: {
type: 'CheckboxControl',
label: t('Server pagination'),
description: t(
'Enable server side pagination of results (experimental feature)',
),
default: false,
},
},
],
[
{
name: 'server_page_length',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Server Page Length'),
default: 10,
choices: SERVER_PAGE_SIZE_OPTIONS,
description: t('Rows per page, 0 means no pagination'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.server_pagination?.value),
},
},
],
[
{
name: 'row_limit',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Row limit'),
clearable: false,
mapStateToProps: state => ({
maxValue: state?.common?.conf?.TABLE_VIZ_MAX_ROW_SERVER,
server_pagination: state?.form_data?.server_pagination,
maxValueWithoutServerPagination:
state?.common?.conf?.SQL_MAX_ROW,
}),
validators: [
legacyValidateInteger,
(v, state) =>
validateMaxValue(
v,
state?.maxValue || DEFAULT_MAX_ROW_TABLE_SERVER,
),
(v, state) =>
validateServerPagination(
v,
state?.server_pagination,
state?.maxValueWithoutServerPagination || DEFAULT_MAX_ROW,
state?.maxValue || DEFAULT_MAX_ROW_TABLE_SERVER,
),
],
// Re run the validations when this control value
validationDependancies: ['server_pagination'],
default: 10000,
choices: formatSelectOptions(ROW_LIMIT_OPTIONS_TABLE),
description: t(
'Limits the number of the rows that are computed in the query that is the source of the data used for this chart.',
),
},
override: {
default: 1000,
},
},
],
[
{
name: 'show_totals',
config: {
type: 'CheckboxControl',
label: t('Show summary'),
default: false,
description: t(
'Show total aggregations of selected metrics. Note that row limit does not apply to the result.',
),
visibility: isAggMode,
resetOnHide: false,
},
},
],
],
},
{
label: t('Options'),
expanded: true,
controlSetRows: [
[
{
name: 'table_timestamp_format',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Timestamp format'),
default: SMART_DATE_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: 'column_config',
config: {
type: 'ColumnConfigControl',
label: t('Customize columns'),
description: t('Further customize how to display each column'),
width: 400,
height: 320,
renderTrigger: true,
shouldMapStateToProps() {
return true;
},
mapStateToProps(explore, _, chart) {
const timeComparisonValue =
explore?.controls?.time_compare?.value;
const { colnames: _colnames, coltypes: _coltypes } =
chart?.queriesResponse?.[0] ?? {};
let colnames: string[] = _colnames || [];
let coltypes: GenericDataType[] = _coltypes || [];
const childColumnMap: Record<string, boolean> = {};
const timeComparisonColumnMap: Record<string, boolean> = {};
if (!isEmpty(timeComparisonValue)) {
/**
* Replace numeric columns with sets of comparison columns.
*/
const updatedColnames: string[] = [];
const updatedColtypes: GenericDataType[] = [];
colnames
.filter(
colname =>
last(colname.split('__')) !== timeComparisonValue,
)
.forEach((colname, index) => {
if (
explore.form_data.metrics?.some(
metric => getMetricLabel(metric) === colname,
) ||
explore.form_data.percent_metrics?.some(
(metric: QueryFormMetric) =>
getMetricLabel(metric) === colname,
)
) {
const comparisonColumns =
generateComparisonColumns(colname);
comparisonColumns.forEach((name, idx) => {
updatedColnames.push(name);
updatedColtypes.push(
...generateComparisonColumnTypes(4),
);
timeComparisonColumnMap[name] = true;
if (idx === 0 && name.startsWith('Main ')) {
childColumnMap[name] = false;
} else {
childColumnMap[name] = true;
}
});
} else {
updatedColnames.push(colname);
updatedColtypes.push(coltypes[index]);
childColumnMap[colname] = false;
timeComparisonColumnMap[colname] = false;
}
});
colnames = updatedColnames;
coltypes = updatedColtypes;
}
return {
columnsPropsObject: {
colnames,
coltypes,
childColumnMap,
timeComparisonColumnMap,
},
};
},
},
},
],
],
},
{
label: t('Visual formatting'),
expanded: true,
controlSetRows: [
[
{
name: 'show_cell_bars',
config: {
type: 'CheckboxControl',
label: t('Show 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('add colors to cell bars for +/-'),
renderTrigger: true,
default: true,
description: t(
'Whether to colorize numeric values by whether they are positive or negative',
),
},
},
],
[
{
name: 'comparison_color_enabled',
config: {
type: 'CheckboxControl',
label: t('basic conditional formatting'),
renderTrigger: true,
visibility: ({ controls }) =>
!isEmpty(controls?.time_compare?.value),
default: false,
description: t(
'This will be applied to the whole table. Arrows (↑ and ↓) will be added to ' +
'main columns for increase and decrease. Basic conditional formatting can be ' +
'overwritten by conditional formatting below.',
),
},
},
],
[
{
name: 'comparison_color_scheme',
config: {
type: 'SelectControl',
label: t('color type'),
default: ColorSchemeEnum.Green,
renderTrigger: true,
choices: [
[ColorSchemeEnum.Green, 'Green for increase, red for decrease'],
[ColorSchemeEnum.Red, 'Red for increase, green for decrease'],
],
visibility: ({ controls }) =>
!isEmpty(controls?.time_compare?.value) &&
Boolean(controls?.comparison_color_enabled?.value),
description: t(
'Adds color to the chart symbols based on the positive or ' +
'negative change from the comparison value.',
),
},
},
],
[
{
name: 'conditional_formatting',
config: {
type: 'ConditionalFormattingControl',
renderTrigger: true,
label: t('Custom Conditional Formatting'),
extraColorChoices: [
{
value: ColorSchemeEnum.Green,
label: t('Green for increase, red for decrease'),
},
{
value: ColorSchemeEnum.Red,
label: t('Red for increase, green for decrease'),
},
],
description: t(
'Apply conditional color formatting to numeric columns',
),
shouldMapStateToProps() {
return true;
},
mapStateToProps(explore, _, chart) {
const verboseMap = explore?.datasource?.hasOwnProperty(
'verbose_map',
)
? (explore?.datasource as Dataset)?.verbose_map
: (explore?.datasource?.columns ?? {});
const chartStatus = chart?.chartStatus;
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: string) => ({
value: colname,
label: Array.isArray(verboseMap)
? colname
: (verboseMap[colname] ?? colname),
}))
: [];
const columnOptions = explore?.controls?.time_compare?.value
? processComparisonColumns(
numericColumns || [],
ensureIsArray(
explore?.controls?.time_compare?.value,
)[0]?.toString() || '',
)
: numericColumns;
return {
removeIrrelevantConditions: chartStatus === 'success',
columnOptions,
verboseMap,
};
},
},
},
],
],
},
{
...sections.timeComparisonControls({
multi: false,
showCalculationType: false,
showFullChoices: false,
}),
visibility: ({ controls }) =>
isAggMode({ controls }) &&
isFeatureEnabled(FeatureFlag.TableV2TimeComparisonEnabled),
},
],
formDataOverrides: formData => ({
...formData,
metrics: getStandardizedControls().popAllMetrics(),
groupby: getStandardizedControls().popAllColumns(),
}),
};
export default config;

View File

@@ -0,0 +1,66 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locale } from '@superset-ui/core';
const en = {
'Query Mode': [''],
Aggregate: [''],
'Raw Records': [''],
'Emit Filter Events': [''],
'Show Cell Bars': [''],
'page_size.show': ['Show'],
'page_size.all': ['All'],
'page_size.entries': ['entries'],
'table.previous_page': ['Previous'],
'table.next_page': ['Next'],
'search.num_records': ['%s record', '%s records...'],
};
const translations: Partial<Record<Locale, typeof en>> = {
en,
fr: {
'Query Mode': [''],
Aggregate: [''],
'Raw Records': [''],
'Emit Filter Events': [''],
'Show Cell Bars': [''],
'page_size.show': ['Afficher'],
'page_size.all': ['tous'],
'page_size.entries': ['entrées'],
'table.previous_page': ['Précédent'],
'table.next_page': ['Suivante'],
'search.num_records': ['%s enregistrement', '%s enregistrements...'],
},
zh: {
'Query Mode': ['查询模式'],
Aggregate: ['分组聚合'],
'Raw Records': ['原始数据'],
'Emit Filter Events': ['关联看板过滤器'],
'Show Cell Bars': ['为指标添加条状图背景'],
'page_size.show': ['每页显示'],
'page_size.all': ['全部'],
'page_size.entries': ['条'],
'table.previous_page': ['上一页'],
'table.next_page': ['下一页'],
'search.num_records': ['%s条记录...'],
},
};
export default translations;

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,71 @@
/**
* 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 { Behavior, ChartMetadata, ChartPlugin, t } 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.InteractiveChart,
Behavior.DrillToDetail,
Behavior.DrillBy,
],
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 V2'),
tags: [
t('Additive'),
t('Business'),
t('Pattern'),
t('Featured'),
t('Report'),
t('Sequential'),
t('Tabular'),
],
thumbnail,
});
export default class AgGridTableChartPlugin extends ChartPlugin<
TableChartFormData,
TableChartProps
> {
constructor() {
super({
loadChart: () => import('./AgGridTableChart'),
metadata,
transformProps,
controlPanel,
buildQuery,
});
}
}

View File

@@ -0,0 +1,206 @@
/**
* 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';
import { CustomCellRendererProps } from 'ag-grid-react';
import { BasicColorFormatterType, InputColumn } from '../types';
import { useIsDark } from '../utils/useTableTheme';
const StyledTotalCell = styled.div`
${() => `
font-weight: bold;
`}
`;
const CellContainer = styled.div<{ backgroundColor?: string; align?: string }>`
display: flex;
background-color: ${({ backgroundColor }) =>
backgroundColor || 'transparent'};
justify-content: ${({ align }) => align || 'left'};
`;
const ArrowContainer = styled.div<{ arrowColor?: string }>`
margin-right: 10px;
color: ${({ arrowColor }) => arrowColor || 'inherit'};
`;
const Bar = styled.div<{
offset: number;
percentage: number;
background: string;
}>`
position: absolute;
left: ${({ offset }) => `${offset}%`};
top: 0;
height: 100%;
width: ${({ percentage }) => `${percentage}%`};
background-color: ${({ background }) => background};
z-index: 1;
`;
type ValueRange = [number, number];
/**
* Cell background width calculation for horizontal bar chart
*/
function cellWidth({
value,
valueRange,
alignPositiveNegative,
}: {
value: number;
valueRange: ValueRange;
alignPositiveNegative: boolean;
}) {
const [minValue, maxValue] = valueRange;
if (alignPositiveNegative) {
const perc = Math.abs(Math.round((value / maxValue) * 100));
return perc;
}
const posExtent = Math.abs(Math.max(maxValue, 0));
const negExtent = Math.abs(Math.min(minValue, 0));
const tot = posExtent + negExtent;
const perc2 = Math.round((Math.abs(value) / tot) * 100);
return perc2;
}
/**
* Cell left margin (offset) calculation for horizontal bar chart elements
* when alignPositiveNegative is not set
*/
function cellOffset({
value,
valueRange,
alignPositiveNegative,
}: {
value: number;
valueRange: ValueRange;
alignPositiveNegative: boolean;
}) {
if (alignPositiveNegative) {
return 0;
}
const [minValue, maxValue] = valueRange;
const posExtent = Math.abs(Math.max(maxValue, 0));
const negExtent = Math.abs(Math.min(minValue, 0));
const tot = posExtent + negExtent;
return Math.round((Math.min(negExtent + value, negExtent) / tot) * 100);
}
/**
* Cell background color calculation for horizontal bar chart
*/
function cellBackground({
value,
colorPositiveNegative = false,
isDarkTheme = false,
}: {
value: number;
colorPositiveNegative: boolean;
isDarkTheme: boolean;
}) {
if (!colorPositiveNegative) {
return isDarkTheme ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'; // transparent or neutral
}
const r = value < 0 ? 150 : 0;
const g = value >= 0 ? 150 : 0;
return `rgba(${r},${g},0,0.2)`;
}
export const NumericCellRenderer = (
params: CustomCellRendererProps & {
allowRenderHtml: boolean;
columns: InputColumn[];
hasBasicColorFormatters: boolean | undefined;
col: InputColumn;
basicColorFormatters: {
[Key: string]: BasicColorFormatterType;
}[];
valueRange: any;
alignPositiveNegative: boolean;
colorPositiveNegative: boolean;
},
) => {
const {
value,
valueFormatted,
node,
hasBasicColorFormatters,
col,
basicColorFormatters,
valueRange,
alignPositiveNegative,
colorPositiveNegative,
} = params;
const isDarkTheme = useIsDark();
if (node?.rowPinned === 'bottom') {
return <StyledTotalCell>{valueFormatted ?? value}</StyledTotalCell>;
}
let arrow = '';
let arrowColor = '';
if (hasBasicColorFormatters && col?.metricName) {
arrow =
basicColorFormatters?.[node?.rowIndex as number]?.[col.metricName]
?.mainArrow;
arrowColor =
basicColorFormatters?.[node?.rowIndex as number]?.[
col.metricName
]?.arrowColor?.toLowerCase();
}
const alignment =
col?.config?.horizontalAlign || (col?.isNumeric ? 'right' : 'left');
if (!valueRange) {
return (
<CellContainer align={alignment}>
{arrow && (
<ArrowContainer arrowColor={arrowColor}>{arrow}</ArrowContainer>
)}
<div>{valueFormatted ?? value}</div>
</CellContainer>
);
}
const CellWidth = cellWidth({
value: value as number,
valueRange,
alignPositiveNegative,
});
const CellOffset = cellOffset({
value: value as number,
valueRange,
alignPositiveNegative,
});
const background = cellBackground({
value: value as number,
colorPositiveNegative,
isDarkTheme,
});
return (
<div>
<Bar offset={CellOffset} percentage={CellWidth} background={background} />
{valueFormatted ?? value}
</div>
);
};

View File

@@ -0,0 +1,71 @@
/* 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 { isProbablyHTML, sanitizeHtml, t } from '@superset-ui/core';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tooltip } from '@superset-ui/core/components';
import { CellRendererProps } from '../types';
import { SummaryContainer, SummaryText } from '../styles';
const SUMMARY_TOOLTIP_TEXT = t(
'Show total aggregations of selected metrics. Note that row limit does not apply to the result.',
);
export const TextCellRenderer = (params: CellRendererProps) => {
const { node, api, colDef, columns, allowRenderHtml, value, valueFormatted } =
params;
if (node?.rowPinned === 'bottom') {
const cols = api.getAllGridColumns().filter(col => col.isVisible());
const colAggCheck = !cols[0].getAggFunc();
if (cols.length > 1 && colAggCheck && columns[0].key === colDef?.field) {
return (
<SummaryContainer>
<SummaryText>{t('Summary')}</SummaryText>
<Tooltip overlay={SUMMARY_TOOLTIP_TEXT}>
<InfoCircleOutlined />
</Tooltip>
</SummaryContainer>
);
}
if (!value) {
return null;
}
}
if (!(typeof value === 'string' || value instanceof Date)) {
return valueFormatted ?? value;
}
if (typeof value === 'string') {
if (value.startsWith('http://') || value.startsWith('https://')) {
return (
<a href={value} target="_blank" rel="noopener noreferrer">
{value}
</a>
);
}
if (allowRenderHtml && isProbablyHTML(value)) {
return <div dangerouslySetInnerHTML={{ __html: sanitizeHtml(value) }} />;
}
}
return <div>{valueFormatted ?? value}</div>;
};

View File

@@ -0,0 +1,405 @@
/**
* 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 { css, styled } from '@superset-ui/core';
import { Select } from '@superset-ui/core/components';
/* Components for AgGridTable */
// Header Styles
export const Container = styled.div`
${({ theme }) => `
display: flex;
width: 100%;
.three-dots-menu {
align-self: center;
margin-left: ${theme.sizeUnit}px;
cursor: pointer;
padding: ${theme.sizeUnit / 2}px;
border-radius: ${theme.borderRadius}px;
margin-top: ${theme.sizeUnit * 0.75}px;
}
`}
`;
export const HeaderContainer = styled.div`
${({ theme }) => `
width: 100%;
display: flex;
align-items: center;
cursor: pointer;
padding: 0 ${theme.sizeUnit * 2}px;
overflow: hidden;
`}
`;
export const HeaderLabel = styled.span`
${({ theme }) => `
font-weight: ${theme.fontWeightStrong};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
max-width: 100%;
`}
`;
export const SortIconWrapper = styled.div`
${({ theme }) => `
display: flex;
align-items: center;
margin-left: ${theme.sizeUnit * 2}px;
`}
`;
export const FilterIconWrapper = styled.div<{ isFilterActive?: boolean }>`
align-self: flex-end;
margin-left: auto;
cursor: pointer;
padding: 3px 4px;
overflow: hidden;
cursor: pointer;
border-radius: 4px;
${({ isFilterActive }) =>
isFilterActive &&
css`
background: linear-gradient(
var(--ag-icon-button-active-background-color),
var(--ag-icon-button-active-background-color)
);
::after {
background-color: var(--ag-accent-color);
border-radius: 50%;
content: '';
height: 6px;
position: absolute;
right: 4px;
width: 6px;
}
`}
svg {
${({ isFilterActive }) =>
isFilterActive &&
css`
clip-path: path('M8,0C8,4.415 11.585,8 16,8L16,16L0,16L0,0L8,0Z');
color: var(--ag-icon-button-active-color);
`}
:hover {
${({ isFilterActive }) =>
!isFilterActive &&
css`
background-color: var(--ag-icon-button-hover-background-color);
box-shadow: 0 0 0 var(--ag-icon-button-background-spread)
var(--ag-icon-button-hover-background-color);
color: var(--ag-icon-button-hover-color);
border-radius: var(--ag-icon-button-border-radius);
`}
}
}
`;
export const MenuContainer = styled.div`
${({ theme }) => `
min-width: ${theme.sizeUnit * 45}px;
padding: ${theme.sizeUnit}px 0;
.menu-item {
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 4}px;
cursor: pointer;
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
&:hover {
background-color: ${theme.colors.primary.light4};
}
}
.menu-divider {
height: 1px;
background-color: ${theme.colors.grayscale.light2};
margin: ${theme.sizeUnit}px 0;
}
`}
`;
export const PopoverWrapper = styled.div`
position: relative;
display: inline-block;
`;
export const PopoverContainer = styled.div`
${({ theme }) =>
`
position: fixed;
box-shadow: var(--ag-menu-shadow);
border-radius: ${theme.sizeUnit}px;
z-index: 99;
min-width: ${theme.sizeUnit * 50}px;
background: var(--ag-menu-background-color);
border: var(--ag-menu-border);
box-shadow: var(--ag-menu-shadow);
color: var(--ag-menu-text-color);
`}
`;
export const PaginationContainer = styled.div`
${({ theme }) => `
border: 1px solid ${theme.colors.grayscale.light2};
display: flex;
align-items: center;
justify-content: flex-end;
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 4}px;
border-top: 1px solid ${theme.colors.grayscale.light2};
font-size: ${theme.fontSize}px;
color: ${theme.colorTextBase};
transform: translateY(-${theme.sizeUnit}px);
background: ${theme.colorBgBase};
`}
`;
export const SelectWrapper = styled.div`
${({ theme }) => `
position: relative;
margin-left: ${theme.sizeUnit * 2}px;
display: inline-block;
min-width: ${theme.sizeUnit * 17}px;
overflow: hidden;
`}
`;
export const PageInfo = styled.span`
${({ theme }) => `
margin: 0 ${theme.sizeUnit * 6}px;
span {
font-weight: ${theme.fontWeightStrong};
}
`}
`;
export const PageCount = styled.span`
${({ theme }) => `
span {
font-weight: ${theme.fontWeightStrong};
}
`}
`;
export const ButtonGroup = styled.div`
${({ theme }) => `
display: flex;
gap: ${theme.sizeUnit * 3}px;
`}
`;
export const PageButton = styled.div<{ disabled?: boolean }>`
${({ theme, disabled }) => `
cursor: ${disabled ? 'not-allowed' : 'pointer'};
display: flex;
align-items: center;
justify-content: center;
svg {
height: ${theme.sizeUnit * 3}px;
width: ${theme.sizeUnit * 3}px;
fill: ${disabled ? theme.colors.grayscale.light1 : theme.colors.grayscale.dark2};
}
`}
`;
export const StyledSelect = styled(Select)`
${({ theme }) => `
width: ${theme.sizeUnit * 30}px;
margin-right: ${theme.sizeUnit * 2}px;
`}
`;
// Time Comparison Visibility Styles
export const InfoText = styled.div`
max-width: 242px;
${({ theme }) => `
padding: 0 ${theme.sizeUnit * 2}px;
color: ${theme.colors.grayscale.base};
font-size: ${theme.fontSizeSM}px;
`}
`;
export const ColumnLabel = styled.span`
${({ theme }) => `
color: ${theme.colors.grayscale.dark2};
`}
`;
export const CheckIconWrapper = styled.span`
${({ theme }) => `
float: right;
font-size: ${theme.fontSizeSM}px;
`}
`;
// Text Cell Renderer Styles
export const SummaryContainer = styled.div`
${({ theme }) => `
display: flex;
align-items: center;
gap: ${theme.sizeUnit}px;
`}
`;
export const SummaryText = styled.div`
${({ theme }) => `
font-weight: ${theme.fontWeightStrong};
`}
`;
// Table Container Styles
export const StyledChartContainer = styled.div<{
height: number;
}>`
${({ theme, height }) => css`
height: ${height}px;
--ag-background-color: ${theme.colorBgBase};
--ag-foreground-color: ${theme.colorText};
--ag-header-background-color: ${theme.colorBgBase};
--ag-header-foreground-color: ${theme.colorText};
.dt-is-filter {
cursor: pointer;
:hover {
background-color: ${theme.colorPrimaryBgHover};
}
}
.dt-is-active-filter {
background: ${theme.colors.primary.light3};
:hover {
background-color: ${theme.colorPrimaryBgHover};
}
}
.dt-truncate-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dt-truncate-cell:hover {
overflow: visible;
white-space: normal;
height: auto;
}
.ag-container {
border-radius: 0px;
border: var(--ag-wrapper-border);
}
.ag-input-wrapper {
::before {
z-index: 100;
}
}
.filter-popover {
z-index: 1 !important;
}
.search-container {
display: flex;
justify-content: flex-end;
margin-bottom: ${theme.sizeUnit * 4}px;
}
.dropdown-controls-container {
display: flex;
justify-content: flex-end;
}
.time-comparison-dropdown {
display: flex;
padding-right: ${theme.sizeUnit * 4}px;
padding-top: ${theme.sizeUnit * 1.75}px;
height: fit-content;
}
.ag-header,
.ag-row,
.ag-spanned-row {
font-size: ${theme.fontSizeSM}px;
font-weight: ${theme.fontWeightStrong};
}
.ag-root-wrapper {
border-radius: 0px;
}
.search-by-text-container {
display: flex;
align-items: center;
}
.search-by-text {
margin-right: ${theme.sizeUnit * 2}px;
}
.ant-popover-inner {
padding: 0px;
}
.input-container {
margin-left: auto;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
overflow: visible;
}
.input-wrapper svg {
pointer-events: none;
transform: translate(${theme.sizeUnit * 7}px, ${theme.sizeUnit / 2}px);
color: ${theme.colors.grayscale.base};
}
.input-wrapper input {
color: ${theme.colorText};
font-size: ${theme.fontSizeSM}px;
padding: ${theme.sizeUnit * 1.5}px ${theme.sizeUnit * 3}px
${theme.sizeUnit * 1.5}px ${theme.sizeUnit * 8}px;
line-height: 1.8;
border-radius: ${theme.borderRadius}px;
border: 1px solid ${theme.colors.grayscale.light2};
background-color: transparent;
outline: none;
&:focus {
border-color: ${theme.colors.primary.base};
}
&::placeholder {
color: ${theme.colors.grayscale.light1};
}
}
`}
`;

View File

@@ -0,0 +1,741 @@
/**
* 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 {
ComparisonType,
Currency,
CurrencyFormatter,
DataRecord,
ensureIsArray,
extractTimegrain,
FeatureFlag,
GenericDataType,
getMetricLabel,
getNumberFormatter,
getTimeFormatter,
getTimeFormatterForGranularity,
isFeatureEnabled,
NumberFormats,
QueryMode,
SMART_DATE_ID,
t,
TimeFormats,
TimeFormatter,
} from '@superset-ui/core';
import { isEmpty, isEqual } from 'lodash';
import {
ConditionalFormattingConfig,
getColorFormatters,
} from '@superset-ui/chart-controls';
import isEqualColumns from './utils/isEqualColumns';
import DateWithFormatter from './utils/DateWithFormatter';
import {
DataColumnMeta,
TableChartProps,
AgGridTableChartTransformedProps,
TableColumnConfig,
ColorSchemeEnum,
BasicColorFormatterType,
} 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',
);
}
function isPositiveNumber(value: string | number | null | undefined) {
const num = Number(value);
return (
value !== null &&
value !== undefined &&
value !== '' &&
!Number.isNaN(num) &&
num > 0
);
}
const processComparisonTotals = (
comparisonSuffix: string,
totals?: DataRecord[],
): DataRecord | undefined => {
if (!totals) {
return totals;
}
const transformedTotals: DataRecord = {};
totals.map((totalRecord: DataRecord) =>
Object.keys(totalRecord).forEach(key => {
if (totalRecord[key] !== undefined && !key.includes(comparisonSuffix)) {
transformedTotals[`Main ${key}`] =
parseInt(transformedTotals[`Main ${key}`]?.toString() || '0', 10) +
parseInt(totalRecord[key]?.toString() || '0', 10);
transformedTotals[`# ${key}`] =
parseInt(transformedTotals[`# ${key}`]?.toString() || '0', 10) +
parseInt(
totalRecord[`${key}__${comparisonSuffix}`]?.toString() || '0',
10,
);
const { valueDifference, percentDifferenceNum } = calculateDifferences(
transformedTotals[`Main ${key}`] as number,
transformedTotals[`# ${key}`] as number,
);
transformedTotals[`${key}`] = valueDifference;
transformedTotals[`% ${key}`] = percentDifferenceNum;
}
}),
);
return transformedTotals;
};
const getComparisonColConfig = (
label: string,
parentColKey: string,
columnConfig: Record<string, TableColumnConfig>,
) => {
const comparisonKey = `${label} ${parentColKey}`;
const comparisonColConfig = columnConfig[comparisonKey] || {};
return comparisonColConfig;
};
const getComparisonColFormatter = (
label: string,
parentCol: DataColumnMeta,
columnConfig: Record<string, TableColumnConfig>,
savedFormat: string | undefined,
savedCurrency: Currency | undefined,
) => {
const currentColConfig = getComparisonColConfig(
label,
parentCol.key,
columnConfig,
);
const hasCurrency = currentColConfig.currencyFormat?.symbol;
const currentColNumberFormat =
// fallback to parent's number format if not set
currentColConfig.d3NumberFormat || parentCol.config?.d3NumberFormat;
let { formatter } = parentCol;
if (label === '%') {
formatter = getNumberFormatter(currentColNumberFormat || PERCENT_3_POINT);
} else if (currentColNumberFormat || hasCurrency) {
const currency = currentColConfig.currencyFormat || savedCurrency;
const numberFormat = currentColNumberFormat || savedFormat;
formatter = currency
? new CurrencyFormatter({
d3Format: numberFormat,
currency,
})
: getNumberFormatter(numberFormat);
}
return formatter;
};
const calculateDifferences = (
originalValue: number,
comparisonValue: number,
) => {
const valueDifference = originalValue - comparisonValue;
let percentDifferenceNum;
if (!originalValue && !comparisonValue) {
percentDifferenceNum = 0;
} else if (!originalValue || !comparisonValue) {
percentDifferenceNum = originalValue ? 1 : -1;
} else {
percentDifferenceNum =
(originalValue - comparisonValue) / Math.abs(comparisonValue);
}
return { valueDifference, percentDifferenceNum };
};
const processComparisonDataRecords = memoizeOne(
function processComparisonDataRecords(
originalData: DataRecord[] | undefined,
originalColumns: DataColumnMeta[],
comparisonSuffix: string,
) {
// Transform data
return originalData?.map(originalItem => {
const transformedItem: DataRecord = {};
originalColumns.forEach(origCol => {
if (
(origCol.isMetric || origCol.isPercentMetric) &&
!origCol.key.includes(comparisonSuffix) &&
origCol.isNumeric
) {
const originalValue = originalItem[origCol.key] || 0;
const comparisonValue = origCol.isMetric
? originalItem?.[`${origCol.key}__${comparisonSuffix}`] || 0
: originalItem[`%${origCol.key.slice(1)}__${comparisonSuffix}`] ||
0;
const { valueDifference, percentDifferenceNum } =
calculateDifferences(
originalValue as number,
comparisonValue as number,
);
transformedItem[`Main ${origCol.key}`] = originalValue;
transformedItem[`# ${origCol.key}`] = comparisonValue;
transformedItem[`${origCol.key}`] = valueDifference;
transformedItem[`% ${origCol.key}`] = percentDifferenceNum;
}
});
Object.keys(originalItem).forEach(key => {
const isMetricOrPercentMetric = originalColumns.some(
col => col.key === key && (col.isMetric || col.isPercentMetric),
);
if (!isMetricOrPercentMetric) {
transformedItem[key] = originalItem[key];
}
});
return transformedItem;
});
},
);
const processComparisonColumns = (
columns: DataColumnMeta[],
props: TableChartProps,
comparisonSuffix: string,
) =>
columns
.map(col => {
const {
datasource: { columnFormats, currencyFormats },
rawFormData: { column_config: columnConfig = {} },
} = props;
const savedFormat = columnFormats?.[col.key];
const savedCurrency = currencyFormats?.[col.key];
const originalLabel = col.label;
if (
(col.isMetric || col.isPercentMetric) &&
!col.key.includes(comparisonSuffix) &&
col.isNumeric
) {
return [
{
...col,
originalLabel,
metricName: col.key,
label: t('Main'),
key: `${t('Main')} ${col.key}`,
config: getComparisonColConfig(t('Main'), col.key, columnConfig),
formatter: getComparisonColFormatter(
t('Main'),
col,
columnConfig,
savedFormat,
savedCurrency,
),
},
{
...col,
originalLabel,
metricName: col.key,
label: `#`,
key: `# ${col.key}`,
config: getComparisonColConfig(`#`, col.key, columnConfig),
formatter: getComparisonColFormatter(
`#`,
col,
columnConfig,
savedFormat,
savedCurrency,
),
},
{
...col,
originalLabel,
metricName: col.key,
label: ``,
key: `${col.key}`,
config: getComparisonColConfig(``, col.key, columnConfig),
formatter: getComparisonColFormatter(
``,
col,
columnConfig,
savedFormat,
savedCurrency,
),
},
{
...col,
originalLabel,
metricName: col.key,
label: `%`,
key: `% ${col.key}`,
config: getComparisonColConfig(`%`, col.key, columnConfig),
formatter: getComparisonColFormatter(
`%`,
col,
columnConfig,
savedFormat,
savedCurrency,
),
},
];
}
if (
!col.isMetric &&
!col.isPercentMetric &&
!col.key.includes(comparisonSuffix)
) {
return [col];
}
return [];
})
.flat();
const serverPageLengthMap = new Map();
const processDataRecords = memoizeOne(function processDataRecords(
data: DataRecord[] | undefined,
columns: DataColumnMeta[],
) {
if (!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, currencyFormats, 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 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 label =
isPercentMetric && verboseMap?.hasOwnProperty(key.replace('%', ''))
? `%${verboseMap[key.replace('%', '')]}`
: verboseMap?.[key] || key;
const isTime = dataType === GenericDataType.Temporal;
const isNumber = dataType === GenericDataType.Numeric;
const savedFormat = columnFormats?.[key];
const savedCurrency = currencyFormats?.[key];
const numberFormat = config.d3NumberFormat || savedFormat;
const currency = config.currencyFormat?.symbol
? config.currencyFormat
: savedCurrency;
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 === SMART_DATE_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 || (isNumber && (numberFormat || currency))) {
formatter = currency
? new CurrencyFormatter({
d3Format: numberFormat,
currency,
})
: 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,
): AgGridTableChartTransformedProps => {
const {
height,
width,
rawFormData: formData,
queriesData = [],
ownState: serverPaginationData,
filterState,
hooks: { setDataMask = () => {} },
emitCrossFilters,
} = chartProps;
const {
include_search: includeSearch = false,
page_length: pageLength,
order_desc: sortDesc = false,
slice_id,
time_compare,
comparison_type,
server_pagination: serverPagination = false,
server_page_length: serverPageLength = 10,
query_mode: queryMode,
align_pn: alignPositiveNegative = true,
show_cell_bars: showCellBars = true,
color_pn: colorPositiveNegative = true,
show_totals: showTotals,
conditional_formatting: conditionalFormatting,
comparison_color_enabled: comparisonColorEnabled = false,
comparison_color_scheme: comparisonColorScheme = ColorSchemeEnum.Green,
} = formData;
const allowRearrangeColumns = true;
const calculateBasicStyle = (
percentDifferenceNum: number,
colorOption: ColorSchemeEnum,
) => {
if (percentDifferenceNum === 0) {
return {
arrow: '',
arrowColor: '',
// eslint-disable-next-line theme-colors/no-literal-colors
backgroundColor: 'rgba(0,0,0,0.2)',
};
}
const isPositive = percentDifferenceNum > 0;
const arrow = isPositive ? '↑' : '↓';
const arrowColor =
colorOption === ColorSchemeEnum.Green
? isPositive
? ColorSchemeEnum.Green
: ColorSchemeEnum.Red
: isPositive
? ColorSchemeEnum.Red
: ColorSchemeEnum.Green;
const backgroundColor =
colorOption === ColorSchemeEnum.Green
? `rgba(${isPositive ? '0,150,0' : '150,0,0'},0.2)`
: `rgba(${isPositive ? '150,0,0' : '0,150,0'},0.2)`;
return { arrow, arrowColor, backgroundColor };
};
const getBasicColorFormatter = memoizeOne(function getBasicColorFormatter(
originalData: DataRecord[] | undefined,
originalColumns: DataColumnMeta[],
selectedColumns?: ConditionalFormattingConfig[],
) {
// Transform data
const relevantColumns = selectedColumns
? originalColumns.filter(col =>
selectedColumns.some(scol => scol?.column?.includes(col.key)),
)
: originalColumns;
return originalData?.map(originalItem => {
const item: { [key: string]: BasicColorFormatterType } = {};
relevantColumns.forEach(origCol => {
if (
(origCol.isMetric || origCol.isPercentMetric) &&
!origCol.key.includes(ensureIsArray(timeOffsets)[0]) &&
origCol.isNumeric
) {
const originalValue = originalItem[origCol.key] || 0;
const comparisonValue = origCol.isMetric
? originalItem?.[
`${origCol.key}__${ensureIsArray(timeOffsets)[0]}`
] || 0
: originalItem[
`%${origCol.key.slice(1)}__${ensureIsArray(timeOffsets)[0]}`
] || 0;
const { percentDifferenceNum } = calculateDifferences(
originalValue as number,
comparisonValue as number,
);
if (selectedColumns) {
selectedColumns.forEach(col => {
if (col?.column?.includes(origCol.key)) {
const { arrow, arrowColor, backgroundColor } =
calculateBasicStyle(
percentDifferenceNum,
col.colorScheme || comparisonColorScheme,
);
item[col.column] = {
mainArrow: arrow,
arrowColor,
backgroundColor,
};
}
});
} else {
const { arrow, arrowColor, backgroundColor } = calculateBasicStyle(
percentDifferenceNum,
comparisonColorScheme,
);
item[`${origCol.key}`] = {
mainArrow: arrow,
arrowColor,
backgroundColor,
};
}
}
});
return item;
});
});
const getBasicColorFormatterForColumn = (
originalData: DataRecord[] | undefined,
originalColumns: DataColumnMeta[],
conditionalFormatting?: ConditionalFormattingConfig[],
) => {
const selectedColumns = conditionalFormatting?.filter(
(config: ConditionalFormattingConfig) =>
config.column &&
(config.colorScheme === ColorSchemeEnum.Green ||
config.colorScheme === ColorSchemeEnum.Red),
);
return selectedColumns?.length
? getBasicColorFormatter(originalData, originalColumns, selectedColumns)
: undefined;
};
const isUsingTimeComparison =
!isEmpty(time_compare) &&
queryMode === QueryMode.Aggregate &&
comparison_type === ComparisonType.Values &&
isFeatureEnabled(FeatureFlag.TableV2TimeComparisonEnabled);
let hasServerPageLengthChanged = false;
const pageLengthFromMap = serverPageLengthMap.get(slice_id);
if (!isEqual(pageLengthFromMap, serverPageLength)) {
serverPageLengthMap.set(slice_id, serverPageLength);
hasServerPageLengthChanged = true;
}
const [, percentMetrics, columns] = processColumns(chartProps);
const timeGrain = extractTimegrain(formData);
const nonCustomNorInheritShifts = ensureIsArray(formData.time_compare).filter(
(shift: string) => shift !== 'custom' && shift !== 'inherit',
);
const customOrInheritShifts = ensureIsArray(formData.time_compare).filter(
(shift: string) => shift === 'custom' || shift === 'inherit',
);
let timeOffsets: string[] = [];
if (isUsingTimeComparison && !isEmpty(nonCustomNorInheritShifts)) {
timeOffsets = nonCustomNorInheritShifts;
}
// Shifts for custom or inherit time comparison
if (isUsingTimeComparison && !isEmpty(customOrInheritShifts)) {
if (customOrInheritShifts.includes('custom')) {
timeOffsets = timeOffsets.concat([formData.start_date_offset]);
}
if (customOrInheritShifts.includes('inherit')) {
timeOffsets = timeOffsets.concat(['inherit']);
}
}
const comparisonSuffix = isUsingTimeComparison
? ensureIsArray(timeOffsets)[0]
: '';
let comparisonColumns: DataColumnMeta[] = [];
if (isUsingTimeComparison) {
comparisonColumns = processComparisonColumns(
columns,
chartProps,
comparisonSuffix,
);
}
let baseQuery;
let countQuery;
let rowCount;
let totalQuery;
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 comparisonData = processComparisonDataRecords(
baseQuery?.data,
columns,
comparisonSuffix,
);
const passedData = isUsingTimeComparison ? comparisonData || [] : data;
const passedColumns = isUsingTimeComparison ? comparisonColumns : columns;
const basicColorFormatters =
comparisonColorEnabled && getBasicColorFormatter(baseQuery?.data, columns);
const columnColorFormatters =
getColorFormatters(conditionalFormatting, passedData) ?? [];
const basicColorColumnFormatters = getBasicColorFormatterForColumn(
baseQuery?.data,
columns,
conditionalFormatting,
);
const hasPageLength = isPositiveNumber(pageLength);
const totals =
showTotals && queryMode === QueryMode.Aggregate
? isUsingTimeComparison
? processComparisonTotals(comparisonSuffix, totalQuery?.data)
: totalQuery?.data[0]
: undefined;
return {
height,
width,
data: passedData,
columns: passedColumns,
percentMetrics,
setDataMask,
sortDesc,
includeSearch,
pageSize: getPageSize(pageLength, data.length, columns.length),
filters: filterState.filters,
emitCrossFilters,
allowRearrangeColumns,
slice_id,
serverPagination,
rowCount,
serverPaginationData,
hasServerPageLengthChanged,
serverPageLength,
hasPageLength,
timeGrain,
isRawRecords: queryMode === QueryMode.Raw,
alignPositiveNegative,
showCellBars,
isUsingTimeComparison,
colorPositiveNegative,
totals,
showTotals,
columnColorFormatters,
basicColorColumnFormatters,
basicColorFormatters,
formData,
};
};
export default transformProps;

View File

@@ -0,0 +1,263 @@
/**
* 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 { ColorFormatters } from '@superset-ui/chart-controls';
import {
NumberFormatter,
TimeFormatter,
TimeGranularity,
QueryFormMetric,
ChartProps,
DataRecord,
DataRecordValue,
DataRecordFilters,
GenericDataType,
QueryMode,
ChartDataResponseResult,
QueryFormData,
SetDataMaskHook,
CurrencyFormatter,
Currency,
JsonObject,
Metric,
} from '@superset-ui/core';
import { ColDef, Column, IHeaderParams } from 'ag-grid-community';
import { CustomCellRendererProps } from 'ag-grid-react';
export type CustomFormatter = (value: DataRecordValue) => string;
export type TableColumnConfig = {
d3NumberFormat?: string;
d3SmallNumberFormat?: string;
d3TimeFormat?: string;
columnWidth?: number;
horizontalAlign?: 'left' | 'right' | 'center';
showCellBars?: boolean;
alignPositiveNegative?: boolean;
colorPositiveNegative?: boolean;
truncateLongCells?: boolean;
currencyFormat?: Currency;
visible?: boolean;
customColumnName?: string;
displayTypeIcon?: boolean;
};
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;
// `originalLabel` preserves the original label when time comparison transforms the labels
originalLabel?: string;
dataType: GenericDataType;
formatter?:
| TimeFormatter
| NumberFormatter
| CustomFormatter
| CurrencyFormatter;
isMetric?: boolean;
isPercentMetric?: boolean;
isNumeric?: boolean;
config?: TableColumnConfig;
isChildColumn?: boolean;
}
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;
time_grain_sqla?: TimeGranularity;
column_config?: Record<string, TableColumnConfig>;
allow_rearrange_columns?: boolean;
};
export interface TableChartProps extends ChartProps {
ownCurrentState?: {
pageSize?: number;
currentPage?: number;
};
rawFormData: TableChartFormData;
queriesData: ChartDataResponseResult[];
}
export type BasicColorFormatterType = {
backgroundColor: string;
arrowColor: string;
mainArrow: string;
};
export type SortByItem = {
id: string;
key: string;
desc?: boolean;
};
export type SearchOption = {
value: string;
label: string;
};
export interface ServerPaginationData {
pageSize?: number;
currentPage?: number;
sortBy?: SortByItem[];
searchText?: string;
searchColumn?: string;
}
export interface AgGridTableChartTransformedProps<
D extends DataRecord = DataRecord,
> {
height: number;
width: number;
setDataMask: SetDataMaskHook;
data: D[];
columns: DataColumnMeta[];
pageSize?: number;
sortDesc?: boolean;
includeSearch?: boolean;
filters?: DataRecordFilters;
emitCrossFilters?: boolean;
allowRearrangeColumns?: boolean;
allowRenderHtml?: boolean;
slice_id: number;
serverPagination: boolean;
rowCount: number;
serverPaginationData: JsonObject;
percentMetrics: string[];
hasServerPageLengthChanged: boolean;
serverPageLength: number;
hasPageLength: boolean;
timeGrain: TimeGranularity | undefined;
isRawRecords: boolean;
alignPositiveNegative: boolean;
showCellBars: boolean;
isUsingTimeComparison: boolean;
colorPositiveNegative: boolean;
totals: DataRecord | undefined;
showTotals: boolean;
columnColorFormatters: ColorFormatters;
basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[];
basicColorColumnFormatters?: { [Key: string]: BasicColorFormatterType }[];
formData: TableChartFormData;
}
export enum ColorSchemeEnum {
'Green' = 'Green',
'Red' = 'Red',
}
export interface SortState {
colId: string;
sort: 'asc' | 'desc' | null;
}
export interface CustomContext {
initialSortState: SortState[];
onColumnHeaderClicked: (args: { column: SortState }) => void;
}
export interface CustomHeaderParams extends IHeaderParams {
context: CustomContext;
column: Column;
slice_id: number;
}
export interface UserProvidedColDef extends ColDef {
isMain?: boolean;
timeComparisonKey?: string;
}
export interface CustomColDef extends ColDef {
context?: {
isMetric?: boolean;
isPercentMetric?: boolean;
isNumeric?: boolean;
};
}
export type TableDataColumnMeta = DataColumnMeta & {
config?: TableColumnConfig;
};
export interface InputColumn {
key: string;
label: string;
dataType: number;
isNumeric: boolean;
isMetric: boolean;
isPercentMetric: boolean;
config: Record<string, any>;
formatter?: Function;
originalLabel?: string;
metricName?: string;
}
export type CellRendererProps = CustomCellRendererProps & {
hasBasicColorFormatters: boolean | undefined;
col: InputColumn;
basicColorFormatters: {
[Key: string]: BasicColorFormatterType;
}[];
valueRange: any;
alignPositiveNegative: boolean;
colorPositiveNegative: boolean;
allowRenderHtml: boolean;
columns: InputColumn[];
};
export type Dataset = {
changed_by?: {
first_name: string;
last_name: string;
};
created_by?: {
first_name: string;
last_name: string;
};
changed_on_humanized: string;
created_on_humanized: string;
description: string;
table_name: string;
owners: {
first_name: string;
last_name: string;
}[];
columns?: Column[];
metrics?: Metric[];
verbose_map?: Record<string, string>;
};
export default {};

View File

@@ -0,0 +1,55 @@
/**
* 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,
normalizeTimestamp,
TimeFormatFunction,
} from '@superset-ui/core';
/**
* 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 }: { formatter?: TimeFormatFunction } = {},
) {
let value = input;
// assuming timestamps without a timezone is in UTC time
if (typeof value === 'string') {
value = normalizeTimestamp(value);
}
super(value as string);
this.input = input;
this.formatter = formatter;
this.toString = (): string => {
if (this.formatter === String) {
return String(this.input);
}
return this.formatter ? this.formatter(this) : Date.toString.call(this);
};
}
}

View File

@@ -0,0 +1,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.
*/
const dateFilterComparator = (filterDate: Date, cellValue: Date) => {
const cellDate = new Date(cellValue);
cellDate.setHours(0, 0, 0, 0);
if (Number.isNaN(cellDate?.getTime())) return -1;
const cellDay = cellDate.getDate();
const cellMonth = cellDate.getMonth();
const cellYear = cellDate.getFullYear();
const filterDay = filterDate.getDate();
const filterMonth = filterDate.getMonth();
const filterYear = filterDate.getFullYear();
if (cellYear < filterYear) return -1;
if (cellYear > filterYear) return 1;
if (cellMonth < filterMonth) return -1;
if (cellMonth > filterMonth) return 1;
if (cellDay < filterDay) return -1;
if (cellDay > filterDay) return 1;
return 0;
};
export default dateFilterComparator;

View File

@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { isNil } from 'lodash';
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 (isNil(min)) {
if (value !== undefined) {
min = value;
max = value;
}
} else if (value !== undefined) {
if (min > value) {
min = value;
}
if (!isNil(max)) {
if (max < value) {
max = value;
}
}
}
}
}
return [min, max];
}

View File

@@ -0,0 +1,38 @@
/* 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 { SetDataMaskHook } from '@superset-ui/core';
import { SortByItem } from '../types';
interface TableOwnState {
currentPage?: number;
pageSize?: number;
sortColumn?: string;
sortOrder?: 'asc' | 'desc';
searchText?: string;
sortBy?: SortByItem[];
}
export const updateTableOwnState = (
setDataMask: SetDataMaskHook = () => {},
modifiedOwnState: TableOwnState,
) =>
setDataMask({
ownState: modifiedOwnState,
});

View File

@@ -0,0 +1,34 @@
/* 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 { ValueGetterParams } from 'ag-grid-community';
const filterValueGetter = (params: ValueGetterParams) => {
const raw = params.data[params.colDef.field as string];
const formatter = params.colDef.valueFormatter as Function;
if (!raw || !formatter) return null;
const formatted = formatter({
value: raw,
});
const numeric = parseFloat(String(formatted).replace('%', '').trim());
return Number.isNaN(numeric) ? null : numeric;
};
export default filterValueGetter;

View File

@@ -0,0 +1,115 @@
/**
* 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 {
CurrencyFormatter,
DataRecordValue,
GenericDataType,
getNumberFormatter,
isDefined,
isProbablyHTML,
sanitizeHtml,
} from '@superset-ui/core';
import { ValueFormatterParams, ValueGetterParams } from 'ag-grid-community';
import { DataColumnMeta, InputColumn } from '../types';
import DateWithFormatter from './DateWithFormatter';
/**
* 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 ||
// null values in temporal columns are wrapped in a Date object, so make sure we
// handle them here too
(value instanceof DateWithFormatter && value.input === null)
) {
return [false, 'N/A'];
}
if (formatter) {
return [false, formatter(value as number)];
}
if (typeof value === 'string') {
return isProbablyHTML(value) ? [true, sanitizeHtml(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
: config.currencyFormat
? new CurrencyFormatter({
d3Format: config.d3SmallNumberFormat,
currency: config.currencyFormat,
})
: getNumberFormatter(config.d3SmallNumberFormat);
return formatValue(
isNumber && typeof value === 'number' && Math.abs(value) < 1
? smallNumberFormatter
: formatter,
value,
);
}
export const valueFormatter = (
params: ValueFormatterParams,
col: InputColumn,
): string => {
const { value, node } = params;
if (
isDefined(value) &&
value !== '' &&
!(value instanceof DateWithFormatter && value.input === null)
) {
return col.formatter?.(value) || value;
}
if (node?.level === -1) {
return '';
}
return 'N/A';
};
export const valueGetter = (params: ValueGetterParams, col: InputColumn) => {
// @ts-ignore
if (params?.colDef?.isMain) {
const modifiedColId = `Main ${params.column.getColId()}`;
return params.data[modifiedColId];
}
if (isDefined(params.data?.[params.column.getColId()])) {
return params.data[params.column.getColId()];
}
if (col.isNumeric) {
return undefined;
}
return '';
};

View File

@@ -0,0 +1,28 @@
/* 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 { CUSTOM_AGG_FUNCS } from '../consts';
import { InputColumn } from '../types';
export const getAggFunc = (col: InputColumn) =>
col.isMetric || col.isPercentMetric
? CUSTOM_AGG_FUNCS.queryTotal
: col.isNumeric
? 'sum'
: undefined;

View File

@@ -0,0 +1,46 @@
/**
* 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 { CellClassParams } from 'ag-grid-community';
import { InputColumn } from '../types';
type GetCellClassParams = CellClassParams & {
col: InputColumn;
emitCrossFilters: boolean | undefined;
};
const getCellClass = (params: GetCellClassParams) => {
const { col, emitCrossFilters } = params;
const isActiveFilterValue = params?.context?.isActiveFilterValue;
let className = '';
if (emitCrossFilters) {
if (!col?.isMetric) {
className += ' dt-is-filter';
}
if (isActiveFilterValue?.(col?.key, params?.value)) {
className += ' dt-is-active-filter';
}
if (col?.config?.truncateLongCells) {
className += ' dt-truncate-cell';
}
}
return className;
};
export default getCellClass;

View File

@@ -0,0 +1,82 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ColorFormatters } from '@superset-ui/chart-controls';
import { CellClassParams } from 'ag-grid-community';
import { BasicColorFormatterType, InputColumn } from '../types';
type CellStyleParams = CellClassParams & {
hasColumnColorFormatters: boolean | undefined;
columnColorFormatters: ColorFormatters;
hasBasicColorFormatters: boolean | undefined;
basicColorFormatters?: {
[Key: string]: BasicColorFormatterType;
}[];
col: InputColumn;
};
const getCellStyle = (params: CellStyleParams) => {
const {
value,
colDef,
rowIndex,
hasBasicColorFormatters,
basicColorFormatters,
hasColumnColorFormatters,
columnColorFormatters,
col,
node,
} = params;
let backgroundColor;
if (hasColumnColorFormatters) {
columnColorFormatters!
.filter(formatter => {
const colTitle = formatter?.column?.includes('Main')
? formatter?.column?.replace('Main', '').trim()
: formatter?.column;
return colTitle === colDef.field;
})
.forEach(formatter => {
const formatterResult =
value || value === 0 ? formatter.getColorFromValue(value) : false;
if (formatterResult) {
backgroundColor = formatterResult;
}
});
}
if (
hasBasicColorFormatters &&
col?.metricName &&
node?.rowPinned !== 'bottom'
) {
backgroundColor =
basicColorFormatters?.[rowIndex]?.[col.metricName]?.backgroundColor;
}
const textAlign =
col?.config?.horizontalAlign || (col?.isNumeric ? 'right' : 'left');
return {
backgroundColor: backgroundColor || '',
textAlign,
};
};
export default getCellStyle;

View File

@@ -0,0 +1,103 @@
/**
* 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 {
DataRecordFilters,
DataRecordValue,
DTTM_ALIAS,
ensureIsArray,
TimeGranularity,
} from '@superset-ui/core';
type GetCrossFilterDataMaskProps = {
key: string;
value: DataRecordValue;
filters?: DataRecordFilters;
timeGrain?: TimeGranularity;
isActiveFilterValue: (key: string, val: DataRecordValue) => boolean;
timestampFormatter: (value: DataRecordValue) => string;
};
export const getCrossFilterDataMask = ({
key,
value,
filters,
timeGrain,
isActiveFilterValue,
timestampFormatter,
}: GetCrossFilterDataMaskProps) => {
let updatedFilters = { ...(filters || {}) };
if (filters && isActiveFilterValue(key, value)) {
updatedFilters = {};
} else {
updatedFilters = {
[key]: [value],
};
}
if (Array.isArray(updatedFilters[key]) && updatedFilters[key].length === 0) {
delete updatedFilters[key];
}
const groupBy = Object.keys(updatedFilters);
const groupByValues = Object.values(updatedFilters);
const labelElements: string[] = [];
groupBy.forEach(col => {
const isTimestamp = col === DTTM_ALIAS;
const filterValues = ensureIsArray(updatedFilters?.[col]);
if (filterValues.length) {
const valueLabels = filterValues.map(value =>
isTimestamp ? timestampFormatter(value) : value,
);
labelElements.push(`${valueLabels.join(', ')}`);
}
});
return {
dataMask: {
extraFormData: {
filters:
groupBy.length === 0
? []
: groupBy.map(col => {
const val = ensureIsArray(updatedFilters?.[col]);
if (!val.length)
return {
col,
op: 'IS NULL' as const,
};
return {
col,
op: 'IN' as const,
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:
updatedFilters && Object.keys(updatedFilters).length
? updatedFilters
: null,
},
},
isCurrentValueSelected: isActiveFilterValue(key, value),
};
};

View File

@@ -0,0 +1,65 @@
/**
* 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.
*/
// All ag grid sort related stuff
import { GridState, SortModelItem } from 'ag-grid-community';
import { SortByItem } from '../types';
const getInitialSortState = (sortBy?: SortByItem[]): SortModelItem[] => {
if (Array.isArray(sortBy) && sortBy.length > 0) {
return [
{
colId: sortBy[0]?.id,
sort: sortBy[0]?.desc ? 'desc' : 'asc',
},
];
}
return [];
};
export const shouldSort = ({
colId,
sortDir,
percentMetrics,
serverPagination,
gridInitialState,
}: {
colId: string;
sortDir: string;
percentMetrics: string[];
serverPagination: boolean;
gridInitialState: GridState;
}) => {
// percent metrics are not sortable
if (percentMetrics.includes(colId)) return false;
// if server pagination is not enabled, return false
// since this is server pagination sort
if (!serverPagination) return false;
const {
colId: initialColId = '',
sort: initialSortDir,
}: Partial<SortModelItem> = gridInitialState?.sort?.sortModel?.[0] || {};
// if the initial sort is the same as the current sort, return false
if (initialColId === colId && initialSortDir === sortDir) return false;
return true;
};
export default getInitialSortState;

View File

@@ -0,0 +1,46 @@
/**
* 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 '@superset-ui/core';
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.currencyFormats === b.datasource.currencyFormats &&
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) &&
JSON.stringify(a.rawFormData.column_config || null) ===
JSON.stringify(b.rawFormData.column_config || null)
);
}

View File

@@ -0,0 +1,327 @@
/* 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 { ColDef } from 'ag-grid-community';
import { useCallback, useMemo } from 'react';
import { DataRecord, GenericDataType } from '@superset-ui/core';
import { ColorFormatters } from '@superset-ui/chart-controls';
import { extent as d3Extent, max as d3Max } from 'd3-array';
import {
BasicColorFormatterType,
CellRendererProps,
InputColumn,
} from '../types';
import getCellClass from './getCellClass';
import filterValueGetter from './filterValueGetter';
import dateFilterComparator from './dateFilterComparator';
import { getAggFunc } from './getAggFunc';
import { TextCellRenderer } from '../renderers/TextCellRenderer';
import { NumericCellRenderer } from '../renderers/NumericCellRenderer';
import CustomHeader from '../AgGridTable/components/CustomHeader';
import { valueFormatter, valueGetter } from './formatValue';
import getCellStyle from './getCellStyle';
interface InputData {
[key: string]: any;
}
type UseColDefsProps = {
columns: InputColumn[];
data: InputData[];
serverPagination: boolean;
isRawRecords: boolean;
defaultAlignPN: boolean;
showCellBars: boolean;
colorPositiveNegative: boolean;
totals: DataRecord | undefined;
columnColorFormatters: ColorFormatters;
allowRearrangeColumns?: boolean;
basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[];
isUsingTimeComparison?: boolean;
emitCrossFilters?: boolean;
alignPositiveNegative: boolean;
slice_id: number;
};
type ValueRange = [number, number];
function getValueRange(
key: string,
alignPositiveNegative: boolean,
data: InputData[],
) {
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;
}
const getCellDataType = (col: InputColumn) => {
switch (col.dataType) {
case GenericDataType.Numeric:
return 'number';
case GenericDataType.Temporal:
return 'date';
case GenericDataType.Boolean:
return 'boolean';
default:
return 'text';
}
};
const getFilterType = (col: InputColumn) => {
switch (col.dataType) {
case GenericDataType.Numeric:
return 'agNumberColumnFilter';
case GenericDataType.String:
return 'agMultiColumnFilter';
case GenericDataType.Temporal:
return 'agDateColumnFilter';
default:
return true;
}
};
function getHeaderLabel(col: InputColumn) {
let headerLabel: string | undefined;
const hasOriginalLabel = !!col?.originalLabel;
const isMain = col?.key?.includes('Main');
const hasDisplayTypeIcon = col?.config?.displayTypeIcon !== false;
const hasCustomColumnName = !!col?.config?.customColumnName;
if (hasOriginalLabel && hasCustomColumnName) {
if ('displayTypeIcon' in col.config) {
headerLabel =
hasDisplayTypeIcon && !isMain
? `${col.label} ${col.config.customColumnName}`
: col.config.customColumnName;
} else {
headerLabel = col.config.customColumnName;
}
} else if (hasOriginalLabel && isMain) {
headerLabel = col.originalLabel;
} else if (hasOriginalLabel && !hasDisplayTypeIcon) {
headerLabel = '';
} else {
headerLabel = col?.label;
}
return headerLabel || '';
}
export const useColDefs = ({
columns,
data,
serverPagination,
isRawRecords,
defaultAlignPN,
showCellBars,
colorPositiveNegative,
totals,
columnColorFormatters,
allowRearrangeColumns,
basicColorFormatters,
isUsingTimeComparison,
emitCrossFilters,
alignPositiveNegative,
slice_id,
}: UseColDefsProps) => {
const getCommonColProps = useCallback(
(
col: InputColumn,
): ColDef & {
isMain: boolean;
} => {
const {
config,
isMetric,
isPercentMetric,
isNumeric,
key: originalKey,
dataType,
originalLabel,
} = col;
const alignPN =
config.alignPositiveNegative === undefined
? defaultAlignPN
: config.alignPositiveNegative;
const hasColumnColorFormatters =
isNumeric &&
Array.isArray(columnColorFormatters) &&
columnColorFormatters.length > 0;
const hasBasicColorFormatters =
isUsingTimeComparison &&
Array.isArray(basicColorFormatters) &&
basicColorFormatters.length > 0;
const isMain = originalKey?.includes('Main');
const colId = isMain
? originalKey.replace('Main', '').trim()
: originalKey;
const isTextColumn =
dataType === GenericDataType.String ||
dataType === GenericDataType.Temporal;
const valueRange =
!hasBasicColorFormatters &&
!hasColumnColorFormatters &&
showCellBars &&
(config.showCellBars ?? true) &&
(isMetric || isRawRecords || isPercentMetric) &&
getValueRange(originalKey, alignPN || alignPositiveNegative, data);
const filter = getFilterType(col);
return {
field: colId,
headerName: getHeaderLabel(col),
valueFormatter: p => valueFormatter(p, col),
valueGetter: p => valueGetter(p, col),
cellStyle: p =>
getCellStyle({
...p,
hasColumnColorFormatters,
columnColorFormatters,
hasBasicColorFormatters,
basicColorFormatters,
col,
}),
cellClass: p =>
getCellClass({
...p,
col,
emitCrossFilters,
}),
minWidth: config?.columnWidth ?? 100,
filter,
...(isPercentMetric && {
filterValueGetter,
}),
...(dataType === GenericDataType.Temporal && {
filterParams: {
comparator: dateFilterComparator,
},
}),
cellDataType: getCellDataType(col),
defaultAggFunc: getAggFunc(col),
initialAggFunc: getAggFunc(col),
...(!(isMetric || isPercentMetric) && {
allowedAggFuncs: [
'sum',
'min',
'max',
'count',
'avg',
'first',
'last',
],
}),
cellRenderer: (p: CellRendererProps) =>
isTextColumn ? TextCellRenderer(p) : NumericCellRenderer(p),
cellRendererParams: {
allowRenderHtml: true,
columns,
hasBasicColorFormatters,
col,
basicColorFormatters,
valueRange,
alignPositiveNegative: alignPN || alignPositiveNegative,
colorPositiveNegative,
},
context: {
isMetric,
isPercentMetric,
isNumeric,
},
lockPinned: !allowRearrangeColumns,
sortable: !serverPagination || !isPercentMetric,
...(serverPagination && {
headerComponent: CustomHeader,
comparator: () => 0,
headerComponentParams: {
slice_id,
},
}),
isMain,
...(!isMain &&
originalLabel && {
columnGroupShow: 'open',
}),
...(originalLabel && {
timeComparisonKey: originalLabel,
}),
wrapText: !config?.truncateLongCells,
autoHeight: !config?.truncateLongCells,
};
},
[
columns,
data,
defaultAlignPN,
columnColorFormatters,
basicColorFormatters,
showCellBars,
colorPositiveNegative,
isUsingTimeComparison,
isRawRecords,
emitCrossFilters,
allowRearrangeColumns,
serverPagination,
alignPositiveNegative,
],
);
const stringifiedCols = JSON.stringify(columns);
const colDefs = useMemo(() => {
const groupIndexMap = new Map<string, number>();
return columns.reduce<ColDef[]>((acc, col) => {
const colDef = getCommonColProps(col);
if (col?.originalLabel) {
if (groupIndexMap.has(col.originalLabel)) {
const groupIdx = groupIndexMap.get(col.originalLabel)!;
(acc[groupIdx] as { children: ColDef[] }).children.push(colDef);
} else {
const group = {
headerName: col.originalLabel,
marryChildren: true,
openByDefault: true,
children: [colDef],
};
groupIndexMap.set(col.originalLabel, acc.length);
acc.push(group);
}
} else {
acc.push(colDef);
}
return acc;
}, []);
}, [stringifiedCols, getCommonColProps]);
return colDefs;
};

View File

@@ -0,0 +1,42 @@
/**
* 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 { useTheme } from '@superset-ui/core';
import {
colorSchemeDark,
colorSchemeLight,
themeQuartz,
} from 'ag-grid-community';
// eslint-disable-next-line import/no-extraneous-dependencies
import tinycolor from 'tinycolor2';
export const useIsDark = () => {
const theme = useTheme();
return tinycolor(theme.colorBgContainer).isDark();
};
const useTableTheme = () => {
const baseTheme = themeQuartz;
const isDarkTheme = useIsDark();
const tableTheme = isDarkTheme
? baseTheme.withPart(colorSchemeDark)
: baseTheme.withPart(colorSchemeLight);
return tableTheme;
};
export default useTableTheme;

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"declarationDir": "lib",
"outDir": "lib",
"rootDir": "src"
},
"exclude": ["lib", "test"],
"extends": "../../tsconfig.json",
"include": ["src/**/*", "types/**/*", "../../types/**/*"],
"references": [
{
"path": "../../packages/superset-ui-chart-controls"
},
{
"path": "../../packages/superset-ui-core"
}
]
}

View File

@@ -0,0 +1,22 @@
/*
* 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.
*/
declare module '*.png';
declare module '*.jpg';
declare module 'regenerator-runtime/runtime';

View File

@@ -80,6 +80,7 @@ import {
import { PivotTableChartPlugin as PivotTableChartPluginV2 } from '@superset-ui/plugin-chart-pivot-table';
import { HandlebarsChartPlugin } from '@superset-ui/plugin-chart-handlebars';
import { FilterPlugins } from 'src/constants';
import AgGridTableChartPlugin from '@superset-ui/plugin-chart-ag-grid-table';
import TimeTableChartPlugin from '../TimeTable';
export default class MainPreset extends Preset {
@@ -94,6 +95,10 @@ export default class MainPreset extends Preset {
]
: [];
const agGridTablePlugin = isFeatureEnabled(FeatureFlag.AgGridTableEnabled)
? [new AgGridTableChartPlugin().configure({ key: VizType.TableAgGrid })]
: [];
super({
name: 'Legacy charts',
presets: [new DeckGLChartPreset()],
@@ -187,6 +192,7 @@ export default class MainPreset extends Preset {
],
}).configure({ key: VizType.Cartodiagram }),
...experimentalPlugins,
...agGridTablePlugin,
],
});
}

View File

@@ -579,6 +579,10 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# Allow metrics and columns to be grouped into (potentially nested) folders in the
# chart builder
"DATASET_FOLDERS": False,
# Enable Table V2 Viz plugin
"AG_GRID_TABLE_ENABLED": False,
# Enable Table v2 time comparison feature
"TABLE_V2_TIME_COMPARISON_ENABLED": False,
}
# ------------------------------