From bbc5f1a18fef41a446895fc59fc6d7188ace3166 Mon Sep 17 00:00:00 2001 From: justinpark Date: Fri, 9 Aug 2024 11:26:58 +0900 Subject: [PATCH 01/12] feat(sqllab): Replace FilterableTable by AgGrid Table --- superset-frontend/package-lock.json | 34 +++ superset-frontend/package.json | 2 + .../FilterableTable/FilterableTable.test.tsx | 33 ++- .../src/components/FilterableTable/index.tsx | 250 +++++------------- .../components/GridTable/GridTable.test.tsx | 48 ++++ .../src/components/GridTable/Header.test.tsx | 85 ++++++ .../src/components/GridTable/Header.tsx | 181 +++++++++++++ .../components/GridTable/HeaderMenu.test.tsx | 195 ++++++++++++++ .../src/components/GridTable/HeaderMenu.tsx | 214 +++++++++++++++ .../src/components/GridTable/index.tsx | 238 +++++++++++++++++ .../src/components/Icons/AntdEnhanced.tsx | 12 + 11 files changed, 1093 insertions(+), 199 deletions(-) create mode 100644 superset-frontend/src/components/GridTable/GridTable.test.tsx create mode 100644 superset-frontend/src/components/GridTable/Header.test.tsx create mode 100644 superset-frontend/src/components/GridTable/Header.tsx create mode 100644 superset-frontend/src/components/GridTable/HeaderMenu.test.tsx create mode 100644 superset-frontend/src/components/GridTable/HeaderMenu.tsx create mode 100644 superset-frontend/src/components/GridTable/index.tsx diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 525f9e3c12ad7..f0ffdece88c91 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -60,6 +60,8 @@ "@visx/xychart": "^3.5.1", "abortcontroller-polyfill": "^1.7.5", "ace-builds": "^1.35.4", + "ag-grid-community": "31.0.3", + "ag-grid-react": "31.0.3", "antd": "4.10.3", "antd-v5": "npm:antd@^5.18.0", "babel-plugin-typescript-to-proptypes": "^2.0.0", @@ -15672,6 +15674,24 @@ "node": ">= 0.12.0" } }, + "node_modules/ag-grid-community": { + "version": "31.0.3", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-31.0.3.tgz", + "integrity": "sha512-k81YmLaOQOab9BavYD+Pw2smZSl6TXOJqj9hRuf70XQl3EknOHCGcra7joJxZRJLMKE/HdR+u33TNyX4TCuWfg==" + }, + "node_modules/ag-grid-react": { + "version": "31.0.3", + "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-31.0.3.tgz", + "integrity": "sha512-CE4Z5Rdb0H3MFBhmge9854AyDvI4DoG7ZoUJRFB1p2pu5ANVe6tv7cwEI/ugHemEXRRP6s5uvW+ndv0Q6x1/+Q==", + "dependencies": { + "ag-grid-community": "~31.0.3", + "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", "dev": true, @@ -68933,6 +68953,20 @@ "version": "1.1.2", "dev": true }, + "ag-grid-community": { + "version": "31.0.3", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-31.0.3.tgz", + "integrity": "sha512-k81YmLaOQOab9BavYD+Pw2smZSl6TXOJqj9hRuf70XQl3EknOHCGcra7joJxZRJLMKE/HdR+u33TNyX4TCuWfg==" + }, + "ag-grid-react": { + "version": "31.0.3", + "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-31.0.3.tgz", + "integrity": "sha512-CE4Z5Rdb0H3MFBhmge9854AyDvI4DoG7ZoUJRFB1p2pu5ANVe6tv7cwEI/ugHemEXRRP6s5uvW+ndv0Q6x1/+Q==", + "requires": { + "ag-grid-community": "~31.0.3", + "prop-types": "^15.8.1" + } + }, "agent-base": { "version": "6.0.2", "dev": true, diff --git a/superset-frontend/package.json b/superset-frontend/package.json index cdeec7d4fe99c..2ed17ad9f1d56 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -126,6 +126,8 @@ "@visx/xychart": "^3.5.1", "abortcontroller-polyfill": "^1.7.5", "ace-builds": "^1.35.4", + "ag-grid-community": "31.0.3", + "ag-grid-react": "31.0.3", "antd": "4.10.3", "antd-v5": "npm:antd@^5.18.0", "babel-plugin-typescript-to-proptypes": "^2.0.0", diff --git a/superset-frontend/src/components/FilterableTable/FilterableTable.test.tsx b/superset-frontend/src/components/FilterableTable/FilterableTable.test.tsx index 415d7f053dfce..c4d2dbacdc64e 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( , ); - 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(); - 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(); - 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(); - 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(); - 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(); - 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 f77f4aede0eb7..c30574b93bbb9 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[]): Datum[] => - data.map(row => { - const newRow = {}; - 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(() => formatTableData(data)); - const getCellContent = useCellContentParser({ columnKeys: orderedColumnKeys, expandedColumns, }); - const getWidthsForColumns = () => { - const PADDING = 50; // accounts for cell padding and width of sorting icon - const widthsByColumnKey = {}; - 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>(() => - getWidthsForColumns(), - ); - - const totalTableWidth = useRef( - orderedColumnKeys - .map(key => widthsForColumnsByKey[key]) - .reduce((curr, next) => curr + next), - ); - const container = useRef(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,51 @@ 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(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 ( - {fitted && ( - - )} + ); }; 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..5b4e26ac32475 --- /dev/null +++ b/superset-frontend/src/components/GridTable/GridTable.test.tsx @@ -0,0 +1,48 @@ +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)).toBeTruthy(); + }); +}); + +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..086960df0d3dc --- /dev/null +++ b/superset-frontend/src/components/GridTable/Header.test.tsx @@ -0,0 +1,85 @@ +import type { Column, GridApi } from 'ag-grid-community'; +import { act, fireEvent, render } from 'spec/helpers/testing-library'; +import Header, { PIVOT_COL_ID } from './Header'; + +jest.mock('src/components/Dropdown', () => ({ + Dropdown: () =>
, +})); + +jest.mock('src/components/Icons', () => ({ + Sort: () =>
, + SortAsc: () =>
, + SortDesc: () =>
, +})); + +class MockApi extends EventTarget { + getAllDisplayedColumns() { + return []; + } +} + +const mockedProps = { + displayName: 'test column', + setSort: jest.fn(), + 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)).toBeTruthy(); +}); + +test('sorts by clicking a column header', () => { + const { getByText, queryByTestId } = render(
); + fireEvent.click(getByText(mockedProps.displayName)); + expect(mockedProps.setSort).toBeCalledWith('asc', false); + expect(queryByTestId('mock-sort-asc')).toBeInTheDocument(); + fireEvent.click(getByText(mockedProps.displayName)); + expect(mockedProps.setSort).toBeCalledWith('desc', false); + expect(queryByTestId('mock-sort-desc')).toBeInTheDocument(); + fireEvent.click(getByText(mockedProps.displayName)); + expect(mockedProps.setSort).toBeCalledWith(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 enableMenu is false', () => { + const { queryByText, queryByTestId } = render( +
, + ); + expect(queryByText(mockedProps.displayName)).toBeTruthy(); + expect(queryByTestId('mock-dropdown')).toBeFalsy(); +}); + +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..bfcf29b77f5ad --- /dev/null +++ b/superset-frontend/src/components/GridTable/Header.tsx @@ -0,0 +1,181 @@ +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 HeaderMenu from './HeaderMenu'; + +interface Params { + enableMenu?: boolean; + enableSorting?: boolean; + displayName: string; + column: Column; + api: GridApi; + setSort: (sort: string | null, multiSort: boolean) => void; +} + +export const PIVOT_COL_ID = '-1'; +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; + right: auto; + left: ${({ theme }) => theme.gridUnit * 2.5 - 1}px; + } + & .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 = ({ + enableMenu, + 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} + )} + + )} +
+ )} + {enableMenu && 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..159a29a355dd7 --- /dev/null +++ b/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx @@ -0,0 +1,195 @@ +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 }) => ( +
{children}
+ ); + Menu.Item = ({ + children, + onClick, + }: { + children: React.ReactChild; + onClick: () => void; + }) => ( + + ); + Menu.SubMenu = ({ + title, + children, + }: { + title: React.ReactNode; + children: React.ReactNode; + }) => ( +
+ {title} + +
+ ); + Menu.Divider = () =>
; + return { Menu }; +}); + +jest.mock('src/components/Icons', () => ({ + DownloadOutlined: () =>
, + CopyOutlined: () =>
, + UnlockOutlined: () =>
, + VerticalRightOutlined: () =>
, + VerticalLeftOutlined: () =>
, + EyeInvisibleOutlined: () =>
, + EyeOutlined: () =>
, +})); + +jest.mock('src/components/Dropdown', () => ({ + Dropdown: ({ overlay }: { overlay: React.ReactChild }) => ( +
{overlay}
+ ), +})); + +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 = { + setColumnPinned: jest.fn(), + getColumns: jest.fn().mockReturnValue([]), + getDataAsCsv: jest.fn().mockReturnValue('csv'), + exportDataAsCsv: jest.fn().mockReturnValue('csv'), + getAllDisplayedColumns: jest.fn().mockReturnValue([]), + setColumnsVisible: jest.fn(), + setColumnVisible: jest.fn(), +} as any as GridApi; + +const mockedProps = { + colId: 'column1', + invisibleColumns: [], + api: mockGridApi, + onVisibleChange: jest.fn(), +}; + +afterEach(() => { + (mockGridApi.setColumnPinned as jest.Mock).mockClear(); + (mockGridApi.setColumnVisible as jest.Mock).mockClear(); +}); + +test('renders copy data', () => { + const { queryByText } = render(); + expect(queryByText('Copy')).toBeTruthy(); +}); + +test('renders buttons pinning both side', () => { + const { queryByText, getByText } = render(); + expect(queryByText('Pin Left')).toBeTruthy(); + expect(queryByText('Pin Right')).toBeTruthy(); + fireEvent.click(getByText('Pin Left')); + expect(mockGridApi.setColumnPinned).toHaveBeenCalledTimes(1); + expect(mockGridApi.setColumnPinned).toHaveBeenCalledWith( + mockedProps.colId, + 'left', + ); + fireEvent.click(getByText('Pin Right')); + expect(mockGridApi.setColumnPinned).toHaveBeenLastCalledWith( + mockedProps.colId, + 'right', + ); +}); + +test('renders unpin on pinned left', () => { + const { queryByText, getByText } = render( + , + ); + expect(queryByText('Pin Left')).toBeFalsy(); + expect(queryByText('Unpin')).toBeTruthy(); + fireEvent.click(getByText('Unpin')); + expect(mockGridApi.setColumnPinned).toHaveBeenCalledTimes(1); + expect(mockGridApi.setColumnPinned).toHaveBeenCalledWith( + mockedProps.colId, + null, + ); +}); + +test('renders unpin on pinned right', () => { + const { queryByText } = render(); + expect(queryByText('Pin Right')).toBeFalsy(); + expect(queryByText('Unpin')).toBeTruthy(); +}); + +test('renders unhide when invisible column exists', async () => { + const { queryByText } = render( + , + ); + expect(queryByText('Unhide')).toBeTruthy(); + const unhideColumnsButton = await screen.findByText('column2'); + fireEvent.click(unhideColumnsButton); + expect(mockGridApi.setColumnVisible).toHaveBeenCalledTimes(1); + expect(mockGridApi.setColumnVisible).toHaveBeenCalledWith('column2', true); +}); + +describe('for main menu', () => { + test('renders Copy to Clipboard', async () => { + const { getByText, queryByTestId } = render( + , + ); + expect(queryByTestId('mock-Divider')).not.toBeInTheDocument(); + 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(); + fireEvent.click(getByText('Download to CSV')); + await waitFor(() => + expect(mockGridApi.exportDataAsCsv).toHaveBeenCalledTimes(1), + ); + expect(mockGridApi.exportDataAsCsv).toHaveBeenCalledWith({ + columnKeys: [], + }); + }); + + test('renders all unhide all hidden columns when multiple invisible columns exist', async () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId('mock-Divider')).toBeInTheDocument(); + const unhideColumnsButton = await screen.findByText( + `All ${2} hidden columns`, + ); + fireEvent.click(unhideColumnsButton); + expect(mockGridApi.setColumnsVisible).toHaveBeenCalledTimes(1); + expect(mockGridApi.setColumnsVisible).toHaveBeenCalledWith( + [mockInvisibleColumn, mockInvisibleColumn3], + true, + ); + }); +}); diff --git a/superset-frontend/src/components/GridTable/HeaderMenu.tsx b/superset-frontend/src/components/GridTable/HeaderMenu.tsx new file mode 100644 index 0000000000000..177fc43a53244 --- /dev/null +++ b/superset-frontend/src/components/GridTable/HeaderMenu.tsx @@ -0,0 +1,214 @@ +import { useCallback } from 'react'; +import { css, 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'; + +type Params = { + colId: string; + column?: Column; + api: GridApi; + pinnedLeft?: boolean; + pinnedRight?: boolean; + invisibleColumns: Column[]; + isMain?: boolean; + onVisibleChange: DropdownProps['onVisibleChange']; +}; + +const HeaderMenu: React.FC = ({ + colId, + api, + pinnedLeft, + pinnedRight, + invisibleColumns, + isMain, + onVisibleChange, +}: Params) => { + const pinColumn = useCallback( + (pinLoc: ColumnPinnedType) => { + api.setColumnPinned(colId, pinLoc); + }, + [api, colId], + ); + + const unHideAction = invisibleColumns.length > 0 && ( + + + {t('Unhide')} + + } + > + {invisibleColumns.length > 1 && ( + { + api.setColumnsVisible(invisibleColumns, true); + }} + > + {t('All %s hidden columns', invisibleColumns.length)} + + )} + {invisibleColumns.map(c => ( + { + api.setColumnVisible(c.getColId(), true); + }} + > + {c.getColDef().headerName} + + ))} + + ); + + if (isMain) { + return ( + + { + 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(); + } + }), + ); + }} + css={css` + display: flex; + align-items: center; + `} + > + {t('Copy the current data')} + + { + api.exportDataAsCsv({ + columnKeys: api + .getAllDisplayedColumns() + .map(c => c.getColId()) + .filter(id => id !== colId), + }); + }} + css={css` + display: flex; + align-items: center; + `} + > + {t('Download to CSV')} + + {invisibleColumns.length > 0 && } + {unHideAction} + + } + /> + ); + } + + return ( + + { + copyTextToClipboard( + () => + new Promise((resolve, reject) => { + const data = api.getDataAsCsv({ + columnKeys: [colId], + suppressQuotes: true, + }); + if (data) { + resolve(data); + } else { + reject(); + } + }), + ); + }} + css={css` + display: flex; + align-items: center; + `} + > + {t('Copy')} + + {(pinnedLeft || pinnedRight) && ( + pinColumn(null)} + css={css` + display: flex; + align-items: center; + `} + > + {t('Unpin')} + + )} + {!pinnedLeft && ( + pinColumn('left')} + css={css` + display: flex; + align-items: center; + `} + > + + {t('Pin Left')} + + )} + {!pinnedRight && ( + pinColumn('right')} + css={css` + display: flex; + align-items: center; + `} + > + + {t('Pin Right')} + + )} + + { + api.setColumnVisible(colId, false); + }} + css={css` + display: flex; + align-items: center; + `} + disabled={api.getColumns()?.length === invisibleColumns.length + 1} + > + + {t('Hide Column')} + + {unHideAction} + + } + /> + ); +}; + +export default HeaderMenu; diff --git a/superset-frontend/src/components/GridTable/index.tsx b/superset-frontend/src/components/GridTable/index.tsx new file mode 100644 index 0000000000000..16957ddfa8f8f --- /dev/null +++ b/superset-frontend/src/components/GridTable/index.tsx @@ -0,0 +1,238 @@ +/** + * 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, AgReactUiProps } from 'ag-grid-react'; + +import 'ag-grid-community/styles/ag-grid.css'; +import 'ag-grid-community/styles/ag-theme-alpine.css'; + +import copyTextToClipboard from 'src/utils/copy'; +import ErrorBoundary from 'src/components/ErrorBoundary'; + +import Header, { PIVOT_COL_ID } from './Header'; + +const gridComponents = { + agColumnHeader: Header, +}; + +export enum GridSize { + Small = 'small', + Middle = 'middle', +} + +export type ColDef = { + type: string; + field: string; +}; + +export interface TableProps { + /** + * 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?: AgReactUiProps['doesExternalFilterPass']; + + height: number; + + cellSelectable?: boolean; + + sortable?: boolean; + + enableActions?: boolean; + + showRowNumber?: boolean; + + usePagination?: boolean; + + striped?: boolean; +} + +const onSortChanged: AgReactUiProps['onSortChanged'] = ({ api }) => + api.refreshCells(); + +function GridTable({ + data, + columns, + sortable = true, + height, + externalFilter, + showRowNumber, + enableActions, + size = GridSize.Middle, + striped, +}: TableProps) { + const theme = useTheme(); + const isExternalFilterPresent = useCallback( + () => Boolean(externalFilter), + [externalFilter], + ); + const rowIndexLength = `${data.length}}`.length; + const onKeyDown: AgReactUiProps>['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, + }, + ...columns.map( + ( + { label, headerName, width, render: cellRenderer, comparator }, + index, + ) => ({ + field: label, + headerName, + cellRenderer, + sortable, + comparator, + ...(index === columns.length - 1 && { + flex: 1, + width, + minWidth: 200, + }), + }), + ), + ].slice(showRowNumber ? 0 : 1), + [columns, sortable, showRowNumber, rowIndexLength], + ); + const defaultColDef: AgReactUiProps['defaultColDef'] = { + suppressMovable: true, + resizable: true, + sortable, + filter: Boolean(enableActions), + }; + + const rowHeight = theme.gridUnit * (size === GridSize.Middle ? 9 : 7); + + return ( + + css` + #grid-table.ag-theme-alpine { + --ag-icon-font-family: agGridMaterial; + --ag-grid-size: ${theme.gridUnit}px; + --ag-font-size: ${theme.typography.sizes.s - 1}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-header-background-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; + } + `} + /> +
+ +
+
+ ); +} + +export default GridTable; diff --git a/superset-frontend/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/src/components/Icons/AntdEnhanced.tsx index ce19dd862f834..f25a0c3940ccd 100644 --- a/superset-frontend/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/src/components/Icons/AntdEnhanced.tsx @@ -27,7 +27,9 @@ import { BarChartOutlined, BellOutlined, BookOutlined, + CaretDownOutlined, CalendarOutlined, + CaretUpOutlined, CheckOutlined, CheckSquareOutlined, CloseOutlined, @@ -38,6 +40,7 @@ import { DatabaseOutlined, DeleteFilled, DownOutlined, + DownloadOutlined, EditOutlined, ExclamationCircleOutlined, EyeOutlined, @@ -64,8 +67,11 @@ import { StopOutlined, SyncOutlined, TagsOutlined, + UnlockOutlined, UpOutlined, UserOutlined, + VerticalLeftOutlined, + VerticalRightOutlined, } from '@ant-design/icons'; import { StyledIcon } from './Icon'; import IconType from './IconType'; @@ -79,7 +85,9 @@ const AntdIcons = { BarChartOutlined, BellOutlined, BookOutlined, + CaretDownOutlined, CalendarOutlined, + CaretUpOutlined, CheckOutlined, CheckSquareOutlined, CloseOutlined, @@ -90,6 +98,7 @@ const AntdIcons = { DatabaseOutlined, DeleteFilled, DownOutlined, + DownloadOutlined, EditOutlined, ExclamationCircleOutlined, EyeOutlined, @@ -116,8 +125,11 @@ const AntdIcons = { StopOutlined, SyncOutlined, TagsOutlined, + UnlockOutlined, UpOutlined, UserOutlined, + VerticalLeftOutlined, + VerticalRightOutlined, }; const AntdEnhancedIcons = Object.keys(AntdIcons) From 4a0e8918c173da6e122a6eb4247ad84defae0a4b Mon Sep 17 00:00:00 2001 From: justinpark Date: Fri, 9 Aug 2024 11:43:51 +0900 Subject: [PATCH 02/12] missing license --- .../components/GridTable/GridTable.test.tsx | 18 ++++++++++++++++++ .../src/components/GridTable/Header.test.tsx | 18 ++++++++++++++++++ .../src/components/GridTable/Header.tsx | 18 ++++++++++++++++++ .../components/GridTable/HeaderMenu.test.tsx | 18 ++++++++++++++++++ .../src/components/GridTable/HeaderMenu.tsx | 18 ++++++++++++++++++ 5 files changed, 90 insertions(+) diff --git a/superset-frontend/src/components/GridTable/GridTable.test.tsx b/superset-frontend/src/components/GridTable/GridTable.test.tsx index 5b4e26ac32475..8d38283ee4057 100644 --- a/superset-frontend/src/components/GridTable/GridTable.test.tsx +++ b/superset-frontend/src/components/GridTable/GridTable.test.tsx @@ -1,3 +1,21 @@ +/** + * 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 '.'; diff --git a/superset-frontend/src/components/GridTable/Header.test.tsx b/superset-frontend/src/components/GridTable/Header.test.tsx index 086960df0d3dc..3163ed2b670ec 100644 --- a/superset-frontend/src/components/GridTable/Header.test.tsx +++ b/superset-frontend/src/components/GridTable/Header.test.tsx @@ -1,3 +1,21 @@ +/** + * 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, { PIVOT_COL_ID } from './Header'; diff --git a/superset-frontend/src/components/GridTable/Header.tsx b/superset-frontend/src/components/GridTable/Header.tsx index bfcf29b77f5ad..80929801769d5 100644 --- a/superset-frontend/src/components/GridTable/Header.tsx +++ b/superset-frontend/src/components/GridTable/Header.tsx @@ -1,3 +1,21 @@ +/** + * 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'; diff --git a/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx b/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx index 159a29a355dd7..9afd20fb395e8 100644 --- a/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx +++ b/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx @@ -1,3 +1,21 @@ +/** + * 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, diff --git a/superset-frontend/src/components/GridTable/HeaderMenu.tsx b/superset-frontend/src/components/GridTable/HeaderMenu.tsx index 177fc43a53244..4e04bcaae3502 100644 --- a/superset-frontend/src/components/GridTable/HeaderMenu.tsx +++ b/superset-frontend/src/components/GridTable/HeaderMenu.tsx @@ -1,3 +1,21 @@ +/** + * 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 { css, t } from '@superset-ui/core'; import type { Column, ColumnPinnedType, GridApi } from 'ag-grid-community'; From 1fb32f36d2fd5d6297677b45c3a2ed2e53703d68 Mon Sep 17 00:00:00 2001 From: justinpark Date: Fri, 9 Aug 2024 21:52:55 +0900 Subject: [PATCH 03/12] fix unit test --- .../src/SqlLab/components/ResultSet/ResultSet.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx index 9c04fee8e7186..aae626cf235bf 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx @@ -339,7 +339,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 () => { @@ -357,7 +357,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', () => { @@ -385,7 +385,7 @@ describe('ResultSet', () => { name: /fetch data preview/i, }), ).toBeVisible(); - expect(screen.queryByRole('table')).toBe(null); + expect(screen.queryByRole('treegrid')).toBe(null); }); test('Async queries - renders "Refetch results" button when a query has no results', () => { @@ -414,7 +414,7 @@ describe('ResultSet', () => { name: /refetch results/i, }), ).toBeVisible(); - expect(screen.queryByRole('table')).toBe(null); + expect(screen.queryByRole('treegrid')).toBe(null); }); test('Async queries - renders on the first call', () => { @@ -434,7 +434,7 @@ describe('ResultSet', () => { }, }), ); - expect(screen.getByRole('table')).toBeVisible(); + expect(screen.getByRole('treegrid')).toBeVisible(); expect( screen.queryByRole('button', { name: /fetch data preview/i, From ffd704fc50a6ef0e67bb09a0750ae7dfc9f6d715 Mon Sep 17 00:00:00 2001 From: justinpark Date: Sun, 11 Aug 2024 19:09:17 +0900 Subject: [PATCH 04/12] fix unit test --- .../src/components/GridTable/Header.test.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/superset-frontend/src/components/GridTable/Header.test.tsx b/superset-frontend/src/components/GridTable/Header.test.tsx index 3163ed2b670ec..3045616e35366 100644 --- a/superset-frontend/src/components/GridTable/Header.test.tsx +++ b/superset-frontend/src/components/GridTable/Header.test.tsx @@ -34,11 +34,16 @@ class MockApi extends EventTarget { getAllDisplayedColumns() { return []; } + + isDestroyed() { + return false; + } } const mockedProps = { displayName: 'test column', setSort: jest.fn(), + enableSorting: true, column: { getColId: () => '123', isPinnedLeft: () => true, @@ -57,13 +62,13 @@ test('renders display name for the column', () => { test('sorts by clicking a column header', () => { const { getByText, queryByTestId } = render(
); fireEvent.click(getByText(mockedProps.displayName)); - expect(mockedProps.setSort).toBeCalledWith('asc', false); + expect(mockedProps.setSort).toHaveBeenCalledWith('asc', false); expect(queryByTestId('mock-sort-asc')).toBeInTheDocument(); fireEvent.click(getByText(mockedProps.displayName)); - expect(mockedProps.setSort).toBeCalledWith('desc', false); + expect(mockedProps.setSort).toHaveBeenCalledWith('desc', false); expect(queryByTestId('mock-sort-desc')).toBeInTheDocument(); fireEvent.click(getByText(mockedProps.displayName)); - expect(mockedProps.setSort).toBeCalledWith(null, false); + expect(mockedProps.setSort).toHaveBeenCalledWith(null, false); expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument(); expect(queryByTestId('mock-sort-desc')).not.toBeInTheDocument(); }); From 33dcf8859ab019e57139e674a95d9c5df6010a4c Mon Sep 17 00:00:00 2001 From: justinpark Date: Thu, 15 Aug 2024 16:15:52 +0900 Subject: [PATCH 05/12] centering header main position --- superset-frontend/src/components/GridTable/Header.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/superset-frontend/src/components/GridTable/Header.tsx b/superset-frontend/src/components/GridTable/Header.tsx index 80929801769d5..a0ce2f4c0a4dc 100644 --- a/superset-frontend/src/components/GridTable/Header.tsx +++ b/superset-frontend/src/components/GridTable/Header.tsx @@ -60,8 +60,9 @@ const HeaderAction = styled.div` right: ${({ theme }) => theme.gridUnit * 3}px; &.main { margin: 0 auto; - right: auto; - left: ${({ theme }) => theme.gridUnit * 2.5 - 1}px; + left: 0; + right: 0; + width: 20px; } & .ant-dropdown-trigger { cursor: context-menu; From 869ec1e753c9324891fe4e12162ac395b3185252 Mon Sep 17 00:00:00 2001 From: justinpark Date: Tue, 20 Aug 2024 08:07:12 +0900 Subject: [PATCH 06/12] update theme by quartz --- superset-frontend/src/components/GridTable/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/superset-frontend/src/components/GridTable/index.tsx b/superset-frontend/src/components/GridTable/index.tsx index 16957ddfa8f8f..c97ec2ad51793 100644 --- a/superset-frontend/src/components/GridTable/index.tsx +++ b/superset-frontend/src/components/GridTable/index.tsx @@ -24,7 +24,7 @@ import type { Column } from 'ag-grid-community'; import { AgGridReact, AgReactUiProps } from 'ag-grid-react'; import 'ag-grid-community/styles/ag-grid.css'; -import 'ag-grid-community/styles/ag-theme-alpine.css'; +import 'ag-grid-community/styles/ag-theme-quartz.css'; import copyTextToClipboard from 'src/utils/copy'; import ErrorBoundary from 'src/components/ErrorBoundary'; @@ -172,7 +172,7 @@ function GridTable({ css` - #grid-table.ag-theme-alpine { + #grid-table.ag-theme-quartz { --ag-icon-font-family: agGridMaterial; --ag-grid-size: ${theme.gridUnit}px; --ag-font-size: ${theme.typography.sizes.s - 1}px; @@ -207,7 +207,7 @@ function GridTable({ />
Date: Tue, 20 Aug 2024 11:36:55 +0900 Subject: [PATCH 07/12] adjust fontsize by size --- superset-frontend/src/components/GridTable/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/superset-frontend/src/components/GridTable/index.tsx b/superset-frontend/src/components/GridTable/index.tsx index c97ec2ad51793..92424b6733993 100644 --- a/superset-frontend/src/components/GridTable/index.tsx +++ b/superset-frontend/src/components/GridTable/index.tsx @@ -175,7 +175,9 @@ function GridTable({ #grid-table.ag-theme-quartz { --ag-icon-font-family: agGridMaterial; --ag-grid-size: ${theme.gridUnit}px; - --ag-font-size: ${theme.typography.sizes.s - 1}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 && From 93054df0764ea6e5683d009662f799192efd20e9 Mon Sep 17 00:00:00 2001 From: justinpark Date: Tue, 20 Aug 2024 11:47:07 +0900 Subject: [PATCH 08/12] set size to small --- superset-frontend/src/components/FilterableTable/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/src/components/FilterableTable/index.tsx b/superset-frontend/src/components/FilterableTable/index.tsx index c30574b93bbb9..0ff1e0ded9d7f 100644 --- a/superset-frontend/src/components/FilterableTable/index.tsx +++ b/superset-frontend/src/components/FilterableTable/index.tsx @@ -144,7 +144,7 @@ const FilterableTable = ({ data-test="table-container" > Date: Wed, 21 Aug 2024 16:48:22 +0900 Subject: [PATCH 09/12] allow multi row selection --- superset-frontend/src/components/GridTable/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/superset-frontend/src/components/GridTable/index.tsx b/superset-frontend/src/components/GridTable/index.tsx index 92424b6733993..7a91886f9e76a 100644 --- a/superset-frontend/src/components/GridTable/index.tsx +++ b/superset-frontend/src/components/GridTable/index.tsx @@ -228,6 +228,7 @@ function GridTable({ ensureDomOrder: true, suppressFieldDotNotation: true, headerHeight: rowHeight, + rowSelection: 'multiple', rowHeight, }} onCellKeyDown={onKeyDown} From aa6462f3adf14cb8f615a66212bfd5eb0e16ea5d Mon Sep 17 00:00:00 2001 From: justinpark Date: Thu, 22 Aug 2024 18:59:59 +0900 Subject: [PATCH 10/12] Add column movable and autosize --- .../src/components/FilterableTable/index.tsx | 1 + .../src/components/GridTable/Header.tsx | 2 +- .../src/components/GridTable/HeaderMenu.tsx | 117 ++++++++++-------- .../src/components/GridTable/constants.ts | 24 ++++ .../src/components/GridTable/index.tsx | 16 +-- .../src/components/Icons/AntdEnhanced.tsx | 2 + superset/config.py | 4 +- 7 files changed, 105 insertions(+), 61 deletions(-) create mode 100644 superset-frontend/src/components/GridTable/constants.ts diff --git a/superset-frontend/src/components/FilterableTable/index.tsx b/superset-frontend/src/components/FilterableTable/index.tsx index 0ff1e0ded9d7f..920e7d68cd62b 100644 --- a/superset-frontend/src/components/FilterableTable/index.tsx +++ b/superset-frontend/src/components/FilterableTable/index.tsx @@ -153,6 +153,7 @@ const FilterableTable = ({ showRowNumber striped={striped} enableActions + columnReorderable /> ); diff --git a/superset-frontend/src/components/GridTable/Header.tsx b/superset-frontend/src/components/GridTable/Header.tsx index a0ce2f4c0a4dc..d6a9026b56f50 100644 --- a/superset-frontend/src/components/GridTable/Header.tsx +++ b/superset-frontend/src/components/GridTable/Header.tsx @@ -21,6 +21,7 @@ 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 { @@ -32,7 +33,6 @@ interface Params { setSort: (sort: string | null, multiSort: boolean) => void; } -export const PIVOT_COL_ID = '-1'; const SORT_DIRECTION = [null, 'asc', 'desc']; const HeaderCell = styled.div` diff --git a/superset-frontend/src/components/GridTable/HeaderMenu.tsx b/superset-frontend/src/components/GridTable/HeaderMenu.tsx index 4e04bcaae3502..ecb51dde5b7a1 100644 --- a/superset-frontend/src/components/GridTable/HeaderMenu.tsx +++ b/superset-frontend/src/components/GridTable/HeaderMenu.tsx @@ -17,13 +17,22 @@ * under the License. */ import { useCallback } from 'react'; -import { css, t } from '@superset-ui/core'; +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; @@ -72,6 +81,7 @@ const HeaderMenu: React.FC = ({ )} {invisibleColumns.map(c => ( { api.setColumnVisible(c.getColId(), true); }} @@ -90,7 +100,7 @@ const HeaderMenu: React.FC = ({ onVisibleChange={onVisibleChange} overlay={ - { copyTextToClipboard( () => @@ -111,14 +121,10 @@ const HeaderMenu: React.FC = ({ }), ); }} - css={css` - display: flex; - align-items: center; - `} > {t('Copy the current data')} - - + { api.exportDataAsCsv({ columnKeys: api @@ -127,15 +133,42 @@ const HeaderMenu: React.FC = ({ .filter(id => id !== colId), }); }} - css={css` - display: flex; - align-items: center; - `} > {t('Download to CSV')} - - {invisibleColumns.length > 0 && } + + + { + api.autoSizeAllColumns(); + }} + > + + {t('Autosize all columns')} + {unHideAction} + + { + 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'); + } + } + }} + > + + {t('Reset columns')} + } /> @@ -149,7 +182,7 @@ const HeaderMenu: React.FC = ({ onVisibleChange={onVisibleChange} overlay={ - { copyTextToClipboard( () => @@ -166,62 +199,46 @@ const HeaderMenu: React.FC = ({ }), ); }} - css={css` - display: flex; - align-items: center; - `} > {t('Copy')} - + {(pinnedLeft || pinnedRight) && ( - pinColumn(null)} - css={css` - display: flex; - align-items: center; - `} - > + pinColumn(null)}> {t('Unpin')} - + )} {!pinnedLeft && ( - pinColumn('left')} - css={css` - display: flex; - align-items: center; - `} - > + pinColumn('left')}> {t('Pin Left')} - + )} {!pinnedRight && ( - pinColumn('right')} - css={css` - display: flex; - align-items: center; - `} - > + pinColumn('right')}> {t('Pin Right')} - + )} - { + const column = api.getColumn(colId); + column?.setColDef(column?.getColDef(), { minWidth: undefined }); + api.autoSizeColumn(colId); + }} + > + + {t('Autosize Column')} + + { api.setColumnVisible(colId, false); }} - css={css` - display: flex; - align-items: center; - `} disabled={api.getColumns()?.length === invisibleColumns.length + 1} > {t('Hide Column')} - + {unHideAction} } diff --git a/superset-frontend/src/components/GridTable/constants.ts b/superset-frontend/src/components/GridTable/constants.ts new file mode 100644 index 0000000000000..42f88fd8cc552 --- /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 index 7a91886f9e76a..ef826f2c6fa62 100644 --- a/superset-frontend/src/components/GridTable/index.tsx +++ b/superset-frontend/src/components/GridTable/index.tsx @@ -29,16 +29,14 @@ import 'ag-grid-community/styles/ag-theme-quartz.css'; import copyTextToClipboard from 'src/utils/copy'; import ErrorBoundary from 'src/components/ErrorBoundary'; -import Header, { PIVOT_COL_ID } from './Header'; +import { PIVOT_COL_ID, GridSize } from './constants'; +import Header from './Header'; const gridComponents = { agColumnHeader: Header, }; -export enum GridSize { - Small = 'small', - Middle = 'middle', -} +export { GridSize }; export type ColDef = { type: string; @@ -67,7 +65,7 @@ export interface TableProps { height: number; - cellSelectable?: boolean; + columnReorderable?: boolean; sortable?: boolean; @@ -87,6 +85,7 @@ function GridTable({ data, columns, sortable = true, + columnReorderable, height, externalFilter, showRowNumber, @@ -138,6 +137,7 @@ function GridTable({ resizable: false, pinned: 'left' as const, sortable: false, + ...(columnReorderable && { suppressMovable: true }), }, ...columns.map( ( @@ -157,10 +157,10 @@ function GridTable({ }), ), ].slice(showRowNumber ? 0 : 1), - [columns, sortable, showRowNumber, rowIndexLength], + [rowIndexLength, columnReorderable, columns, showRowNumber, sortable], ); const defaultColDef: AgReactUiProps['defaultColDef'] = { - suppressMovable: true, + ...(!columnReorderable && { suppressMovable: true }), resizable: true, sortable, filter: Boolean(enableActions), diff --git a/superset-frontend/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/src/components/Icons/AntdEnhanced.tsx index f25a0c3940ccd..5d17834d3d144 100644 --- a/superset-frontend/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/src/components/Icons/AntdEnhanced.tsx @@ -33,6 +33,7 @@ import { CheckOutlined, CheckSquareOutlined, CloseOutlined, + ColumnWidthOutlined, CommentOutlined, ConsoleSqlOutlined, CopyOutlined, @@ -91,6 +92,7 @@ const AntdIcons = { CheckOutlined, CheckSquareOutlined, CloseOutlined, + ColumnWidthOutlined, CommentOutlined, ConsoleSqlOutlined, CopyOutlined, diff --git a/superset/config.py b/superset/config.py index a39dc432c3712..097586faf42c6 100644 --- a/superset/config.py +++ b/superset/config.py @@ -1536,7 +1536,7 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument TALISMAN_CONFIG = { "content_security_policy": { "base-uri": ["'self'"], - "default-src": ["'self'"], + "default-src": ["'self'", "data:"], "img-src": [ "'self'", "blob:", @@ -1566,7 +1566,7 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument TALISMAN_DEV_CONFIG = { "content_security_policy": { "base-uri": ["'self'"], - "default-src": ["'self'"], + "default-src": ["'self'", "data:"], "img-src": [ "'self'", "blob:", From 31cc493a09007333ef8c1f996d63d652663b9e09 Mon Sep 17 00:00:00 2001 From: justinpark Date: Thu, 22 Aug 2024 20:53:41 +0900 Subject: [PATCH 11/12] fix test specs --- .../src/components/GridTable/Header.test.tsx | 3 ++- .../src/components/GridTable/HeaderMenu.test.tsx | 9 +++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/superset-frontend/src/components/GridTable/Header.test.tsx b/superset-frontend/src/components/GridTable/Header.test.tsx index 3045616e35366..c1154e7476c3d 100644 --- a/superset-frontend/src/components/GridTable/Header.test.tsx +++ b/superset-frontend/src/components/GridTable/Header.test.tsx @@ -18,7 +18,8 @@ */ import type { Column, GridApi } from 'ag-grid-community'; import { act, fireEvent, render } from 'spec/helpers/testing-library'; -import Header, { PIVOT_COL_ID } from './Header'; +import Header from './Header'; +import { PIVOT_COL_ID } from './constants'; jest.mock('src/components/Dropdown', () => ({ Dropdown: () =>
, diff --git a/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx b/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx index 9afd20fb395e8..0233308d0640b 100644 --- a/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx +++ b/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx @@ -66,6 +66,7 @@ jest.mock('src/components/Icons', () => ({ VerticalLeftOutlined: () =>
, EyeInvisibleOutlined: () =>
, EyeOutlined: () =>
, + ColumnWidthOutlined: () =>
, })); jest.mock('src/components/Dropdown', () => ({ @@ -165,10 +166,7 @@ test('renders unhide when invisible column exists', async () => { describe('for main menu', () => { test('renders Copy to Clipboard', async () => { - const { getByText, queryByTestId } = render( - , - ); - expect(queryByTestId('mock-Divider')).not.toBeInTheDocument(); + const { getByText } = render(); fireEvent.click(getByText('Copy the current data')); await waitFor(() => expect(mockGridApi.getDataAsCsv).toHaveBeenCalledTimes(1), @@ -192,14 +190,13 @@ describe('for main menu', () => { }); test('renders all unhide all hidden columns when multiple invisible columns exist', async () => { - const { queryByTestId } = render( + render( , ); - expect(queryByTestId('mock-Divider')).toBeInTheDocument(); const unhideColumnsButton = await screen.findByText( `All ${2} hidden columns`, ); From e5d9e67c1f23245c47b0c1239af2e22099a957b6 Mon Sep 17 00:00:00 2001 From: justinpark Date: Fri, 23 Aug 2024 08:28:05 +0900 Subject: [PATCH 12/12] Add specs for additional menu actions --- .../components/GridTable/HeaderMenu.test.tsx | 68 ++++++++++++++++++- .../src/components/GridTable/HeaderMenu.tsx | 8 ++- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx b/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx index 0233308d0640b..3a187e4cf8684 100644 --- a/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx +++ b/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx @@ -89,14 +89,24 @@ const mockInvisibleColumn3 = { getDataAsCsv: jest.fn().mockReturnValue('csv'), } as any as Column; +const mockSetColDef = jest.fn(); + const mockGridApi = { + autoSizeColumn: jest.fn(), + autoSizeAllColumns: jest.fn(), setColumnPinned: jest.fn(), + getColumn: jest.fn().mockReturnValue({ + setColDef: mockSetColDef, + 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 = { @@ -107,13 +117,27 @@ const mockedProps = { }; afterEach(() => { + mockSetColDef.mockClear(); + (mockGridApi.getDataAsCsv as jest.Mock).mockClear(); (mockGridApi.setColumnPinned as jest.Mock).mockClear(); (mockGridApi.setColumnVisible as jest.Mock).mockClear(); + (mockGridApi.setColumnsVisible as jest.Mock).mockClear(); + (mockGridApi.setColumnsPinned as jest.Mock).mockClear(); + (mockGridApi.autoSizeColumn as jest.Mock).mockClear(); + (mockGridApi.autoSizeAllColumns as jest.Mock).mockClear(); + (mockGridApi.moveColumns as jest.Mock).mockClear(); }); -test('renders copy data', () => { - const { queryByText } = render(); - expect(queryByText('Copy')).toBeTruthy(); +test('renders copy data', async () => { + const { getByText } = render(); + 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 side', () => { @@ -153,6 +177,15 @@ test('renders unpin on pinned right', () => { expect(queryByText('Unpin')).toBeTruthy(); }); +test('renders autosize column', async () => { + const { getByText } = render(); + fireEvent.click(getByText('Autosize Column')); + await waitFor(() => + expect(mockGridApi.autoSizeColumn).toHaveBeenCalledTimes(1), + ); + expect(mockSetColDef).toHaveBeenCalledTimes(1); +}); + test('renders unhide when invisible column exists', async () => { const { queryByText } = render( , @@ -189,6 +222,14 @@ describe('for main menu', () => { }); }); + test('renders autosize column', async () => { + const { getByText } = render(); + 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( { true, ); }); + + test('reset columns configuration', async () => { + const { getByText } = render( + , + ); + 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 index ecb51dde5b7a1..38c6ab74171d6 100644 --- a/superset-frontend/src/components/GridTable/HeaderMenu.tsx +++ b/superset-frontend/src/components/GridTable/HeaderMenu.tsx @@ -223,7 +223,13 @@ const HeaderMenu: React.FC = ({ { const column = api.getColumn(colId); - column?.setColDef(column?.getColDef(), { minWidth: undefined }); + column?.setColDef( + { + ...column?.getColDef(), + minWidth: undefined, + }, + column?.getColDef(), + ); api.autoSizeColumn(colId); }} >