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)

Reply via email to