mirror of
https://github.com/apache/superset.git
synced 2026-04-19 16:14:52 +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);
|
||||
Reference in New Issue
Block a user