- )}
+
);
};
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 0000000000000..1f603c75f335b
--- /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();
+ 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();
+
+ // 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 0000000000000..7657dbe7b4a77
--- /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: () => ,
+}));
+
+jest.mock('src/components/Icons', () => ({
+ Sort: () => ,
+ SortAsc: () => ,
+ SortDesc: () => ,
+}));
+
+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();
+ expect(queryByText(mockedProps.displayName)).toBeInTheDocument();
+});
+
+test('sorts by clicking a column header', () => {
+ const { getByText, queryByTestId } = render();
+ 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();
+ 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(
+ ,
+ );
+ expect(queryByText(mockedProps.displayName)).toBeInTheDocument();
+ expect(queryByTestId('mock-dropdown')).not.toBeInTheDocument();
+});
+
+test('hide display name for PIVOT_COL_ID', () => {
+ const { queryByText } = render(
+ 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 0000000000000..a50677970707b
--- /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 = ({
+ 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(0);
+ const [invisibleColumns, setInvisibleColumns] = useState([]);
+ const [currentSort, setCurrentSort] = useState(null);
+ const [sortIndex, setSortIndex] = useState();
+ 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 && (
+
+
{displayName}
+ {enableSorting && (
+
+
+
+ {currentSort === 'asc' && (
+
+ )}
+ {currentSort === 'desc' && (
+
+ )}
+
+ {typeof sortIndex === 'number' && (
+ {sortIndex + 1}
+ )}
+
+ )}
+
+ )}
+ {enableFilterButton && colId && api && (
+
+ {colId && (
+
+ )}
+
+ )}
+ >
+ );
+};
+
+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 0000000000000..691cddb4aa74d
--- /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 }) => (
+