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/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, 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..920e7d68cd62b 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,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(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..8d38283ee4057 --- /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)).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..c1154e7476c3d --- /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)).toBeTruthy(); +}); + +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 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..d6a9026b56f50 --- /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 { + enableMenu?: 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 = ({ + 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..3a187e4cf8684 --- /dev/null +++ b/superset-frontend/src/components/GridTable/HeaderMenu.test.tsx @@ -0,0 +1,272 @@ +/** + * 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 }) => ( +
{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: () =>
, + ColumnWidthOutlined: () =>
, +})); + +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 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 = { + colId: 'column1', + invisibleColumns: [], + api: mockGridApi, + onVisibleChange: jest.fn(), +}; + +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', 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', () => { + 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 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( + , + ); + 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 } = render(); + 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 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( + , + ); + 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( + , + ); + 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 0000000000000..38c6ab74171d6 --- /dev/null +++ b/superset-frontend/src/components/GridTable/HeaderMenu.tsx @@ -0,0 +1,255 @@ +/** + * 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 = ({ + 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(); + } + }), + ); + }} + > + {t('Copy the current data')} + + { + api.exportDataAsCsv({ + columnKeys: api + .getAllDisplayedColumns() + .map(c => c.getColId()) + .filter(id => id !== colId), + }); + }} + > + {t('Download to CSV')} + + + { + 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')} + + + } + /> + ); + } + + return ( + + { + copyTextToClipboard( + () => + new Promise((resolve, reject) => { + const data = api.getDataAsCsv({ + columnKeys: [colId], + suppressQuotes: true, + }); + if (data) { + resolve(data); + } else { + reject(); + } + }), + ); + }} + > + {t('Copy')} + + {(pinnedLeft || pinnedRight) && ( + pinColumn(null)}> + {t('Unpin')} + + )} + {!pinnedLeft && ( + pinColumn('left')}> + + {t('Pin Left')} + + )} + {!pinnedRight && ( + pinColumn('right')}> + + {t('Pin Right')} + + )} + + { + const column = api.getColumn(colId); + column?.setColDef( + { + ...column?.getColDef(), + minWidth: undefined, + }, + column?.getColDef(), + ); + api.autoSizeColumn(colId); + }} + > + + {t('Autosize Column')} + + { + api.setColumnVisible(colId, false); + }} + disabled={api.getColumns()?.length === invisibleColumns.length + 1} + > + + {t('Hide Column')} + + {unHideAction} + + } + /> + ); +}; + +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 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 new file mode 100644 index 0000000000000..ef826f2c6fa62 --- /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, AgReactUiProps } 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 { + /** + * 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; + + columnReorderable?: boolean; + + sortable?: boolean; + + enableActions?: boolean; + + showRowNumber?: boolean; + + usePagination?: boolean; + + striped?: boolean; +} + +const onSortChanged: AgReactUiProps['onSortChanged'] = ({ api }) => + api.refreshCells(); + +function GridTable({ + data, + columns, + sortable = true, + columnReorderable, + 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, + ...(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: 200, + }), + }), + ), + ].slice(showRowNumber ? 0 : 1), + [rowIndexLength, columnReorderable, columns, showRowNumber, sortable], + ); + const defaultColDef: AgReactUiProps['defaultColDef'] = { + ...(!columnReorderable && { suppressMovable: true }), + resizable: true, + sortable, + filter: Boolean(enableActions), + }; + + const rowHeight = theme.gridUnit * (size === GridSize.Middle ? 9 : 7); + + return ( + + 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-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..5d17834d3d144 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, @@ -64,8 +68,11 @@ import { StopOutlined, SyncOutlined, TagsOutlined, + UnlockOutlined, UpOutlined, UserOutlined, + VerticalLeftOutlined, + VerticalRightOutlined, } from '@ant-design/icons'; import { StyledIcon } from './Icon'; import IconType from './IconType'; @@ -79,10 +86,13 @@ const AntdIcons = { BarChartOutlined, BellOutlined, BookOutlined, + CaretDownOutlined, CalendarOutlined, + CaretUpOutlined, CheckOutlined, CheckSquareOutlined, CloseOutlined, + ColumnWidthOutlined, CommentOutlined, ConsoleSqlOutlined, CopyOutlined, @@ -90,6 +100,7 @@ const AntdIcons = { DatabaseOutlined, DeleteFilled, DownOutlined, + DownloadOutlined, EditOutlined, ExclamationCircleOutlined, EyeOutlined, @@ -116,8 +127,11 @@ const AntdIcons = { StopOutlined, SyncOutlined, TagsOutlined, + UnlockOutlined, UpOutlined, UserOutlined, + VerticalLeftOutlined, + VerticalRightOutlined, }; const AntdEnhancedIcons = Object.keys(AntdIcons) 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:",