feat(sqllab): Replace FilterableTable by AgGrid Table (#29900)

This commit is contained in:
JUST.in DO IT 2025-01-30 16:43:22 -08:00 committed by GitHub
parent 3f46bcf142
commit f73d61a597
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1287 additions and 204 deletions

View File

@ -56,6 +56,8 @@
"@visx/xychart": "^3.5.1", "@visx/xychart": "^3.5.1",
"abortcontroller-polyfill": "^1.7.8", "abortcontroller-polyfill": "^1.7.8",
"ace-builds": "^1.36.3", "ace-builds": "^1.36.3",
"ag-grid-community": "32.2.1",
"ag-grid-react": "32.2.1",
"antd": "4.10.3", "antd": "4.10.3",
"antd-v5": "npm:antd@^5.18.0", "antd-v5": "npm:antd@^5.18.0",
"bootstrap": "^3.4.1", "bootstrap": "^3.4.1",
@ -14478,6 +14480,32 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/ag-charts-types": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-10.2.0.tgz",
"integrity": "sha512-PUqH1QtugpYLnlbMdeSZVf5PpT1XZVsP69qN1JXhetLtQpVC28zaj7ikwu9CMA9N9b+dBboA9QcjUQUJZVUokQ=="
},
"node_modules/ag-grid-community": {
"version": "32.2.1",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-32.2.1.tgz",
"integrity": "sha512-mrnm1DnLI9Wd408mMwP+6p7lbTC3FYgzNIUPygBvNh3SzZnbzTEUJF/BTKXi+MARWtG5S0IMUYy4hqBiLbobaQ==",
"dependencies": {
"ag-charts-types": "10.2.0"
}
},
"node_modules/ag-grid-react": {
"version": "32.2.1",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-32.2.1.tgz",
"integrity": "sha512-lojTKsT/ncRZ81mrDa7qkIhZePfYlLCHIiAL1WbzL1mNPrglaa7QQKkE6hhhuAXvAm2uUhK1OfkMPnrqsEFldA==",
"dependencies": {
"ag-grid-community": "32.2.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": "^16.3.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",

View File

@ -123,6 +123,8 @@
"@visx/xychart": "^3.5.1", "@visx/xychart": "^3.5.1",
"abortcontroller-polyfill": "^1.7.8", "abortcontroller-polyfill": "^1.7.8",
"ace-builds": "^1.36.3", "ace-builds": "^1.36.3",
"ag-grid-community": "32.2.1",
"ag-grid-react": "32.2.1",
"antd": "4.10.3", "antd": "4.10.3",
"antd-v5": "npm:antd@^5.18.0", "antd-v5": "npm:antd@^5.18.0",
"bootstrap": "^3.4.1", "bootstrap": "^3.4.1",

View File

@ -354,7 +354,7 @@ describe('ResultSet', () => {
); );
}); });
const { getByRole } = setup(mockedProps, mockStore(initialState)); const { getByRole } = setup(mockedProps, mockStore(initialState));
expect(getByRole('table')).toBeInTheDocument(); expect(getByRole('treegrid')).toBeInTheDocument();
}); });
test('renders if there is a limit in query.results but not queryLimit', async () => { test('renders if there is a limit in query.results but not queryLimit', async () => {
@ -372,7 +372,7 @@ describe('ResultSet', () => {
}, },
}), }),
); );
expect(getByRole('table')).toBeInTheDocument(); expect(getByRole('treegrid')).toBeInTheDocument();
}); });
test('Async queries - renders "Fetch data preview" button when data preview has no results', () => { test('Async queries - renders "Fetch data preview" button when data preview has no results', () => {
@ -400,7 +400,7 @@ describe('ResultSet', () => {
name: /fetch data preview/i, name: /fetch data preview/i,
}), }),
).toBeVisible(); ).toBeVisible();
expect(screen.queryByRole('table')).not.toBeInTheDocument(); expect(screen.queryByRole('treegrid')).not.toBeInTheDocument();
}); });
test('Async queries - renders "Refetch results" button when a query has no results', () => { test('Async queries - renders "Refetch results" button when a query has no results', () => {
@ -429,7 +429,7 @@ describe('ResultSet', () => {
name: /refetch results/i, name: /refetch results/i,
}), }),
).toBeVisible(); ).toBeVisible();
expect(screen.queryByRole('table')).not.toBeInTheDocument(); expect(screen.queryByRole('treegrid')).not.toBeInTheDocument();
}); });
test('Async queries - renders on the first call', () => { test('Async queries - renders on the first call', () => {
@ -449,7 +449,7 @@ describe('ResultSet', () => {
}, },
}), }),
); );
expect(screen.getByRole('table')).toBeVisible(); expect(screen.getByRole('treegrid')).toBeVisible();
expect( expect(
screen.queryByRole('button', { screen.queryByRole('button', {
name: /fetch data preview/i, name: /fetch data preview/i,

View File

@ -38,7 +38,7 @@ describe('FilterableTable', () => {
const { getByRole, getByText } = render( const { getByRole, getByText } = render(
<FilterableTable {...mockedProps} />, <FilterableTable {...mockedProps} />,
); );
expect(getByRole('table')).toBeInTheDocument(); expect(getByRole('treegrid')).toBeInTheDocument();
mockedProps.data.forEach(({ b: columnBContent }) => { mockedProps.data.forEach(({ b: columnBContent }) => {
expect(getByText(columnBContent)).toBeInTheDocument(); expect(getByText(columnBContent)).toBeInTheDocument();
}); });
@ -78,11 +78,10 @@ describe('FilterableTable sorting - RTL', () => {
}; };
render(<FilterableTable {...stringProps} />); render(<FilterableTable {...stringProps} />);
const stringColumn = within(screen.getByRole('table')) const stringColumn = within(screen.getByRole('treegrid'))
.getByText('columnA') .getByText('columnA')
.closest('th'); .closest('[role=button]');
// Antd 4.x Table does not follow the table role structure. Need a hacky selector to point the cell item const gridCells = screen.getByText('Bravo').closest('[role=rowgroup]');
const gridCells = screen.getByTitle('Bravo').closest('.virtual-grid');
// Original order // Original order
expect(gridCells?.textContent).toEqual( expect(gridCells?.textContent).toEqual(
@ -124,10 +123,10 @@ describe('FilterableTable sorting - RTL', () => {
}; };
render(<FilterableTable {...integerProps} />); render(<FilterableTable {...integerProps} />);
const integerColumn = within(screen.getByRole('table')) const integerColumn = within(screen.getByRole('treegrid'))
.getByText('columnB') .getByText('columnB')
.closest('th'); .closest('[role=button]');
const gridCells = screen.getByTitle('21').closest('.virtual-grid'); const gridCells = screen.getByText('21').closest('[role=rowgroup]');
// Original order // Original order
expect(gridCells?.textContent).toEqual(['21', '0', '623'].join('')); expect(gridCells?.textContent).toEqual(['21', '0', '623'].join(''));
@ -159,10 +158,10 @@ describe('FilterableTable sorting - RTL', () => {
}; };
render(<FilterableTable {...floatProps} />); render(<FilterableTable {...floatProps} />);
const floatColumn = within(screen.getByRole('table')) const floatColumn = within(screen.getByRole('treegrid'))
.getByText('columnC') .getByText('columnC')
.closest('th'); .closest('[role=button]');
const gridCells = screen.getByTitle('45.67').closest('.virtual-grid'); const gridCells = screen.getByText('45.67').closest('[role=rowgroup]');
// Original order // Original order
expect(gridCells?.textContent).toEqual( expect(gridCells?.textContent).toEqual(
@ -214,10 +213,10 @@ describe('FilterableTable sorting - RTL', () => {
}; };
render(<FilterableTable {...mixedFloatProps} />); render(<FilterableTable {...mixedFloatProps} />);
const mixedFloatColumn = within(screen.getByRole('table')) const mixedFloatColumn = within(screen.getByRole('treegrid'))
.getByText('columnD') .getByText('columnD')
.closest('th'); .closest('[role=button]');
const gridCells = screen.getByTitle('48710.92').closest('.virtual-grid'); const gridCells = screen.getByText('48710.92').closest('[role=rowgroup]');
// Original order // Original order
expect(gridCells?.textContent).toEqual( expect(gridCells?.textContent).toEqual(
@ -312,10 +311,10 @@ describe('FilterableTable sorting - RTL', () => {
}; };
render(<FilterableTable {...dsProps} />); render(<FilterableTable {...dsProps} />);
const dsColumn = within(screen.getByRole('table')) const dsColumn = within(screen.getByRole('treegrid'))
.getByText('columnDS') .getByText('columnDS')
.closest('th'); .closest('[role=button]');
const gridCells = screen.getByTitle('2021-01-01').closest('.virtual-grid'); const gridCells = screen.getByText('2021-01-01').closest('[role=rowgroup]');
// Original order // Original order
expect(gridCells?.textContent).toEqual( expect(gridCells?.textContent).toEqual(

View File

@ -16,55 +16,20 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import _JSONbig from 'json-bigint'; import { useMemo, useRef, useCallback } from 'react';
import { useEffect, useRef, useState, useMemo } from 'react'; import { styled } from '@superset-ui/core';
import { getMultipleTextDimensions, styled } from '@superset-ui/core';
import { useDebounceValue } from 'src/hooks/useDebounceValue';
import { useCellContentParser } from './useCellContentParser'; import { useCellContentParser } from './useCellContentParser';
import { renderResultCell } from './utils'; import { renderResultCell } from './utils';
import { Table, TableSize } from '../Table'; import GridTable, { GridSize, ColDef } from '../GridTable';
const JSONbig = _JSONbig({
storeAsString: true,
constructorAction: 'preserve',
});
const SCROLL_BAR_HEIGHT = 15;
// This regex handles all possible number formats in javascript, including ints, floats, // This regex handles all possible number formats in javascript, including ints, floats,
// exponential notation, NaN, and Infinity. // exponential notation, NaN, and Infinity.
// See https://stackoverflow.com/a/30987109 for more details // See https://stackoverflow.com/a/30987109 for more details
const ONLY_NUMBER_REGEX = /^(NaN|-?((\d*\.\d+|\d+)([Ee][+-]?\d+)?|Infinity))$/; const ONLY_NUMBER_REGEX = /^(NaN|-?((\d*\.\d+|\d+)([Ee][+-]?\d+)?|Infinity))$/;
const StyledFilterableTable = styled.div` const StyledFilterableTable = styled.div`
${({ theme }) => ` height: 100%;
height: 100%; overflow: hidden;
overflow: hidden;
.ant-table-cell {
font-weight: ${theme.typography.weights.bold};
background-color: ${theme.colors.grayscale.light5};
}
.ant-table-cell,
.virtual-table-cell {
min-width: 0px;
align-self: center;
font-size: ${theme.typography.sizes.s}px;
}
.even-row {
background: ${theme.colors.grayscale.light4};
}
.odd-row {
background: ${theme.colors.grayscale.light5};
}
.cell-text-for-measuring {
font-family: ${theme.typography.families.sansSerif};
font-size: ${theme.typography.sizes.s}px;
}
`}
`; `;
type CellDataType = string | number | null; type CellDataType = string | number | null;
@ -79,12 +44,38 @@ export interface FilterableTableProps {
overscanColumnCount?: number; overscanColumnCount?: number;
overscanRowCount?: number; overscanRowCount?: number;
rowHeight?: number; rowHeight?: number;
// need antd 5.0 to support striped color pattern
striped?: boolean; striped?: boolean;
expandedColumns?: string[]; expandedColumns?: string[];
allowHTML?: boolean; allowHTML?: boolean;
} }
const parseNumberFromString = (value: string | number | null) => {
if (typeof value === 'string' && ONLY_NUMBER_REGEX.test(value)) {
return parseFloat(value);
}
return value;
};
const sortResults = (valueA: string | number, valueB: string | number) => {
const aValue = parseNumberFromString(valueA);
const bValue = parseNumberFromString(valueB);
// equal items sort equally
if (aValue === bValue) {
return 0;
}
// nulls sort after anything else
if (aValue === null) {
return 1;
}
if (bValue === null) {
return -1;
}
return aValue < bValue ? -1 : 1;
};
const FilterableTable = ({ const FilterableTable = ({
orderedColumnKeys, orderedColumnKeys,
data, data,
@ -92,83 +83,13 @@ const FilterableTable = ({
filterText = '', filterText = '',
expandedColumns = [], expandedColumns = [],
allowHTML = true, allowHTML = true,
striped,
}: FilterableTableProps) => { }: FilterableTableProps) => {
const formatTableData = (data: Record<string, unknown>[]): Datum[] =>
data.map(row => {
const newRow: Record<string, any> = {};
Object.entries(row).forEach(([key, val]) => {
if (['string', 'number'].indexOf(typeof val) >= 0) {
newRow[key] = val;
} else {
newRow[key] = val === null ? null : JSONbig.stringify(val);
}
});
return newRow;
});
const [fitted, setFitted] = useState(false);
const [list] = useState<Datum[]>(() => formatTableData(data));
const getCellContent = useCellContentParser({ const getCellContent = useCellContentParser({
columnKeys: orderedColumnKeys, columnKeys: orderedColumnKeys,
expandedColumns, expandedColumns,
}); });
const getWidthsForColumns = () => {
const PADDING = 50; // accounts for cell padding and width of sorting icon
const widthsByColumnKey: Record<string, number> = {};
const cellContent = ([] as string[]).concat(
...orderedColumnKeys.map(key => {
const cellContentList = list.map((data: Datum) =>
getCellContent({ cellData: data[key], columnKey: key }),
);
cellContentList.push(key);
return cellContentList;
}),
);
const colWidths = getMultipleTextDimensions({
className: 'cell-text-for-measuring',
texts: cellContent,
}).map(dimension => dimension.width);
orderedColumnKeys.forEach((key, index) => {
// we can't use Math.max(...colWidths.slice(...)) here since the number
// of elements might be bigger than the number of allowed arguments in a
// JavaScript function
widthsByColumnKey[key] =
colWidths
.slice(index * (list.length + 1), (index + 1) * (list.length + 1))
.reduce((a, b) => Math.max(a, b)) + PADDING;
});
return widthsByColumnKey;
};
const [widthsForColumnsByKey] = useState<Record<string, number>>(() =>
getWidthsForColumns(),
);
const totalTableWidth = useRef(
orderedColumnKeys
.map(key => widthsForColumnsByKey[key])
.reduce((curr, next) => curr + next),
);
const container = useRef<HTMLDivElement>(null);
const fitTableToWidthIfNeeded = () => {
const containerWidth = container.current?.clientWidth ?? 0;
if (totalTableWidth.current < containerWidth) {
// fit table width if content doesn't fill the width of the container
totalTableWidth.current = containerWidth;
}
setFitted(true);
};
useEffect(() => {
fitTableToWidthIfNeeded();
}, []);
const hasMatch = (text: string, row: Datum) => { const hasMatch = (text: string, row: Datum) => {
const values: string[] = []; const values: string[] = [];
Object.keys(row).forEach(key => { Object.keys(row).forEach(key => {
@ -188,86 +109,52 @@ const FilterableTable = ({
return values.some(v => v.includes(lowerCaseText)); return values.some(v => v.includes(lowerCaseText));
}; };
// Parse any numbers from strings so they'll sort correctly const columns = useMemo(
const parseNumberFromString = (value: string | number | null) => {
if (typeof value === 'string') {
if (ONLY_NUMBER_REGEX.test(value)) {
return parseFloat(value);
}
}
return value;
};
const sortResults = (key: string, a: Datum, b: Datum) => {
const aValue = parseNumberFromString(a[key]);
const bValue = parseNumberFromString(b[key]);
// equal items sort equally
if (aValue === bValue) {
return 0;
}
// nulls sort after anything else
if (aValue === null) {
return 1;
}
if (bValue === null) {
return -1;
}
return aValue < bValue ? -1 : 1;
};
const keyword = useDebounceValue(filterText);
const filteredList = useMemo(
() => () =>
keyword ? list.filter((row: Datum) => hasMatch(keyword, row)) : list, orderedColumnKeys.map(key => ({
[list, keyword], key,
label: key,
fieldName: key,
headerName: key,
comparator: sortResults,
render: ({ value, colDef }: { value: CellDataType; colDef: ColDef }) =>
renderResultCell({
cellData: value,
columnKey: colDef.field,
allowHTML,
getCellContent,
}),
})),
[orderedColumnKeys, allowHTML, getCellContent],
); );
// exclude the height of the horizontal scroll bar from the height of the table const keyword = useRef<string | undefined>(filterText);
// and the height of the table container if the content overflows keyword.current = filterText;
const totalTableHeight =
container.current && totalTableWidth.current > container.current.clientWidth
? height - SCROLL_BAR_HEIGHT
: height;
const columns = orderedColumnKeys.map(key => ({ const keywordFilter = useCallback(node => {
key, if (keyword.current && node.data) {
title: key, return hasMatch(keyword.current, node.data);
dataIndex: key, }
width: widthsForColumnsByKey[key], return true;
sorter: (a: Datum, b: Datum) => sortResults(key, a, b), }, []);
render: (text: CellDataType) =>
renderResultCell({
cellData: text,
columnKey: key,
allowHTML,
getCellContent,
}),
}));
return ( return (
<StyledFilterableTable <StyledFilterableTable
className="filterable-table-container" className="filterable-table-container"
data-test="table-container" data-test="table-container"
ref={container}
> >
{fitted && ( <GridTable
<Table size={GridSize.Small}
loading={filterText !== keyword} height={height}
size={TableSize.Small} usePagination={false}
height={totalTableHeight + 42} columns={columns}
usePagination={false} data={data}
columns={columns} externalFilter={keywordFilter}
data={filteredList} showRowNumber
childrenColumnName="" striped={striped}
virtualize enableActions
bordered columnReorderable
/> />
)}
</StyledFilterableTable> </StyledFilterableTable>
); );
}; };

View File

@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render } from 'spec/helpers/testing-library';
import GridTable from '.';
jest.mock('src/components/ErrorBoundary', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const mockedProps = {
queryId: 'abc',
columns: ['a', 'b', 'c'].map(key => ({
key,
label: key,
headerName: key,
render: ({ value }: { value: any }) => value,
})),
data: [
{ a: 'a1', b: 'b1', c: 'c1', d: 0 },
{ a: 'a2', b: 'b2', c: 'c2', d: 100 },
{ a: null, b: 'b3', c: 'c3', d: 50 },
],
height: 500,
};
test('renders a grid with 3 Table rows', () => {
const { queryByText } = render(<GridTable {...mockedProps} />);
mockedProps.data.forEach(({ b: columnBContent }) => {
expect(queryByText(columnBContent)).toBeInTheDocument();
});
});
test('sorts strings correctly', () => {
const stringProps = {
...mockedProps,
columns: ['columnA'].map(key => ({
key,
label: key,
headerName: key,
render: ({ value }: { value: any }) => value,
})),
data: [{ columnA: 'Bravo' }, { columnA: 'Alpha' }, { columnA: 'Charlie' }],
height: 500,
};
const { container } = render(<GridTable {...stringProps} />);
// Original order
expect(container).toHaveTextContent(['Bravo', 'Alpha', 'Charlie'].join(''));
});

View File

@ -0,0 +1,109 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { Column, GridApi } from 'ag-grid-community';
import { act, fireEvent, render } from 'spec/helpers/testing-library';
import Header from './Header';
import { PIVOT_COL_ID } from './constants';
jest.mock('src/components/Dropdown', () => ({
Dropdown: () => <div data-test="mock-dropdown" />,
}));
jest.mock('src/components/Icons', () => ({
Sort: () => <div data-test="mock-sort" />,
SortAsc: () => <div data-test="mock-sort-asc" />,
SortDesc: () => <div data-test="mock-sort-desc" />,
}));
class MockApi extends EventTarget {
getAllDisplayedColumns() {
return [];
}
isDestroyed() {
return false;
}
}
const mockedProps = {
displayName: 'test column',
setSort: jest.fn(),
enableSorting: true,
column: {
getColId: () => '123',
isPinnedLeft: () => true,
isPinnedRight: () => false,
getSort: () => 'asc',
getSortIndex: () => null,
} as any as Column,
api: new MockApi() as any as GridApi,
};
test('renders display name for the column', () => {
const { queryByText } = render(<Header {...mockedProps} />);
expect(queryByText(mockedProps.displayName)).toBeInTheDocument();
});
test('sorts by clicking a column header', () => {
const { getByText, queryByTestId } = render(<Header {...mockedProps} />);
fireEvent.click(getByText(mockedProps.displayName));
expect(mockedProps.setSort).toHaveBeenCalledWith('asc', false);
expect(queryByTestId('mock-sort-asc')).toBeInTheDocument();
fireEvent.click(getByText(mockedProps.displayName));
expect(mockedProps.setSort).toHaveBeenCalledWith('desc', false);
expect(queryByTestId('mock-sort-desc')).toBeInTheDocument();
fireEvent.click(getByText(mockedProps.displayName));
expect(mockedProps.setSort).toHaveBeenCalledWith(null, false);
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
expect(queryByTestId('mock-sort-desc')).not.toBeInTheDocument();
});
test('synchronizes the current sort when sortChanged event occured', async () => {
const { findByTestId } = render(<Header {...mockedProps} />);
act(() => {
mockedProps.api.dispatchEvent(new Event('sortChanged'));
});
const sortAsc = await findByTestId('mock-sort-asc');
expect(sortAsc).toBeInTheDocument();
});
test('disable menu when enableFilterButton is false', () => {
const { queryByText, queryByTestId } = render(
<Header {...mockedProps} enableFilterButton={false} />,
);
expect(queryByText(mockedProps.displayName)).toBeInTheDocument();
expect(queryByTestId('mock-dropdown')).not.toBeInTheDocument();
});
test('hide display name for PIVOT_COL_ID', () => {
const { queryByText } = render(
<Header
{...mockedProps}
column={
{
getColId: () => PIVOT_COL_ID,
isPinnedLeft: () => true,
isPinnedRight: () => false,
getSortIndex: () => null,
} as any as Column
}
/>,
);
expect(queryByText(mockedProps.displayName)).not.toBeInTheDocument();
});

View File

@ -0,0 +1,200 @@
/**
* 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, useEffect, useRef, useState } from 'react';
import { styled, useTheme, t } from '@superset-ui/core';
import type { Column, GridApi } from 'ag-grid-community';
import Icons from 'src/components/Icons';
import { PIVOT_COL_ID } from './constants';
import HeaderMenu from './HeaderMenu';
interface Params {
enableFilterButton?: boolean;
enableSorting?: boolean;
displayName: string;
column: Column;
api: GridApi;
setSort: (sort: string | null, multiSort: boolean) => void;
}
const SORT_DIRECTION = [null, 'asc', 'desc'];
const HeaderCell = styled.div`
display: flex;
flex: 1;
&[role='button'] {
cursor: pointer;
}
`;
const HeaderCellSort = styled.div`
position: relative;
display: inline-flex;
align-items: center;
`;
const SortSeqLabel = styled.span`
position: absolute;
right: 0;
`;
const HeaderAction = styled.div`
display: none;
position: absolute;
right: ${({ theme }) => theme.gridUnit * 3}px;
&.main {
margin: 0 auto;
left: 0;
right: 0;
width: 20px;
}
& .ant-dropdown-trigger {
cursor: context-menu;
padding: ${({ theme }) => theme.gridUnit * 2}px;
background-color: var(--ag-background-color);
box-shadow: 0 0 2px var(--ag-chip-border-color);
border-radius: 50%;
&:hover {
box-shadow: 0 0 4px ${({ theme }) => theme.colors.grayscale.light1};
}
}
`;
const IconPlaceholder = styled.div`
position: absolute;
top: 0;
`;
const Header: React.FC<Params> = ({
enableFilterButton,
enableSorting,
displayName,
setSort,
column,
api,
}: Params) => {
const theme = useTheme();
const colId = column.getColId();
const pinnedLeft = column.isPinnedLeft();
const pinnedRight = column.isPinnedRight();
const sortOption = useRef<number>(0);
const [invisibleColumns, setInvisibleColumns] = useState<Column[]>([]);
const [currentSort, setCurrentSort] = useState<string | null>(null);
const [sortIndex, setSortIndex] = useState<number | null>();
const onSort = useCallback(
event => {
sortOption.current = (sortOption.current + 1) % SORT_DIRECTION.length;
const sort = SORT_DIRECTION[sortOption.current];
setSort(sort, event.shiftKey);
setCurrentSort(sort);
},
[setSort],
);
const onVisibleChange = useCallback(
(isVisible: boolean) => {
if (isVisible) {
setInvisibleColumns(
api.getColumns()?.filter(c => !c.isVisible()) || [],
);
}
},
[api],
);
const onSortChanged = useCallback(() => {
const hasMultiSort =
api.getAllDisplayedColumns().findIndex(c => c.getSortIndex()) !== -1;
const updatedSortIndex = column.getSortIndex();
sortOption.current = SORT_DIRECTION.indexOf(column.getSort() ?? null);
setCurrentSort(column.getSort() ?? null);
setSortIndex(hasMultiSort ? updatedSortIndex : null);
}, [api, column]);
useEffect(() => {
api.addEventListener('sortChanged', onSortChanged);
return () => {
if (api.isDestroyed()) return;
api.removeEventListener('sortChanged', onSortChanged);
};
}, [api, onSortChanged]);
return (
<>
{colId !== PIVOT_COL_ID && (
<HeaderCell
tabIndex={0}
className="ag-header-cell-label"
{...(enableSorting && {
role: 'button',
onClick: onSort,
title: t(
'To enable multiple column sorting, hold down the ⇧ Shift key while clicking the column header.',
),
})}
>
<div className="ag-header-cell-text">{displayName}</div>
{enableSorting && (
<HeaderCellSort>
<Icons.Sort iconSize="xxl" />
<IconPlaceholder>
{currentSort === 'asc' && (
<Icons.SortAsc
iconSize="xxl"
iconColor={theme.colors.primary.base}
/>
)}
{currentSort === 'desc' && (
<Icons.SortDesc
iconSize="xxl"
iconColor={theme.colors.primary.base}
/>
)}
</IconPlaceholder>
{typeof sortIndex === 'number' && (
<SortSeqLabel>{sortIndex + 1}</SortSeqLabel>
)}
</HeaderCellSort>
)}
</HeaderCell>
)}
{enableFilterButton && colId && api && (
<HeaderAction
className={`customHeaderAction${
colId === PIVOT_COL_ID ? ' main' : ''
}`}
>
{colId && (
<HeaderMenu
colId={colId}
api={api}
pinnedLeft={pinnedLeft}
pinnedRight={pinnedRight}
invisibleColumns={invisibleColumns}
isMain={colId === PIVOT_COL_ID}
onVisibleChange={onVisibleChange}
/>
)}
</HeaderAction>
)}
</>
);
};
export default Header;

View File

@ -0,0 +1,266 @@
/**
* 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 type { Column, GridApi } from 'ag-grid-community';
import {
fireEvent,
render,
waitFor,
screen,
} from 'spec/helpers/testing-library';
import HeaderMenu from './HeaderMenu';
jest.mock('src/components/Menu', () => {
const Menu = ({ children }: { children: React.ReactChild }) => (
<div data-test="mock-Menu">{children}</div>
);
Menu.Item = ({
children,
onClick,
}: {
children: React.ReactChild;
onClick: () => void;
}) => (
<button type="button" data-test="mock-Item" onClick={onClick}>
{children}
</button>
);
Menu.SubMenu = ({
title,
children,
}: {
title: React.ReactNode;
children: React.ReactNode;
}) => (
<div>
{title}
<button type="button" data-test="mock-SubMenu">
{children}
</button>
</div>
);
Menu.Divider = () => <div data-test="mock-Divider" />;
return { Menu };
});
jest.mock('src/components/Icons', () => ({
DownloadOutlined: () => <div data-test="mock-DownloadOutlined" />,
CopyOutlined: () => <div data-test="mock-CopyOutlined" />,
UnlockOutlined: () => <div data-test="mock-UnlockOutlined" />,
VerticalRightOutlined: () => <div data-test="mock-VerticalRightOutlined" />,
VerticalLeftOutlined: () => <div data-test="mock-VerticalLeftOutlined" />,
EyeInvisibleOutlined: () => <div data-test="mock-EyeInvisibleOutlined" />,
EyeOutlined: () => <div data-test="mock-EyeOutlined" />,
ColumnWidthOutlined: () => <div data-test="mock-column-width" />,
}));
jest.mock('src/components/Dropdown', () => ({
Dropdown: ({ overlay }: { overlay: React.ReactChild }) => (
<div data-test="mock-Dropdown">{overlay}</div>
),
}));
jest.mock('src/utils/copy', () => jest.fn().mockImplementation(f => f()));
const mockInvisibleColumn = {
getColId: jest.fn().mockReturnValue('column2'),
getColDef: jest.fn().mockReturnValue({ headerName: 'column2' }),
getDataAsCsv: jest.fn().mockReturnValue('csv'),
} as any as Column;
const mockInvisibleColumn3 = {
getColId: jest.fn().mockReturnValue('column3'),
getColDef: jest.fn().mockReturnValue({ headerName: 'column3' }),
getDataAsCsv: jest.fn().mockReturnValue('csv'),
} as any as Column;
const mockGridApi = {
autoSizeColumns: jest.fn(),
autoSizeAllColumns: jest.fn(),
getColumn: jest.fn().mockReturnValue({
getColDef: jest.fn().mockReturnValue({}),
}),
getColumns: jest.fn().mockReturnValue([]),
getDataAsCsv: jest.fn().mockReturnValue('csv'),
exportDataAsCsv: jest.fn().mockReturnValue('csv'),
getAllDisplayedColumns: jest.fn().mockReturnValue([]),
setColumnsPinned: jest.fn(),
setColumnsVisible: jest.fn(),
setColumnVisible: jest.fn(),
moveColumns: jest.fn(),
} as any as GridApi;
const mockedProps = {
colId: 'column1',
invisibleColumns: [],
api: mockGridApi,
onVisibleChange: jest.fn(),
};
afterEach(() => {
(mockGridApi.getDataAsCsv as jest.Mock).mockClear();
(mockGridApi.setColumnsPinned as jest.Mock).mockClear();
(mockGridApi.setColumnsVisible as jest.Mock).mockClear();
(mockGridApi.setColumnsVisible as jest.Mock).mockClear();
(mockGridApi.setColumnsPinned as jest.Mock).mockClear();
(mockGridApi.autoSizeColumns as jest.Mock).mockClear();
(mockGridApi.autoSizeAllColumns as jest.Mock).mockClear();
(mockGridApi.moveColumns as jest.Mock).mockClear();
});
test('renders copy data', async () => {
const { getByText } = render(<HeaderMenu {...mockedProps} />);
fireEvent.click(getByText('Copy'));
await waitFor(() =>
expect(mockGridApi.getDataAsCsv).toHaveBeenCalledTimes(1),
);
expect(mockGridApi.getDataAsCsv).toHaveBeenCalledWith({
columnKeys: [mockedProps.colId],
suppressQuotes: true,
});
});
test('renders buttons pinning both sides', () => {
const { queryByText, getByText } = render(<HeaderMenu {...mockedProps} />);
expect(queryByText('Pin Left')).toBeInTheDocument();
expect(queryByText('Pin Right')).toBeInTheDocument();
fireEvent.click(getByText('Pin Left'));
expect(mockGridApi.setColumnsPinned).toHaveBeenCalledTimes(1);
expect(mockGridApi.setColumnsPinned).toHaveBeenCalledWith(
[mockedProps.colId],
'left',
);
fireEvent.click(getByText('Pin Right'));
expect(mockGridApi.setColumnsPinned).toHaveBeenLastCalledWith(
[mockedProps.colId],
'right',
);
});
test('renders unpin on pinned left', () => {
const { queryByText, getByText } = render(
<HeaderMenu {...mockedProps} pinnedLeft />,
);
expect(queryByText('Pin Left')).not.toBeInTheDocument();
expect(queryByText('Unpin')).toBeInTheDocument();
fireEvent.click(getByText('Unpin'));
expect(mockGridApi.setColumnsPinned).toHaveBeenCalledTimes(1);
expect(mockGridApi.setColumnsPinned).toHaveBeenCalledWith(
[mockedProps.colId],
null,
);
});
test('renders unpin on pinned right', () => {
const { queryByText } = render(<HeaderMenu {...mockedProps} pinnedRight />);
expect(queryByText('Pin Right')).not.toBeInTheDocument();
expect(queryByText('Unpin')).toBeInTheDocument();
});
test('renders autosize column', async () => {
const { getByText } = render(<HeaderMenu {...mockedProps} />);
fireEvent.click(getByText('Autosize Column'));
await waitFor(() =>
expect(mockGridApi.autoSizeColumns).toHaveBeenCalledTimes(1),
);
});
test('renders unhide when invisible column exists', async () => {
const { queryByText } = render(
<HeaderMenu {...mockedProps} invisibleColumns={[mockInvisibleColumn]} />,
);
expect(queryByText('Unhide')).toBeInTheDocument();
const unhideColumnsButton = await screen.findByText('column2');
fireEvent.click(unhideColumnsButton);
expect(mockGridApi.setColumnsVisible).toHaveBeenCalledTimes(1);
expect(mockGridApi.setColumnsVisible).toHaveBeenCalledWith(['column2'], true);
});
describe('for main menu', () => {
test('renders Copy to Clipboard', async () => {
const { getByText } = render(<HeaderMenu {...mockedProps} isMain />);
fireEvent.click(getByText('Copy the current data'));
await waitFor(() =>
expect(mockGridApi.getDataAsCsv).toHaveBeenCalledTimes(1),
);
expect(mockGridApi.getDataAsCsv).toHaveBeenCalledWith({
columnKeys: [],
columnSeparator: '\t',
suppressQuotes: true,
});
});
test('renders Download to CSV', async () => {
const { getByText } = render(<HeaderMenu {...mockedProps} isMain />);
fireEvent.click(getByText('Download to CSV'));
await waitFor(() =>
expect(mockGridApi.exportDataAsCsv).toHaveBeenCalledTimes(1),
);
expect(mockGridApi.exportDataAsCsv).toHaveBeenCalledWith({
columnKeys: [],
});
});
test('renders autosize column', async () => {
const { getByText } = render(<HeaderMenu {...mockedProps} isMain />);
fireEvent.click(getByText('Autosize all columns'));
await waitFor(() =>
expect(mockGridApi.autoSizeAllColumns).toHaveBeenCalledTimes(1),
);
});
test('renders all unhide all hidden columns when multiple invisible columns exist', async () => {
render(
<HeaderMenu
{...mockedProps}
isMain
invisibleColumns={[mockInvisibleColumn, mockInvisibleColumn3]}
/>,
);
const unhideColumnsButton = await screen.findByText(
`All ${2} hidden columns`,
);
fireEvent.click(unhideColumnsButton);
expect(mockGridApi.setColumnsVisible).toHaveBeenCalledTimes(1);
expect(mockGridApi.setColumnsVisible).toHaveBeenCalledWith(
[mockInvisibleColumn, mockInvisibleColumn3],
true,
);
});
test('reset columns configuration', async () => {
const { getByText } = render(
<HeaderMenu
{...mockedProps}
isMain
invisibleColumns={[mockInvisibleColumn]}
/>,
);
fireEvent.click(getByText('Reset columns'));
await waitFor(() =>
expect(mockGridApi.setColumnsVisible).toHaveBeenCalledTimes(1),
);
expect(mockGridApi.setColumnsVisible).toHaveBeenCalledWith(
[mockInvisibleColumn],
true,
);
expect(mockGridApi.setColumnsPinned).toHaveBeenCalledTimes(1);
expect(mockGridApi.setColumnsPinned).toHaveBeenCalledWith([], null);
expect(mockGridApi.moveColumns).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,247 @@
/**
* 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 } from 'react';
import { styled, t } from '@superset-ui/core';
import type { Column, ColumnPinnedType, GridApi } from 'ag-grid-community';
import Icons from 'src/components/Icons';
import { Dropdown, DropdownProps } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu';
import copyTextToClipboard from 'src/utils/copy';
import { PIVOT_COL_ID } from './constants';
const IconMenuItem = styled(Menu.Item)`
display: flex;
align-items: center;
`;
const IconEmpty = styled.span`
width: 20px;
`;
type Params = {
colId: string;
column?: Column;
api: GridApi;
pinnedLeft?: boolean;
pinnedRight?: boolean;
invisibleColumns: Column[];
isMain?: boolean;
onVisibleChange: DropdownProps['onVisibleChange'];
};
const HeaderMenu: React.FC<Params> = ({
colId,
api,
pinnedLeft,
pinnedRight,
invisibleColumns,
isMain,
onVisibleChange,
}: Params) => {
const pinColumn = useCallback(
(pinLoc: ColumnPinnedType) => {
api.setColumnsPinned([colId], pinLoc);
},
[api, colId],
);
const unHideAction = invisibleColumns.length > 0 && (
<Menu.SubMenu
title={
<>
<Icons.EyeOutlined iconSize="m" />
{t('Unhide')}
</>
}
>
{invisibleColumns.length > 1 && (
<Menu.Item
onClick={() => {
api.setColumnsVisible(invisibleColumns, true);
}}
>
<b>{t('All %s hidden columns', invisibleColumns.length)}</b>
</Menu.Item>
)}
{invisibleColumns.map(c => (
<Menu.Item
key={c.getColId()}
onClick={() => {
api.setColumnsVisible([c.getColId()], true);
}}
>
{c.getColDef().headerName}
</Menu.Item>
))}
</Menu.SubMenu>
);
if (isMain) {
return (
<Dropdown
placement="bottomLeft"
trigger={['click']}
onVisibleChange={onVisibleChange}
overlay={
<Menu style={{ width: 250 }} mode="vertical">
<IconMenuItem
onClick={() => {
copyTextToClipboard(
() =>
new Promise((resolve, reject) => {
const data = api.getDataAsCsv({
columnKeys: api
.getAllDisplayedColumns()
.map(c => c.getColId())
.filter(id => id !== colId),
suppressQuotes: true,
columnSeparator: '\t',
});
if (data) {
resolve(data);
} else {
reject();
}
}),
);
}}
>
<Icons.CopyOutlined iconSize="m" /> {t('Copy the current data')}
</IconMenuItem>
<IconMenuItem
onClick={() => {
api.exportDataAsCsv({
columnKeys: api
.getAllDisplayedColumns()
.map(c => c.getColId())
.filter(id => id !== colId),
});
}}
>
<Icons.DownloadOutlined iconSize="m" /> {t('Download to CSV')}
</IconMenuItem>
<Menu.Divider />
<IconMenuItem
onClick={() => {
api.autoSizeAllColumns();
}}
>
<Icons.ColumnWidthOutlined iconSize="m" />
{t('Autosize all columns')}
</IconMenuItem>
{unHideAction}
<Menu.Divider />
<IconMenuItem
onClick={() => {
api.setColumnsVisible(invisibleColumns, true);
const columns = api.getColumns();
if (columns) {
const pinnedColumns = columns.filter(
c => c.getColId() !== PIVOT_COL_ID && c.isPinned(),
);
api.setColumnsPinned(pinnedColumns, null);
api.moveColumns(columns, 0);
const firstColumn = columns.find(
c => c.getColId() !== PIVOT_COL_ID,
);
if (firstColumn) {
api.ensureColumnVisible(firstColumn, 'start');
}
}
}}
>
<IconEmpty className="anticon" />
{t('Reset columns')}
</IconMenuItem>
</Menu>
}
/>
);
}
return (
<Dropdown
placement="bottomRight"
trigger={['click']}
onVisibleChange={onVisibleChange}
overlay={
<Menu style={{ width: 180 }} mode="vertical">
<IconMenuItem
onClick={() => {
copyTextToClipboard(
() =>
new Promise((resolve, reject) => {
const data = api.getDataAsCsv({
columnKeys: [colId],
suppressQuotes: true,
});
if (data) {
resolve(data);
} else {
reject();
}
}),
);
}}
>
<Icons.CopyOutlined iconSize="m" /> {t('Copy')}
</IconMenuItem>
{(pinnedLeft || pinnedRight) && (
<IconMenuItem onClick={() => pinColumn(null)}>
<Icons.UnlockOutlined iconSize="m" /> {t('Unpin')}
</IconMenuItem>
)}
{!pinnedLeft && (
<IconMenuItem onClick={() => pinColumn('left')}>
<Icons.VerticalRightOutlined iconSize="m" />
{t('Pin Left')}
</IconMenuItem>
)}
{!pinnedRight && (
<IconMenuItem onClick={() => pinColumn('right')}>
<Icons.VerticalLeftOutlined iconSize="m" />
{t('Pin Right')}
</IconMenuItem>
)}
<Menu.Divider />
<IconMenuItem
onClick={() => {
api.autoSizeColumns([colId]);
}}
>
<Icons.ColumnWidthOutlined iconSize="m" />
{t('Autosize Column')}
</IconMenuItem>
<IconMenuItem
onClick={() => {
api.setColumnsVisible([colId], false);
}}
disabled={api.getColumns()?.length === invisibleColumns.length + 1}
>
<Icons.EyeInvisibleOutlined iconSize="m" />
{t('Hide Column')}
</IconMenuItem>
{unHideAction}
</Menu>
}
/>
);
};
export default HeaderMenu;

View File

@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const PIVOT_COL_ID = '-1';
export enum GridSize {
Small = 'small',
Middle = 'middle',
}

View File

@ -0,0 +1,241 @@
/**
* 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 } from 'react';
import { Global } from '@emotion/react';
import { css, useTheme } from '@superset-ui/core';
import type { Column } from 'ag-grid-community';
import { AgGridReact, type AgGridReactProps } from 'ag-grid-react';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-quartz.css';
import copyTextToClipboard from 'src/utils/copy';
import ErrorBoundary from 'src/components/ErrorBoundary';
import { PIVOT_COL_ID, GridSize } from './constants';
import Header from './Header';
const gridComponents = {
agColumnHeader: Header,
};
export { GridSize };
export type ColDef = {
type: string;
field: string;
};
export interface TableProps<RecordType> {
/**
* Data that will populate the each row and map to the column key.
*/
data: RecordType[];
/**
* Table column definitions.
*/
columns: {
label: string;
headerName?: string;
width?: number;
comparator?: (valueA: string | number, valueB: string | number) => number;
render?: (value: any) => React.ReactNode;
}[];
size?: GridSize;
externalFilter?: AgGridReactProps['doesExternalFilterPass'];
height: number;
columnReorderable?: boolean;
sortable?: boolean;
enableActions?: boolean;
showRowNumber?: boolean;
usePagination?: boolean;
striped?: boolean;
}
const onSortChanged: AgGridReactProps['onSortChanged'] = ({ api }) =>
api.refreshCells();
function GridTable<RecordType extends object>({
data,
columns,
sortable = true,
columnReorderable,
height,
externalFilter,
showRowNumber,
enableActions,
size = GridSize.Middle,
striped,
}: TableProps<RecordType>) {
const theme = useTheme();
const isExternalFilterPresent = useCallback(
() => Boolean(externalFilter),
[externalFilter],
);
const rowIndexLength = `${data.length}}`.length;
const onKeyDown: AgGridReactProps<Record<string, any>>['onCellKeyDown'] =
useCallback(({ event, column, data, value, api }) => {
if (
!document.getSelection?.()?.toString?.() &&
event &&
event.key === 'c' &&
(event.ctrlKey || event.metaKey)
) {
const columns =
column.getColId() === PIVOT_COL_ID
? api
.getAllDisplayedColumns()
.filter((column: Column) => column.getColId() !== PIVOT_COL_ID)
: [column];
const record =
column.getColId() === PIVOT_COL_ID
? [
columns.map((column: Column) => column.getColId()).join('\t'),
columns
.map((column: Column) => data?.[column.getColId()])
.join('\t'),
].join('\n')
: String(value);
copyTextToClipboard(() => Promise.resolve(record));
}
}, []);
const columnDefs = useMemo(
() =>
[
{
field: PIVOT_COL_ID,
valueGetter: 'node.rowIndex+1',
cellClass: 'locked-col',
width: 20 + rowIndexLength * 6,
suppressNavigable: true,
resizable: false,
pinned: 'left' as const,
sortable: false,
...(columnReorderable && { suppressMovable: true }),
},
...columns.map(
(
{ label, headerName, width, render: cellRenderer, comparator },
index,
) => ({
field: label,
headerName,
cellRenderer,
sortable,
comparator,
...(index === columns.length - 1 && {
flex: 1,
width,
minWidth: 150,
}),
}),
),
].slice(showRowNumber ? 0 : 1),
[rowIndexLength, columnReorderable, columns, showRowNumber, sortable],
);
const defaultColDef: AgGridReactProps['defaultColDef'] = {
...(!columnReorderable && { suppressMovable: true }),
resizable: true,
sortable,
filter: Boolean(enableActions),
};
const rowHeight = theme.gridUnit * (size === GridSize.Middle ? 9 : 7);
return (
<ErrorBoundary>
<Global
styles={() => css`
#grid-table.ag-theme-quartz {
--ag-icon-font-family: agGridMaterial;
--ag-grid-size: ${theme.gridUnit}px;
--ag-font-size: ${theme.typography.sizes[
size === GridSize.Middle ? 'm' : 's'
]}px;
--ag-font-family: ${theme.typography.families.sansSerif};
--ag-row-height: ${rowHeight}px;
${!striped &&
`--ag-odd-row-background-color: ${theme.colors.grayscale.light5};`}
--ag-border-color: ${theme.colors.grayscale.light2};
--ag-row-border-color: ${theme.colors.grayscale.light2};
--ag-header-background-color: ${theme.colors.grayscale.light4};
}
#grid-table .ag-cell {
-webkit-font-smoothing: antialiased;
}
.locked-col {
background: var(--ag-row-border-color);
padding: 0;
text-align: center;
font-size: calc(var(--ag-font-size) * 0.9);
color: var(--ag-disabled-foreground-color);
}
.ag-row-hover .locked-col {
background: var(--ag-row-hover-color);
}
.ag-header-cell {
overflow: hidden;
}
& [role='columnheader']:hover .customHeaderAction {
display: block;
}
`}
/>
<div
id="grid-table"
className="ag-theme-quartz"
css={css`
width: 100%;
height: ${height}px;
`}
>
<AgGridReact
rowData={data}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
onSortChanged={onSortChanged}
isExternalFilterPresent={isExternalFilterPresent}
doesExternalFilterPass={externalFilter}
components={gridComponents}
gridOptions={{
enableCellTextSelection: true,
ensureDomOrder: true,
suppressFieldDotNotation: true,
headerHeight: rowHeight,
rowSelection: 'multiple',
rowHeight,
}}
onCellKeyDown={onKeyDown}
/>
</div>
</ErrorBoundary>
);
}
export default GridTable;

View File

@ -27,10 +27,13 @@ import {
BarChartOutlined, BarChartOutlined,
BellOutlined, BellOutlined,
BookOutlined, BookOutlined,
CaretDownOutlined,
CalendarOutlined, CalendarOutlined,
CaretUpOutlined,
CheckOutlined, CheckOutlined,
CheckSquareOutlined, CheckSquareOutlined,
CloseOutlined, CloseOutlined,
ColumnWidthOutlined,
CommentOutlined, CommentOutlined,
ConsoleSqlOutlined, ConsoleSqlOutlined,
CopyOutlined, CopyOutlined,
@ -38,6 +41,7 @@ import {
DatabaseOutlined, DatabaseOutlined,
DeleteFilled, DeleteFilled,
DownOutlined, DownOutlined,
DownloadOutlined,
EditOutlined, EditOutlined,
ExclamationCircleOutlined, ExclamationCircleOutlined,
EyeOutlined, EyeOutlined,
@ -65,8 +69,11 @@ import {
StopOutlined, StopOutlined,
SyncOutlined, SyncOutlined,
TagsOutlined, TagsOutlined,
UnlockOutlined,
UpOutlined, UpOutlined,
UserOutlined, UserOutlined,
VerticalLeftOutlined,
VerticalRightOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { StyledIcon } from './Icon'; import { StyledIcon } from './Icon';
import IconType from './IconType'; import IconType from './IconType';
@ -80,10 +87,13 @@ const AntdIcons = {
BarChartOutlined, BarChartOutlined,
BellOutlined, BellOutlined,
BookOutlined, BookOutlined,
CaretDownOutlined,
CalendarOutlined, CalendarOutlined,
CaretUpOutlined,
CheckOutlined, CheckOutlined,
CheckSquareOutlined, CheckSquareOutlined,
CloseOutlined, CloseOutlined,
ColumnWidthOutlined,
CommentOutlined, CommentOutlined,
ConsoleSqlOutlined, ConsoleSqlOutlined,
CopyOutlined, CopyOutlined,
@ -91,6 +101,7 @@ const AntdIcons = {
DatabaseOutlined, DatabaseOutlined,
DeleteFilled, DeleteFilled,
DownOutlined, DownOutlined,
DownloadOutlined,
EditOutlined, EditOutlined,
ExclamationCircleOutlined, ExclamationCircleOutlined,
EyeOutlined, EyeOutlined,
@ -118,8 +129,11 @@ const AntdIcons = {
StopOutlined, StopOutlined,
SyncOutlined, SyncOutlined,
TagsOutlined, TagsOutlined,
UnlockOutlined,
UpOutlined, UpOutlined,
UserOutlined, UserOutlined,
VerticalLeftOutlined,
VerticalRightOutlined,
}; };
const AntdEnhancedIcons = Object.keys(AntdIcons) const AntdEnhancedIcons = Object.keys(AntdIcons)