This is an automated email from the ASF dual-hosted git repository.
justinpark pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new f73d61a597 feat(sqllab): Replace FilterableTable by AgGrid Table
(#29900)
f73d61a597 is described below
commit f73d61a597fa111d404e8d621ba7ff5b77232000
Author: JUST.in DO IT <[email protected]>
AuthorDate: Thu Jan 30 16:43:22 2025 -0800
feat(sqllab): Replace FilterableTable by AgGrid Table (#29900)
---
superset-frontend/package-lock.json | 28 +++
superset-frontend/package.json | 2 +
.../SqlLab/components/ResultSet/ResultSet.test.tsx | 10 +-
.../FilterableTable/FilterableTable.test.tsx | 33 ++-
.../src/components/FilterableTable/index.tsx | 251 ++++++-------------
.../src/components/GridTable/GridTable.test.tsx | 66 +++++
.../src/components/GridTable/Header.test.tsx | 109 +++++++++
.../src/components/GridTable/Header.tsx | 200 ++++++++++++++++
.../src/components/GridTable/HeaderMenu.test.tsx | 266 +++++++++++++++++++++
.../src/components/GridTable/HeaderMenu.tsx | 247 +++++++++++++++++++
.../src/components/GridTable/constants.ts | 24 ++
.../src/components/GridTable/index.tsx | 241 +++++++++++++++++++
.../src/components/Icons/AntdEnhanced.tsx | 14 ++
13 files changed, 1287 insertions(+), 204 deletions(-)
diff --git a/superset-frontend/package-lock.json
b/superset-frontend/package-lock.json
index 8f9a07604a..75f9f39d16 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -56,6 +56,8 @@
"@visx/xychart": "^3.5.1",
"abortcontroller-polyfill": "^1.7.8",
"ace-builds": "^1.36.3",
+ "ag-grid-community": "32.2.1",
+ "ag-grid-react": "32.2.1",
"antd": "4.10.3",
"antd-v5": "npm:antd@^5.18.0",
"bootstrap": "^3.4.1",
@@ -14478,6 +14480,32 @@
"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": {
"version": "6.0.2",
"resolved":
"https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
diff --git a/superset-frontend/package.json b/superset-frontend/package.json
index 7edc690f35..ca423f2465 100644
--- a/superset-frontend/package.json
+++ b/superset-frontend/package.json
@@ -123,6 +123,8 @@
"@visx/xychart": "^3.5.1",
"abortcontroller-polyfill": "^1.7.8",
"ace-builds": "^1.36.3",
+ "ag-grid-community": "32.2.1",
+ "ag-grid-react": "32.2.1",
"antd": "4.10.3",
"antd-v5": "npm:antd@^5.18.0",
"bootstrap": "^3.4.1",
diff --git
a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
index 565cebe0a5..188946d4c7 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
@@ -354,7 +354,7 @@ describe('ResultSet', () => {
);
});
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 () => {
@@ -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', () => {
@@ -400,7 +400,7 @@ describe('ResultSet', () => {
name: /fetch data preview/i,
}),
).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', () => {
@@ -429,7 +429,7 @@ describe('ResultSet', () => {
name: /refetch results/i,
}),
).toBeVisible();
- expect(screen.queryByRole('table')).not.toBeInTheDocument();
+ expect(screen.queryByRole('treegrid')).not.toBeInTheDocument();
});
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(
screen.queryByRole('button', {
name: /fetch data preview/i,
diff --git
a/superset-frontend/src/components/FilterableTable/FilterableTable.test.tsx
b/superset-frontend/src/components/FilterableTable/FilterableTable.test.tsx
index b7ce92be3f..577d0760cb 100644
--- a/superset-frontend/src/components/FilterableTable/FilterableTable.test.tsx
+++ b/superset-frontend/src/components/FilterableTable/FilterableTable.test.tsx
@@ -38,7 +38,7 @@ describe('FilterableTable', () => {
const { getByRole, getByText } = render(
<FilterableTable {...mockedProps} />,
);
- expect(getByRole('table')).toBeInTheDocument();
+ expect(getByRole('treegrid')).toBeInTheDocument();
mockedProps.data.forEach(({ b: columnBContent }) => {
expect(getByText(columnBContent)).toBeInTheDocument();
});
@@ -78,11 +78,10 @@ describe('FilterableTable sorting - RTL', () => {
};
render(<FilterableTable {...stringProps} />);
- const stringColumn = within(screen.getByRole('table'))
+ const stringColumn = within(screen.getByRole('treegrid'))
.getByText('columnA')
- .closest('th');
- // Antd 4.x Table does not follow the table role structure. Need a hacky
selector to point the cell item
- const gridCells = screen.getByTitle('Bravo').closest('.virtual-grid');
+ .closest('[role=button]');
+ const gridCells = screen.getByText('Bravo').closest('[role=rowgroup]');
// Original order
expect(gridCells?.textContent).toEqual(
@@ -124,10 +123,10 @@ describe('FilterableTable sorting - RTL', () => {
};
render(<FilterableTable {...integerProps} />);
- const integerColumn = within(screen.getByRole('table'))
+ const integerColumn = within(screen.getByRole('treegrid'))
.getByText('columnB')
- .closest('th');
- const gridCells = screen.getByTitle('21').closest('.virtual-grid');
+ .closest('[role=button]');
+ const gridCells = screen.getByText('21').closest('[role=rowgroup]');
// Original order
expect(gridCells?.textContent).toEqual(['21', '0', '623'].join(''));
@@ -159,10 +158,10 @@ describe('FilterableTable sorting - RTL', () => {
};
render(<FilterableTable {...floatProps} />);
- const floatColumn = within(screen.getByRole('table'))
+ const floatColumn = within(screen.getByRole('treegrid'))
.getByText('columnC')
- .closest('th');
- const gridCells = screen.getByTitle('45.67').closest('.virtual-grid');
+ .closest('[role=button]');
+ const gridCells = screen.getByText('45.67').closest('[role=rowgroup]');
// Original order
expect(gridCells?.textContent).toEqual(
@@ -214,10 +213,10 @@ describe('FilterableTable sorting - RTL', () => {
};
render(<FilterableTable {...mixedFloatProps} />);
- const mixedFloatColumn = within(screen.getByRole('table'))
+ const mixedFloatColumn = within(screen.getByRole('treegrid'))
.getByText('columnD')
- .closest('th');
- const gridCells = screen.getByTitle('48710.92').closest('.virtual-grid');
+ .closest('[role=button]');
+ const gridCells = screen.getByText('48710.92').closest('[role=rowgroup]');
// Original order
expect(gridCells?.textContent).toEqual(
@@ -312,10 +311,10 @@ describe('FilterableTable sorting - RTL', () => {
};
render(<FilterableTable {...dsProps} />);
- const dsColumn = within(screen.getByRole('table'))
+ const dsColumn = within(screen.getByRole('treegrid'))
.getByText('columnDS')
- .closest('th');
- const gridCells = screen.getByTitle('2021-01-01').closest('.virtual-grid');
+ .closest('[role=button]');
+ const gridCells =
screen.getByText('2021-01-01').closest('[role=rowgroup]');
// Original order
expect(gridCells?.textContent).toEqual(
diff --git a/superset-frontend/src/components/FilterableTable/index.tsx
b/superset-frontend/src/components/FilterableTable/index.tsx
index 04429ebf84..920e7d68cd 100644
--- a/superset-frontend/src/components/FilterableTable/index.tsx
+++ b/superset-frontend/src/components/FilterableTable/index.tsx
@@ -16,55 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
-import _JSONbig from 'json-bigint';
-import { useEffect, useRef, useState, useMemo } from 'react';
-import { getMultipleTextDimensions, styled } from '@superset-ui/core';
-import { useDebounceValue } from 'src/hooks/useDebounceValue';
+import { useMemo, useRef, useCallback } from 'react';
+import { styled } from '@superset-ui/core';
import { useCellContentParser } from './useCellContentParser';
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,
// exponential notation, NaN, and Infinity.
// See https://stackoverflow.com/a/30987109 for more details
const ONLY_NUMBER_REGEX = /^(NaN|-?((\d*\.\d+|\d+)([Ee][+-]?\d+)?|Infinity))$/;
const StyledFilterableTable = styled.div`
- ${({ theme }) => `
- height: 100%;
- 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;
- }
- `}
+ height: 100%;
+ overflow: hidden;
`;
type CellDataType = string | number | null;
@@ -79,12 +44,38 @@ export interface FilterableTableProps {
overscanColumnCount?: number;
overscanRowCount?: number;
rowHeight?: number;
- // need antd 5.0 to support striped color pattern
striped?: boolean;
expandedColumns?: string[];
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 = ({
orderedColumnKeys,
data,
@@ -92,83 +83,13 @@ const FilterableTable = ({
filterText = '',
expandedColumns = [],
allowHTML = true,
+ striped,
}: 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({
columnKeys: orderedColumnKeys,
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 values: string[] = [];
Object.keys(row).forEach(key => {
@@ -188,86 +109,52 @@ const FilterableTable = ({
return values.some(v => v.includes(lowerCaseText));
};
- // Parse any numbers from strings so they'll sort correctly
- 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(
+ const columns = useMemo(
() =>
- keyword ? list.filter((row: Datum) => hasMatch(keyword, row)) : list,
- [list, keyword],
+ orderedColumnKeys.map(key => ({
+ 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
- // and the height of the table container if the content overflows
- const totalTableHeight =
- container.current && totalTableWidth.current >
container.current.clientWidth
- ? height - SCROLL_BAR_HEIGHT
- : height;
+ const keyword = useRef<string | undefined>(filterText);
+ keyword.current = filterText;
- const columns = orderedColumnKeys.map(key => ({
- key,
- title: key,
- dataIndex: key,
- width: widthsForColumnsByKey[key],
- sorter: (a: Datum, b: Datum) => sortResults(key, a, b),
- render: (text: CellDataType) =>
- renderResultCell({
- cellData: text,
- columnKey: key,
- allowHTML,
- getCellContent,
- }),
- }));
+ const keywordFilter = useCallback(node => {
+ if (keyword.current && node.data) {
+ return hasMatch(keyword.current, node.data);
+ }
+ return true;
+ }, []);
return (
<StyledFilterableTable
className="filterable-table-container"
data-test="table-container"
- ref={container}
>
- {fitted && (
- <Table
- loading={filterText !== keyword}
- size={TableSize.Small}
- height={totalTableHeight + 42}
- usePagination={false}
- columns={columns}
- data={filteredList}
- childrenColumnName=""
- virtualize
- bordered
- />
- )}
+ <GridTable
+ size={GridSize.Small}
+ height={height}
+ usePagination={false}
+ columns={columns}
+ data={data}
+ externalFilter={keywordFilter}
+ showRowNumber
+ striped={striped}
+ enableActions
+ columnReorderable
+ />
</StyledFilterableTable>
);
};
diff --git a/superset-frontend/src/components/GridTable/GridTable.test.tsx
b/superset-frontend/src/components/GridTable/GridTable.test.tsx
new file mode 100644
index 0000000000..1f603c75f3
--- /dev/null
+++ b/superset-frontend/src/components/GridTable/GridTable.test.tsx
@@ -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(''));
+});
diff --git a/superset-frontend/src/components/GridTable/Header.test.tsx
b/superset-frontend/src/components/GridTable/Header.test.tsx
new file mode 100644
index 0000000000..7657dbe7b4
--- /dev/null
+++ b/superset-frontend/src/components/GridTable/Header.test.tsx
@@ -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();
+});
diff --git a/superset-frontend/src/components/GridTable/Header.tsx
b/superset-frontend/src/components/GridTable/Header.tsx
new file mode 100644
index 0000000000..a506779707
--- /dev/null
+++ b/superset-frontend/src/components/GridTable/Header.tsx
@@ -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;
diff --git a/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx
b/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx
new file mode 100644
index 0000000000..691cddb4aa
--- /dev/null
+++ b/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx
@@ -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);
+ });
+});
diff --git a/superset-frontend/src/components/GridTable/HeaderMenu.tsx
b/superset-frontend/src/components/GridTable/HeaderMenu.tsx
new file mode 100644
index 0000000000..648c5e2138
--- /dev/null
+++ b/superset-frontend/src/components/GridTable/HeaderMenu.tsx
@@ -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;
diff --git a/superset-frontend/src/components/GridTable/constants.ts
b/superset-frontend/src/components/GridTable/constants.ts
new file mode 100644
index 0000000000..42f88fd8cc
--- /dev/null
+++ b/superset-frontend/src/components/GridTable/constants.ts
@@ -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',
+}
diff --git a/superset-frontend/src/components/GridTable/index.tsx
b/superset-frontend/src/components/GridTable/index.tsx
new file mode 100644
index 0000000000..1311148adc
--- /dev/null
+++ b/superset-frontend/src/components/GridTable/index.tsx
@@ -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;
diff --git a/superset-frontend/src/components/Icons/AntdEnhanced.tsx
b/superset-frontend/src/components/Icons/AntdEnhanced.tsx
index 3032a8a826..eadd0ea2ed 100644
--- a/superset-frontend/src/components/Icons/AntdEnhanced.tsx
+++ b/superset-frontend/src/components/Icons/AntdEnhanced.tsx
@@ -27,10 +27,13 @@ import {
BarChartOutlined,
BellOutlined,
BookOutlined,
+ CaretDownOutlined,
CalendarOutlined,
+ CaretUpOutlined,
CheckOutlined,
CheckSquareOutlined,
CloseOutlined,
+ ColumnWidthOutlined,
CommentOutlined,
ConsoleSqlOutlined,
CopyOutlined,
@@ -38,6 +41,7 @@ import {
DatabaseOutlined,
DeleteFilled,
DownOutlined,
+ DownloadOutlined,
EditOutlined,
ExclamationCircleOutlined,
EyeOutlined,
@@ -65,8 +69,11 @@ import {
StopOutlined,
SyncOutlined,
TagsOutlined,
+ UnlockOutlined,
UpOutlined,
UserOutlined,
+ VerticalLeftOutlined,
+ VerticalRightOutlined,
} from '@ant-design/icons';
import { StyledIcon } from './Icon';
import IconType from './IconType';
@@ -80,10 +87,13 @@ const AntdIcons = {
BarChartOutlined,
BellOutlined,
BookOutlined,
+ CaretDownOutlined,
CalendarOutlined,
+ CaretUpOutlined,
CheckOutlined,
CheckSquareOutlined,
CloseOutlined,
+ ColumnWidthOutlined,
CommentOutlined,
ConsoleSqlOutlined,
CopyOutlined,
@@ -91,6 +101,7 @@ const AntdIcons = {
DatabaseOutlined,
DeleteFilled,
DownOutlined,
+ DownloadOutlined,
EditOutlined,
ExclamationCircleOutlined,
EyeOutlined,
@@ -118,8 +129,11 @@ const AntdIcons = {
StopOutlined,
SyncOutlined,
TagsOutlined,
+ UnlockOutlined,
UpOutlined,
UserOutlined,
+ VerticalLeftOutlined,
+ VerticalRightOutlined,
};
const AntdEnhancedIcons = Object.keys(AntdIcons)