mirror of
https://github.com/apache/superset.git
synced 2026-04-18 23:55:00 +00:00
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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -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 {};
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 '';
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user