feat(sqllab): Replace FilterableTable by AgGrid Table (#29900)
This commit is contained in:
parent
3f46bcf142
commit
f73d61a597
|
|
@ -56,6 +56,8 @@
|
|||
"@visx/xychart": "^3.5.1",
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
"ace-builds": "^1.36.3",
|
||||
"ag-grid-community": "32.2.1",
|
||||
"ag-grid-react": "32.2.1",
|
||||
"antd": "4.10.3",
|
||||
"antd-v5": "npm:antd@^5.18.0",
|
||||
"bootstrap": "^3.4.1",
|
||||
|
|
@ -14478,6 +14480,32 @@
|
|||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-charts-types": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-10.2.0.tgz",
|
||||
"integrity": "sha512-PUqH1QtugpYLnlbMdeSZVf5PpT1XZVsP69qN1JXhetLtQpVC28zaj7ikwu9CMA9N9b+dBboA9QcjUQUJZVUokQ=="
|
||||
},
|
||||
"node_modules/ag-grid-community": {
|
||||
"version": "32.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-32.2.1.tgz",
|
||||
"integrity": "sha512-mrnm1DnLI9Wd408mMwP+6p7lbTC3FYgzNIUPygBvNh3SzZnbzTEUJF/BTKXi+MARWtG5S0IMUYy4hqBiLbobaQ==",
|
||||
"dependencies": {
|
||||
"ag-charts-types": "10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-grid-react": {
|
||||
"version": "32.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-32.2.1.tgz",
|
||||
"integrity": "sha512-lojTKsT/ncRZ81mrDa7qkIhZePfYlLCHIiAL1WbzL1mNPrglaa7QQKkE6hhhuAXvAm2uUhK1OfkMPnrqsEFldA==",
|
||||
"dependencies": {
|
||||
"ag-grid-community": "32.2.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.3.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -123,6 +123,8 @@
|
|||
"@visx/xychart": "^3.5.1",
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
"ace-builds": "^1.36.3",
|
||||
"ag-grid-community": "32.2.1",
|
||||
"ag-grid-react": "32.2.1",
|
||||
"antd": "4.10.3",
|
||||
"antd-v5": "npm:antd@^5.18.0",
|
||||
"bootstrap": "^3.4.1",
|
||||
|
|
|
|||
|
|
@ -354,7 +354,7 @@ describe('ResultSet', () => {
|
|||
);
|
||||
});
|
||||
const { getByRole } = setup(mockedProps, mockStore(initialState));
|
||||
expect(getByRole('table')).toBeInTheDocument();
|
||||
expect(getByRole('treegrid')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders if there is a limit in query.results but not queryLimit', async () => {
|
||||
|
|
@ -372,7 +372,7 @@ describe('ResultSet', () => {
|
|||
},
|
||||
}),
|
||||
);
|
||||
expect(getByRole('table')).toBeInTheDocument();
|
||||
expect(getByRole('treegrid')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Async queries - renders "Fetch data preview" button when data preview has no results', () => {
|
||||
|
|
@ -400,7 +400,7 @@ describe('ResultSet', () => {
|
|||
name: /fetch data preview/i,
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('treegrid')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Async queries - renders "Refetch results" button when a query has no results', () => {
|
||||
|
|
@ -429,7 +429,7 @@ describe('ResultSet', () => {
|
|||
name: /refetch results/i,
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('treegrid')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Async queries - renders on the first call', () => {
|
||||
|
|
@ -449,7 +449,7 @@ describe('ResultSet', () => {
|
|||
},
|
||||
}),
|
||||
);
|
||||
expect(screen.getByRole('table')).toBeVisible();
|
||||
expect(screen.getByRole('treegrid')).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: /fetch data preview/i,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ describe('FilterableTable', () => {
|
|||
const { getByRole, getByText } = render(
|
||||
<FilterableTable {...mockedProps} />,
|
||||
);
|
||||
expect(getByRole('table')).toBeInTheDocument();
|
||||
expect(getByRole('treegrid')).toBeInTheDocument();
|
||||
mockedProps.data.forEach(({ b: columnBContent }) => {
|
||||
expect(getByText(columnBContent)).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -78,11 +78,10 @@ describe('FilterableTable sorting - RTL', () => {
|
|||
};
|
||||
render(<FilterableTable {...stringProps} />);
|
||||
|
||||
const stringColumn = within(screen.getByRole('table'))
|
||||
const stringColumn = within(screen.getByRole('treegrid'))
|
||||
.getByText('columnA')
|
||||
.closest('th');
|
||||
// Antd 4.x Table does not follow the table role structure. Need a hacky selector to point the cell item
|
||||
const gridCells = screen.getByTitle('Bravo').closest('.virtual-grid');
|
||||
.closest('[role=button]');
|
||||
const gridCells = screen.getByText('Bravo').closest('[role=rowgroup]');
|
||||
|
||||
// Original order
|
||||
expect(gridCells?.textContent).toEqual(
|
||||
|
|
@ -124,10 +123,10 @@ describe('FilterableTable sorting - RTL', () => {
|
|||
};
|
||||
render(<FilterableTable {...integerProps} />);
|
||||
|
||||
const integerColumn = within(screen.getByRole('table'))
|
||||
const integerColumn = within(screen.getByRole('treegrid'))
|
||||
.getByText('columnB')
|
||||
.closest('th');
|
||||
const gridCells = screen.getByTitle('21').closest('.virtual-grid');
|
||||
.closest('[role=button]');
|
||||
const gridCells = screen.getByText('21').closest('[role=rowgroup]');
|
||||
|
||||
// Original order
|
||||
expect(gridCells?.textContent).toEqual(['21', '0', '623'].join(''));
|
||||
|
|
@ -159,10 +158,10 @@ describe('FilterableTable sorting - RTL', () => {
|
|||
};
|
||||
render(<FilterableTable {...floatProps} />);
|
||||
|
||||
const floatColumn = within(screen.getByRole('table'))
|
||||
const floatColumn = within(screen.getByRole('treegrid'))
|
||||
.getByText('columnC')
|
||||
.closest('th');
|
||||
const gridCells = screen.getByTitle('45.67').closest('.virtual-grid');
|
||||
.closest('[role=button]');
|
||||
const gridCells = screen.getByText('45.67').closest('[role=rowgroup]');
|
||||
|
||||
// Original order
|
||||
expect(gridCells?.textContent).toEqual(
|
||||
|
|
@ -214,10 +213,10 @@ describe('FilterableTable sorting - RTL', () => {
|
|||
};
|
||||
render(<FilterableTable {...mixedFloatProps} />);
|
||||
|
||||
const mixedFloatColumn = within(screen.getByRole('table'))
|
||||
const mixedFloatColumn = within(screen.getByRole('treegrid'))
|
||||
.getByText('columnD')
|
||||
.closest('th');
|
||||
const gridCells = screen.getByTitle('48710.92').closest('.virtual-grid');
|
||||
.closest('[role=button]');
|
||||
const gridCells = screen.getByText('48710.92').closest('[role=rowgroup]');
|
||||
|
||||
// Original order
|
||||
expect(gridCells?.textContent).toEqual(
|
||||
|
|
@ -312,10 +311,10 @@ describe('FilterableTable sorting - RTL', () => {
|
|||
};
|
||||
render(<FilterableTable {...dsProps} />);
|
||||
|
||||
const dsColumn = within(screen.getByRole('table'))
|
||||
const dsColumn = within(screen.getByRole('treegrid'))
|
||||
.getByText('columnDS')
|
||||
.closest('th');
|
||||
const gridCells = screen.getByTitle('2021-01-01').closest('.virtual-grid');
|
||||
.closest('[role=button]');
|
||||
const gridCells = screen.getByText('2021-01-01').closest('[role=rowgroup]');
|
||||
|
||||
// Original order
|
||||
expect(gridCells?.textContent).toEqual(
|
||||
|
|
|
|||
|
|
@ -16,55 +16,20 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import _JSONbig from 'json-bigint';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { getMultipleTextDimensions, styled } from '@superset-ui/core';
|
||||
import { useDebounceValue } from 'src/hooks/useDebounceValue';
|
||||
import { useMemo, useRef, useCallback } from 'react';
|
||||
import { styled } from '@superset-ui/core';
|
||||
import { useCellContentParser } from './useCellContentParser';
|
||||
import { renderResultCell } from './utils';
|
||||
import { Table, TableSize } from '../Table';
|
||||
import GridTable, { GridSize, ColDef } from '../GridTable';
|
||||
|
||||
const JSONbig = _JSONbig({
|
||||
storeAsString: true,
|
||||
constructorAction: 'preserve',
|
||||
});
|
||||
|
||||
const SCROLL_BAR_HEIGHT = 15;
|
||||
// This regex handles all possible number formats in javascript, including ints, floats,
|
||||
// exponential notation, NaN, and Infinity.
|
||||
// See https://stackoverflow.com/a/30987109 for more details
|
||||
const ONLY_NUMBER_REGEX = /^(NaN|-?((\d*\.\d+|\d+)([Ee][+-]?\d+)?|Infinity))$/;
|
||||
|
||||
const StyledFilterableTable = styled.div`
|
||||
${({ theme }) => `
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.ant-table-cell {
|
||||
font-weight: ${theme.typography.weights.bold};
|
||||
background-color: ${theme.colors.grayscale.light5};
|
||||
}
|
||||
|
||||
.ant-table-cell,
|
||||
.virtual-table-cell {
|
||||
min-width: 0px;
|
||||
align-self: center;
|
||||
font-size: ${theme.typography.sizes.s}px;
|
||||
}
|
||||
|
||||
.even-row {
|
||||
background: ${theme.colors.grayscale.light4};
|
||||
}
|
||||
|
||||
.odd-row {
|
||||
background: ${theme.colors.grayscale.light5};
|
||||
}
|
||||
|
||||
.cell-text-for-measuring {
|
||||
font-family: ${theme.typography.families.sansSerif};
|
||||
font-size: ${theme.typography.sizes.s}px;
|
||||
}
|
||||
`}
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
type CellDataType = string | number | null;
|
||||
|
|
@ -79,12 +44,38 @@ export interface FilterableTableProps {
|
|||
overscanColumnCount?: number;
|
||||
overscanRowCount?: number;
|
||||
rowHeight?: number;
|
||||
// need antd 5.0 to support striped color pattern
|
||||
striped?: boolean;
|
||||
expandedColumns?: string[];
|
||||
allowHTML?: boolean;
|
||||
}
|
||||
|
||||
const parseNumberFromString = (value: string | number | null) => {
|
||||
if (typeof value === 'string' && ONLY_NUMBER_REGEX.test(value)) {
|
||||
return parseFloat(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const sortResults = (valueA: string | number, valueB: string | number) => {
|
||||
const aValue = parseNumberFromString(valueA);
|
||||
const bValue = parseNumberFromString(valueB);
|
||||
|
||||
// equal items sort equally
|
||||
if (aValue === bValue) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// nulls sort after anything else
|
||||
if (aValue === null) {
|
||||
return 1;
|
||||
}
|
||||
if (bValue === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return aValue < bValue ? -1 : 1;
|
||||
};
|
||||
|
||||
const FilterableTable = ({
|
||||
orderedColumnKeys,
|
||||
data,
|
||||
|
|
@ -92,83 +83,13 @@ const FilterableTable = ({
|
|||
filterText = '',
|
||||
expandedColumns = [],
|
||||
allowHTML = true,
|
||||
striped,
|
||||
}: FilterableTableProps) => {
|
||||
const formatTableData = (data: Record<string, unknown>[]): Datum[] =>
|
||||
data.map(row => {
|
||||
const newRow: Record<string, any> = {};
|
||||
Object.entries(row).forEach(([key, val]) => {
|
||||
if (['string', 'number'].indexOf(typeof val) >= 0) {
|
||||
newRow[key] = val;
|
||||
} else {
|
||||
newRow[key] = val === null ? null : JSONbig.stringify(val);
|
||||
}
|
||||
});
|
||||
return newRow;
|
||||
});
|
||||
|
||||
const [fitted, setFitted] = useState(false);
|
||||
const [list] = useState<Datum[]>(() => formatTableData(data));
|
||||
|
||||
const getCellContent = useCellContentParser({
|
||||
columnKeys: orderedColumnKeys,
|
||||
expandedColumns,
|
||||
});
|
||||
|
||||
const getWidthsForColumns = () => {
|
||||
const PADDING = 50; // accounts for cell padding and width of sorting icon
|
||||
const widthsByColumnKey: Record<string, number> = {};
|
||||
const cellContent = ([] as string[]).concat(
|
||||
...orderedColumnKeys.map(key => {
|
||||
const cellContentList = list.map((data: Datum) =>
|
||||
getCellContent({ cellData: data[key], columnKey: key }),
|
||||
);
|
||||
cellContentList.push(key);
|
||||
return cellContentList;
|
||||
}),
|
||||
);
|
||||
|
||||
const colWidths = getMultipleTextDimensions({
|
||||
className: 'cell-text-for-measuring',
|
||||
texts: cellContent,
|
||||
}).map(dimension => dimension.width);
|
||||
|
||||
orderedColumnKeys.forEach((key, index) => {
|
||||
// we can't use Math.max(...colWidths.slice(...)) here since the number
|
||||
// of elements might be bigger than the number of allowed arguments in a
|
||||
// JavaScript function
|
||||
widthsByColumnKey[key] =
|
||||
colWidths
|
||||
.slice(index * (list.length + 1), (index + 1) * (list.length + 1))
|
||||
.reduce((a, b) => Math.max(a, b)) + PADDING;
|
||||
});
|
||||
|
||||
return widthsByColumnKey;
|
||||
};
|
||||
|
||||
const [widthsForColumnsByKey] = useState<Record<string, number>>(() =>
|
||||
getWidthsForColumns(),
|
||||
);
|
||||
|
||||
const totalTableWidth = useRef(
|
||||
orderedColumnKeys
|
||||
.map(key => widthsForColumnsByKey[key])
|
||||
.reduce((curr, next) => curr + next),
|
||||
);
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fitTableToWidthIfNeeded = () => {
|
||||
const containerWidth = container.current?.clientWidth ?? 0;
|
||||
if (totalTableWidth.current < containerWidth) {
|
||||
// fit table width if content doesn't fill the width of the container
|
||||
totalTableWidth.current = containerWidth;
|
||||
}
|
||||
setFitted(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fitTableToWidthIfNeeded();
|
||||
}, []);
|
||||
|
||||
const hasMatch = (text: string, row: Datum) => {
|
||||
const values: string[] = [];
|
||||
Object.keys(row).forEach(key => {
|
||||
|
|
@ -188,86 +109,52 @@ const FilterableTable = ({
|
|||
return values.some(v => v.includes(lowerCaseText));
|
||||
};
|
||||
|
||||
// Parse any numbers from strings so they'll sort correctly
|
||||
const parseNumberFromString = (value: string | number | null) => {
|
||||
if (typeof value === 'string') {
|
||||
if (ONLY_NUMBER_REGEX.test(value)) {
|
||||
return parseFloat(value);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const sortResults = (key: string, a: Datum, b: Datum) => {
|
||||
const aValue = parseNumberFromString(a[key]);
|
||||
const bValue = parseNumberFromString(b[key]);
|
||||
|
||||
// equal items sort equally
|
||||
if (aValue === bValue) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// nulls sort after anything else
|
||||
if (aValue === null) {
|
||||
return 1;
|
||||
}
|
||||
if (bValue === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return aValue < bValue ? -1 : 1;
|
||||
};
|
||||
|
||||
const keyword = useDebounceValue(filterText);
|
||||
|
||||
const filteredList = useMemo(
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
keyword ? list.filter((row: Datum) => hasMatch(keyword, row)) : list,
|
||||
[list, keyword],
|
||||
orderedColumnKeys.map(key => ({
|
||||
key,
|
||||
label: key,
|
||||
fieldName: key,
|
||||
headerName: key,
|
||||
comparator: sortResults,
|
||||
render: ({ value, colDef }: { value: CellDataType; colDef: ColDef }) =>
|
||||
renderResultCell({
|
||||
cellData: value,
|
||||
columnKey: colDef.field,
|
||||
allowHTML,
|
||||
getCellContent,
|
||||
}),
|
||||
})),
|
||||
[orderedColumnKeys, allowHTML, getCellContent],
|
||||
);
|
||||
|
||||
// exclude the height of the horizontal scroll bar from the height of the table
|
||||
// and the height of the table container if the content overflows
|
||||
const totalTableHeight =
|
||||
container.current && totalTableWidth.current > container.current.clientWidth
|
||||
? height - SCROLL_BAR_HEIGHT
|
||||
: height;
|
||||
const keyword = useRef<string | undefined>(filterText);
|
||||
keyword.current = filterText;
|
||||
|
||||
const columns = orderedColumnKeys.map(key => ({
|
||||
key,
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
width: widthsForColumnsByKey[key],
|
||||
sorter: (a: Datum, b: Datum) => sortResults(key, a, b),
|
||||
render: (text: CellDataType) =>
|
||||
renderResultCell({
|
||||
cellData: text,
|
||||
columnKey: key,
|
||||
allowHTML,
|
||||
getCellContent,
|
||||
}),
|
||||
}));
|
||||
const keywordFilter = useCallback(node => {
|
||||
if (keyword.current && node.data) {
|
||||
return hasMatch(keyword.current, node.data);
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StyledFilterableTable
|
||||
className="filterable-table-container"
|
||||
data-test="table-container"
|
||||
ref={container}
|
||||
>
|
||||
{fitted && (
|
||||
<Table
|
||||
loading={filterText !== keyword}
|
||||
size={TableSize.Small}
|
||||
height={totalTableHeight + 42}
|
||||
usePagination={false}
|
||||
columns={columns}
|
||||
data={filteredList}
|
||||
childrenColumnName=""
|
||||
virtualize
|
||||
bordered
|
||||
/>
|
||||
)}
|
||||
<GridTable
|
||||
size={GridSize.Small}
|
||||
height={height}
|
||||
usePagination={false}
|
||||
columns={columns}
|
||||
data={data}
|
||||
externalFilter={keywordFilter}
|
||||
showRowNumber
|
||||
striped={striped}
|
||||
enableActions
|
||||
columnReorderable
|
||||
/>
|
||||
</StyledFilterableTable>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render } from 'spec/helpers/testing-library';
|
||||
import GridTable from '.';
|
||||
|
||||
jest.mock('src/components/ErrorBoundary', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
const mockedProps = {
|
||||
queryId: 'abc',
|
||||
columns: ['a', 'b', 'c'].map(key => ({
|
||||
key,
|
||||
label: key,
|
||||
headerName: key,
|
||||
render: ({ value }: { value: any }) => value,
|
||||
})),
|
||||
data: [
|
||||
{ a: 'a1', b: 'b1', c: 'c1', d: 0 },
|
||||
{ a: 'a2', b: 'b2', c: 'c2', d: 100 },
|
||||
{ a: null, b: 'b3', c: 'c3', d: 50 },
|
||||
],
|
||||
height: 500,
|
||||
};
|
||||
|
||||
test('renders a grid with 3 Table rows', () => {
|
||||
const { queryByText } = render(<GridTable {...mockedProps} />);
|
||||
mockedProps.data.forEach(({ b: columnBContent }) => {
|
||||
expect(queryByText(columnBContent)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('sorts strings correctly', () => {
|
||||
const stringProps = {
|
||||
...mockedProps,
|
||||
columns: ['columnA'].map(key => ({
|
||||
key,
|
||||
label: key,
|
||||
headerName: key,
|
||||
render: ({ value }: { value: any }) => value,
|
||||
})),
|
||||
data: [{ columnA: 'Bravo' }, { columnA: 'Alpha' }, { columnA: 'Charlie' }],
|
||||
height: 500,
|
||||
};
|
||||
const { container } = render(<GridTable {...stringProps} />);
|
||||
|
||||
// Original order
|
||||
expect(container).toHaveTextContent(['Bravo', 'Alpha', 'Charlie'].join(''));
|
||||
});
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Column, GridApi } from 'ag-grid-community';
|
||||
import { act, fireEvent, render } from 'spec/helpers/testing-library';
|
||||
import Header from './Header';
|
||||
import { PIVOT_COL_ID } from './constants';
|
||||
|
||||
jest.mock('src/components/Dropdown', () => ({
|
||||
Dropdown: () => <div data-test="mock-dropdown" />,
|
||||
}));
|
||||
|
||||
jest.mock('src/components/Icons', () => ({
|
||||
Sort: () => <div data-test="mock-sort" />,
|
||||
SortAsc: () => <div data-test="mock-sort-asc" />,
|
||||
SortDesc: () => <div data-test="mock-sort-desc" />,
|
||||
}));
|
||||
|
||||
class MockApi extends EventTarget {
|
||||
getAllDisplayedColumns() {
|
||||
return [];
|
||||
}
|
||||
|
||||
isDestroyed() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const mockedProps = {
|
||||
displayName: 'test column',
|
||||
setSort: jest.fn(),
|
||||
enableSorting: true,
|
||||
column: {
|
||||
getColId: () => '123',
|
||||
isPinnedLeft: () => true,
|
||||
isPinnedRight: () => false,
|
||||
getSort: () => 'asc',
|
||||
getSortIndex: () => null,
|
||||
} as any as Column,
|
||||
api: new MockApi() as any as GridApi,
|
||||
};
|
||||
|
||||
test('renders display name for the column', () => {
|
||||
const { queryByText } = render(<Header {...mockedProps} />);
|
||||
expect(queryByText(mockedProps.displayName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('sorts by clicking a column header', () => {
|
||||
const { getByText, queryByTestId } = render(<Header {...mockedProps} />);
|
||||
fireEvent.click(getByText(mockedProps.displayName));
|
||||
expect(mockedProps.setSort).toHaveBeenCalledWith('asc', false);
|
||||
expect(queryByTestId('mock-sort-asc')).toBeInTheDocument();
|
||||
fireEvent.click(getByText(mockedProps.displayName));
|
||||
expect(mockedProps.setSort).toHaveBeenCalledWith('desc', false);
|
||||
expect(queryByTestId('mock-sort-desc')).toBeInTheDocument();
|
||||
fireEvent.click(getByText(mockedProps.displayName));
|
||||
expect(mockedProps.setSort).toHaveBeenCalledWith(null, false);
|
||||
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('mock-sort-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('synchronizes the current sort when sortChanged event occured', async () => {
|
||||
const { findByTestId } = render(<Header {...mockedProps} />);
|
||||
act(() => {
|
||||
mockedProps.api.dispatchEvent(new Event('sortChanged'));
|
||||
});
|
||||
const sortAsc = await findByTestId('mock-sort-asc');
|
||||
expect(sortAsc).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('disable menu when enableFilterButton is false', () => {
|
||||
const { queryByText, queryByTestId } = render(
|
||||
<Header {...mockedProps} enableFilterButton={false} />,
|
||||
);
|
||||
expect(queryByText(mockedProps.displayName)).toBeInTheDocument();
|
||||
expect(queryByTestId('mock-dropdown')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hide display name for PIVOT_COL_ID', () => {
|
||||
const { queryByText } = render(
|
||||
<Header
|
||||
{...mockedProps}
|
||||
column={
|
||||
{
|
||||
getColId: () => PIVOT_COL_ID,
|
||||
isPinnedLeft: () => true,
|
||||
isPinnedRight: () => false,
|
||||
getSortIndex: () => null,
|
||||
} as any as Column
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(queryByText(mockedProps.displayName)).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { styled, useTheme, t } from '@superset-ui/core';
|
||||
import type { Column, GridApi } from 'ag-grid-community';
|
||||
|
||||
import Icons from 'src/components/Icons';
|
||||
import { PIVOT_COL_ID } from './constants';
|
||||
import HeaderMenu from './HeaderMenu';
|
||||
|
||||
interface Params {
|
||||
enableFilterButton?: boolean;
|
||||
enableSorting?: boolean;
|
||||
displayName: string;
|
||||
column: Column;
|
||||
api: GridApi;
|
||||
setSort: (sort: string | null, multiSort: boolean) => void;
|
||||
}
|
||||
|
||||
const SORT_DIRECTION = [null, 'asc', 'desc'];
|
||||
|
||||
const HeaderCell = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
&[role='button'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
const HeaderCellSort = styled.div`
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const SortSeqLabel = styled.span`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
`;
|
||||
|
||||
const HeaderAction = styled.div`
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: ${({ theme }) => theme.gridUnit * 3}px;
|
||||
&.main {
|
||||
margin: 0 auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 20px;
|
||||
}
|
||||
& .ant-dropdown-trigger {
|
||||
cursor: context-menu;
|
||||
padding: ${({ theme }) => theme.gridUnit * 2}px;
|
||||
background-color: var(--ag-background-color);
|
||||
box-shadow: 0 0 2px var(--ag-chip-border-color);
|
||||
border-radius: 50%;
|
||||
&:hover {
|
||||
box-shadow: 0 0 4px ${({ theme }) => theme.colors.grayscale.light1};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const IconPlaceholder = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
const Header: React.FC<Params> = ({
|
||||
enableFilterButton,
|
||||
enableSorting,
|
||||
displayName,
|
||||
setSort,
|
||||
column,
|
||||
api,
|
||||
}: Params) => {
|
||||
const theme = useTheme();
|
||||
const colId = column.getColId();
|
||||
const pinnedLeft = column.isPinnedLeft();
|
||||
const pinnedRight = column.isPinnedRight();
|
||||
const sortOption = useRef<number>(0);
|
||||
const [invisibleColumns, setInvisibleColumns] = useState<Column[]>([]);
|
||||
const [currentSort, setCurrentSort] = useState<string | null>(null);
|
||||
const [sortIndex, setSortIndex] = useState<number | null>();
|
||||
const onSort = useCallback(
|
||||
event => {
|
||||
sortOption.current = (sortOption.current + 1) % SORT_DIRECTION.length;
|
||||
const sort = SORT_DIRECTION[sortOption.current];
|
||||
setSort(sort, event.shiftKey);
|
||||
setCurrentSort(sort);
|
||||
},
|
||||
[setSort],
|
||||
);
|
||||
const onVisibleChange = useCallback(
|
||||
(isVisible: boolean) => {
|
||||
if (isVisible) {
|
||||
setInvisibleColumns(
|
||||
api.getColumns()?.filter(c => !c.isVisible()) || [],
|
||||
);
|
||||
}
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
const onSortChanged = useCallback(() => {
|
||||
const hasMultiSort =
|
||||
api.getAllDisplayedColumns().findIndex(c => c.getSortIndex()) !== -1;
|
||||
const updatedSortIndex = column.getSortIndex();
|
||||
sortOption.current = SORT_DIRECTION.indexOf(column.getSort() ?? null);
|
||||
setCurrentSort(column.getSort() ?? null);
|
||||
setSortIndex(hasMultiSort ? updatedSortIndex : null);
|
||||
}, [api, column]);
|
||||
|
||||
useEffect(() => {
|
||||
api.addEventListener('sortChanged', onSortChanged);
|
||||
|
||||
return () => {
|
||||
if (api.isDestroyed()) return;
|
||||
api.removeEventListener('sortChanged', onSortChanged);
|
||||
};
|
||||
}, [api, onSortChanged]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{colId !== PIVOT_COL_ID && (
|
||||
<HeaderCell
|
||||
tabIndex={0}
|
||||
className="ag-header-cell-label"
|
||||
{...(enableSorting && {
|
||||
role: 'button',
|
||||
onClick: onSort,
|
||||
title: t(
|
||||
'To enable multiple column sorting, hold down the ⇧ Shift key while clicking the column header.',
|
||||
),
|
||||
})}
|
||||
>
|
||||
<div className="ag-header-cell-text">{displayName}</div>
|
||||
{enableSorting && (
|
||||
<HeaderCellSort>
|
||||
<Icons.Sort iconSize="xxl" />
|
||||
<IconPlaceholder>
|
||||
{currentSort === 'asc' && (
|
||||
<Icons.SortAsc
|
||||
iconSize="xxl"
|
||||
iconColor={theme.colors.primary.base}
|
||||
/>
|
||||
)}
|
||||
{currentSort === 'desc' && (
|
||||
<Icons.SortDesc
|
||||
iconSize="xxl"
|
||||
iconColor={theme.colors.primary.base}
|
||||
/>
|
||||
)}
|
||||
</IconPlaceholder>
|
||||
{typeof sortIndex === 'number' && (
|
||||
<SortSeqLabel>{sortIndex + 1}</SortSeqLabel>
|
||||
)}
|
||||
</HeaderCellSort>
|
||||
)}
|
||||
</HeaderCell>
|
||||
)}
|
||||
{enableFilterButton && colId && api && (
|
||||
<HeaderAction
|
||||
className={`customHeaderAction${
|
||||
colId === PIVOT_COL_ID ? ' main' : ''
|
||||
}`}
|
||||
>
|
||||
{colId && (
|
||||
<HeaderMenu
|
||||
colId={colId}
|
||||
api={api}
|
||||
pinnedLeft={pinnedLeft}
|
||||
pinnedRight={pinnedRight}
|
||||
invisibleColumns={invisibleColumns}
|
||||
isMain={colId === PIVOT_COL_ID}
|
||||
onVisibleChange={onVisibleChange}
|
||||
/>
|
||||
)}
|
||||
</HeaderAction>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Column, GridApi } from 'ag-grid-community';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
waitFor,
|
||||
screen,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import HeaderMenu from './HeaderMenu';
|
||||
|
||||
jest.mock('src/components/Menu', () => {
|
||||
const Menu = ({ children }: { children: React.ReactChild }) => (
|
||||
<div data-test="mock-Menu">{children}</div>
|
||||
);
|
||||
Menu.Item = ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactChild;
|
||||
onClick: () => void;
|
||||
}) => (
|
||||
<button type="button" data-test="mock-Item" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
Menu.SubMenu = ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div>
|
||||
{title}
|
||||
<button type="button" data-test="mock-SubMenu">
|
||||
{children}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
Menu.Divider = () => <div data-test="mock-Divider" />;
|
||||
return { Menu };
|
||||
});
|
||||
|
||||
jest.mock('src/components/Icons', () => ({
|
||||
DownloadOutlined: () => <div data-test="mock-DownloadOutlined" />,
|
||||
CopyOutlined: () => <div data-test="mock-CopyOutlined" />,
|
||||
UnlockOutlined: () => <div data-test="mock-UnlockOutlined" />,
|
||||
VerticalRightOutlined: () => <div data-test="mock-VerticalRightOutlined" />,
|
||||
VerticalLeftOutlined: () => <div data-test="mock-VerticalLeftOutlined" />,
|
||||
EyeInvisibleOutlined: () => <div data-test="mock-EyeInvisibleOutlined" />,
|
||||
EyeOutlined: () => <div data-test="mock-EyeOutlined" />,
|
||||
ColumnWidthOutlined: () => <div data-test="mock-column-width" />,
|
||||
}));
|
||||
|
||||
jest.mock('src/components/Dropdown', () => ({
|
||||
Dropdown: ({ overlay }: { overlay: React.ReactChild }) => (
|
||||
<div data-test="mock-Dropdown">{overlay}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('src/utils/copy', () => jest.fn().mockImplementation(f => f()));
|
||||
|
||||
const mockInvisibleColumn = {
|
||||
getColId: jest.fn().mockReturnValue('column2'),
|
||||
getColDef: jest.fn().mockReturnValue({ headerName: 'column2' }),
|
||||
getDataAsCsv: jest.fn().mockReturnValue('csv'),
|
||||
} as any as Column;
|
||||
|
||||
const mockInvisibleColumn3 = {
|
||||
getColId: jest.fn().mockReturnValue('column3'),
|
||||
getColDef: jest.fn().mockReturnValue({ headerName: 'column3' }),
|
||||
getDataAsCsv: jest.fn().mockReturnValue('csv'),
|
||||
} as any as Column;
|
||||
|
||||
const mockGridApi = {
|
||||
autoSizeColumns: jest.fn(),
|
||||
autoSizeAllColumns: jest.fn(),
|
||||
getColumn: jest.fn().mockReturnValue({
|
||||
getColDef: jest.fn().mockReturnValue({}),
|
||||
}),
|
||||
getColumns: jest.fn().mockReturnValue([]),
|
||||
getDataAsCsv: jest.fn().mockReturnValue('csv'),
|
||||
exportDataAsCsv: jest.fn().mockReturnValue('csv'),
|
||||
getAllDisplayedColumns: jest.fn().mockReturnValue([]),
|
||||
setColumnsPinned: jest.fn(),
|
||||
setColumnsVisible: jest.fn(),
|
||||
setColumnVisible: jest.fn(),
|
||||
moveColumns: jest.fn(),
|
||||
} as any as GridApi;
|
||||
|
||||
const mockedProps = {
|
||||
colId: 'column1',
|
||||
invisibleColumns: [],
|
||||
api: mockGridApi,
|
||||
onVisibleChange: jest.fn(),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
(mockGridApi.getDataAsCsv as jest.Mock).mockClear();
|
||||
(mockGridApi.setColumnsPinned as jest.Mock).mockClear();
|
||||
(mockGridApi.setColumnsVisible as jest.Mock).mockClear();
|
||||
(mockGridApi.setColumnsVisible as jest.Mock).mockClear();
|
||||
(mockGridApi.setColumnsPinned as jest.Mock).mockClear();
|
||||
(mockGridApi.autoSizeColumns as jest.Mock).mockClear();
|
||||
(mockGridApi.autoSizeAllColumns as jest.Mock).mockClear();
|
||||
(mockGridApi.moveColumns as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
test('renders copy data', async () => {
|
||||
const { getByText } = render(<HeaderMenu {...mockedProps} />);
|
||||
fireEvent.click(getByText('Copy'));
|
||||
await waitFor(() =>
|
||||
expect(mockGridApi.getDataAsCsv).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(mockGridApi.getDataAsCsv).toHaveBeenCalledWith({
|
||||
columnKeys: [mockedProps.colId],
|
||||
suppressQuotes: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('renders buttons pinning both sides', () => {
|
||||
const { queryByText, getByText } = render(<HeaderMenu {...mockedProps} />);
|
||||
expect(queryByText('Pin Left')).toBeInTheDocument();
|
||||
expect(queryByText('Pin Right')).toBeInTheDocument();
|
||||
fireEvent.click(getByText('Pin Left'));
|
||||
expect(mockGridApi.setColumnsPinned).toHaveBeenCalledTimes(1);
|
||||
expect(mockGridApi.setColumnsPinned).toHaveBeenCalledWith(
|
||||
[mockedProps.colId],
|
||||
'left',
|
||||
);
|
||||
fireEvent.click(getByText('Pin Right'));
|
||||
expect(mockGridApi.setColumnsPinned).toHaveBeenLastCalledWith(
|
||||
[mockedProps.colId],
|
||||
'right',
|
||||
);
|
||||
});
|
||||
|
||||
test('renders unpin on pinned left', () => {
|
||||
const { queryByText, getByText } = render(
|
||||
<HeaderMenu {...mockedProps} pinnedLeft />,
|
||||
);
|
||||
expect(queryByText('Pin Left')).not.toBeInTheDocument();
|
||||
expect(queryByText('Unpin')).toBeInTheDocument();
|
||||
fireEvent.click(getByText('Unpin'));
|
||||
expect(mockGridApi.setColumnsPinned).toHaveBeenCalledTimes(1);
|
||||
expect(mockGridApi.setColumnsPinned).toHaveBeenCalledWith(
|
||||
[mockedProps.colId],
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('renders unpin on pinned right', () => {
|
||||
const { queryByText } = render(<HeaderMenu {...mockedProps} pinnedRight />);
|
||||
expect(queryByText('Pin Right')).not.toBeInTheDocument();
|
||||
expect(queryByText('Unpin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders autosize column', async () => {
|
||||
const { getByText } = render(<HeaderMenu {...mockedProps} />);
|
||||
fireEvent.click(getByText('Autosize Column'));
|
||||
await waitFor(() =>
|
||||
expect(mockGridApi.autoSizeColumns).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
});
|
||||
|
||||
test('renders unhide when invisible column exists', async () => {
|
||||
const { queryByText } = render(
|
||||
<HeaderMenu {...mockedProps} invisibleColumns={[mockInvisibleColumn]} />,
|
||||
);
|
||||
expect(queryByText('Unhide')).toBeInTheDocument();
|
||||
const unhideColumnsButton = await screen.findByText('column2');
|
||||
fireEvent.click(unhideColumnsButton);
|
||||
expect(mockGridApi.setColumnsVisible).toHaveBeenCalledTimes(1);
|
||||
expect(mockGridApi.setColumnsVisible).toHaveBeenCalledWith(['column2'], true);
|
||||
});
|
||||
|
||||
describe('for main menu', () => {
|
||||
test('renders Copy to Clipboard', async () => {
|
||||
const { getByText } = render(<HeaderMenu {...mockedProps} isMain />);
|
||||
fireEvent.click(getByText('Copy the current data'));
|
||||
await waitFor(() =>
|
||||
expect(mockGridApi.getDataAsCsv).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(mockGridApi.getDataAsCsv).toHaveBeenCalledWith({
|
||||
columnKeys: [],
|
||||
columnSeparator: '\t',
|
||||
suppressQuotes: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('renders Download to CSV', async () => {
|
||||
const { getByText } = render(<HeaderMenu {...mockedProps} isMain />);
|
||||
fireEvent.click(getByText('Download to CSV'));
|
||||
await waitFor(() =>
|
||||
expect(mockGridApi.exportDataAsCsv).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(mockGridApi.exportDataAsCsv).toHaveBeenCalledWith({
|
||||
columnKeys: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('renders autosize column', async () => {
|
||||
const { getByText } = render(<HeaderMenu {...mockedProps} isMain />);
|
||||
fireEvent.click(getByText('Autosize all columns'));
|
||||
await waitFor(() =>
|
||||
expect(mockGridApi.autoSizeAllColumns).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
});
|
||||
|
||||
test('renders all unhide all hidden columns when multiple invisible columns exist', async () => {
|
||||
render(
|
||||
<HeaderMenu
|
||||
{...mockedProps}
|
||||
isMain
|
||||
invisibleColumns={[mockInvisibleColumn, mockInvisibleColumn3]}
|
||||
/>,
|
||||
);
|
||||
const unhideColumnsButton = await screen.findByText(
|
||||
`All ${2} hidden columns`,
|
||||
);
|
||||
fireEvent.click(unhideColumnsButton);
|
||||
expect(mockGridApi.setColumnsVisible).toHaveBeenCalledTimes(1);
|
||||
expect(mockGridApi.setColumnsVisible).toHaveBeenCalledWith(
|
||||
[mockInvisibleColumn, mockInvisibleColumn3],
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('reset columns configuration', async () => {
|
||||
const { getByText } = render(
|
||||
<HeaderMenu
|
||||
{...mockedProps}
|
||||
isMain
|
||||
invisibleColumns={[mockInvisibleColumn]}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(getByText('Reset columns'));
|
||||
await waitFor(() =>
|
||||
expect(mockGridApi.setColumnsVisible).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(mockGridApi.setColumnsVisible).toHaveBeenCalledWith(
|
||||
[mockInvisibleColumn],
|
||||
true,
|
||||
);
|
||||
expect(mockGridApi.setColumnsPinned).toHaveBeenCalledTimes(1);
|
||||
expect(mockGridApi.setColumnsPinned).toHaveBeenCalledWith([], null);
|
||||
expect(mockGridApi.moveColumns).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
import { styled, t } from '@superset-ui/core';
|
||||
import type { Column, ColumnPinnedType, GridApi } from 'ag-grid-community';
|
||||
|
||||
import Icons from 'src/components/Icons';
|
||||
import { Dropdown, DropdownProps } from 'src/components/Dropdown';
|
||||
import { Menu } from 'src/components/Menu';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import { PIVOT_COL_ID } from './constants';
|
||||
|
||||
const IconMenuItem = styled(Menu.Item)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
const IconEmpty = styled.span`
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
type Params = {
|
||||
colId: string;
|
||||
column?: Column;
|
||||
api: GridApi;
|
||||
pinnedLeft?: boolean;
|
||||
pinnedRight?: boolean;
|
||||
invisibleColumns: Column[];
|
||||
isMain?: boolean;
|
||||
onVisibleChange: DropdownProps['onVisibleChange'];
|
||||
};
|
||||
|
||||
const HeaderMenu: React.FC<Params> = ({
|
||||
colId,
|
||||
api,
|
||||
pinnedLeft,
|
||||
pinnedRight,
|
||||
invisibleColumns,
|
||||
isMain,
|
||||
onVisibleChange,
|
||||
}: Params) => {
|
||||
const pinColumn = useCallback(
|
||||
(pinLoc: ColumnPinnedType) => {
|
||||
api.setColumnsPinned([colId], pinLoc);
|
||||
},
|
||||
[api, colId],
|
||||
);
|
||||
|
||||
const unHideAction = invisibleColumns.length > 0 && (
|
||||
<Menu.SubMenu
|
||||
title={
|
||||
<>
|
||||
<Icons.EyeOutlined iconSize="m" />
|
||||
{t('Unhide')}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{invisibleColumns.length > 1 && (
|
||||
<Menu.Item
|
||||
onClick={() => {
|
||||
api.setColumnsVisible(invisibleColumns, true);
|
||||
}}
|
||||
>
|
||||
<b>{t('All %s hidden columns', invisibleColumns.length)}</b>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{invisibleColumns.map(c => (
|
||||
<Menu.Item
|
||||
key={c.getColId()}
|
||||
onClick={() => {
|
||||
api.setColumnsVisible([c.getColId()], true);
|
||||
}}
|
||||
>
|
||||
{c.getColDef().headerName}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.SubMenu>
|
||||
);
|
||||
|
||||
if (isMain) {
|
||||
return (
|
||||
<Dropdown
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
onVisibleChange={onVisibleChange}
|
||||
overlay={
|
||||
<Menu style={{ width: 250 }} mode="vertical">
|
||||
<IconMenuItem
|
||||
onClick={() => {
|
||||
copyTextToClipboard(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
const data = api.getDataAsCsv({
|
||||
columnKeys: api
|
||||
.getAllDisplayedColumns()
|
||||
.map(c => c.getColId())
|
||||
.filter(id => id !== colId),
|
||||
suppressQuotes: true,
|
||||
columnSeparator: '\t',
|
||||
});
|
||||
if (data) {
|
||||
resolve(data);
|
||||
} else {
|
||||
reject();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icons.CopyOutlined iconSize="m" /> {t('Copy the current data')}
|
||||
</IconMenuItem>
|
||||
<IconMenuItem
|
||||
onClick={() => {
|
||||
api.exportDataAsCsv({
|
||||
columnKeys: api
|
||||
.getAllDisplayedColumns()
|
||||
.map(c => c.getColId())
|
||||
.filter(id => id !== colId),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icons.DownloadOutlined iconSize="m" /> {t('Download to CSV')}
|
||||
</IconMenuItem>
|
||||
<Menu.Divider />
|
||||
<IconMenuItem
|
||||
onClick={() => {
|
||||
api.autoSizeAllColumns();
|
||||
}}
|
||||
>
|
||||
<Icons.ColumnWidthOutlined iconSize="m" />
|
||||
{t('Autosize all columns')}
|
||||
</IconMenuItem>
|
||||
{unHideAction}
|
||||
<Menu.Divider />
|
||||
<IconMenuItem
|
||||
onClick={() => {
|
||||
api.setColumnsVisible(invisibleColumns, true);
|
||||
const columns = api.getColumns();
|
||||
if (columns) {
|
||||
const pinnedColumns = columns.filter(
|
||||
c => c.getColId() !== PIVOT_COL_ID && c.isPinned(),
|
||||
);
|
||||
api.setColumnsPinned(pinnedColumns, null);
|
||||
api.moveColumns(columns, 0);
|
||||
const firstColumn = columns.find(
|
||||
c => c.getColId() !== PIVOT_COL_ID,
|
||||
);
|
||||
if (firstColumn) {
|
||||
api.ensureColumnVisible(firstColumn, 'start');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconEmpty className="anticon" />
|
||||
{t('Reset columns')}
|
||||
</IconMenuItem>
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
onVisibleChange={onVisibleChange}
|
||||
overlay={
|
||||
<Menu style={{ width: 180 }} mode="vertical">
|
||||
<IconMenuItem
|
||||
onClick={() => {
|
||||
copyTextToClipboard(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
const data = api.getDataAsCsv({
|
||||
columnKeys: [colId],
|
||||
suppressQuotes: true,
|
||||
});
|
||||
if (data) {
|
||||
resolve(data);
|
||||
} else {
|
||||
reject();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icons.CopyOutlined iconSize="m" /> {t('Copy')}
|
||||
</IconMenuItem>
|
||||
{(pinnedLeft || pinnedRight) && (
|
||||
<IconMenuItem onClick={() => pinColumn(null)}>
|
||||
<Icons.UnlockOutlined iconSize="m" /> {t('Unpin')}
|
||||
</IconMenuItem>
|
||||
)}
|
||||
{!pinnedLeft && (
|
||||
<IconMenuItem onClick={() => pinColumn('left')}>
|
||||
<Icons.VerticalRightOutlined iconSize="m" />
|
||||
{t('Pin Left')}
|
||||
</IconMenuItem>
|
||||
)}
|
||||
{!pinnedRight && (
|
||||
<IconMenuItem onClick={() => pinColumn('right')}>
|
||||
<Icons.VerticalLeftOutlined iconSize="m" />
|
||||
{t('Pin Right')}
|
||||
</IconMenuItem>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<IconMenuItem
|
||||
onClick={() => {
|
||||
api.autoSizeColumns([colId]);
|
||||
}}
|
||||
>
|
||||
<Icons.ColumnWidthOutlined iconSize="m" />
|
||||
{t('Autosize Column')}
|
||||
</IconMenuItem>
|
||||
<IconMenuItem
|
||||
onClick={() => {
|
||||
api.setColumnsVisible([colId], false);
|
||||
}}
|
||||
disabled={api.getColumns()?.length === invisibleColumns.length + 1}
|
||||
>
|
||||
<Icons.EyeInvisibleOutlined iconSize="m" />
|
||||
{t('Hide Column')}
|
||||
</IconMenuItem>
|
||||
{unHideAction}
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderMenu;
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Global } from '@emotion/react';
|
||||
import { css, useTheme } from '@superset-ui/core';
|
||||
|
||||
import type { Column } from 'ag-grid-community';
|
||||
import { AgGridReact, type AgGridReactProps } from 'ag-grid-react';
|
||||
|
||||
import 'ag-grid-community/styles/ag-grid.css';
|
||||
import 'ag-grid-community/styles/ag-theme-quartz.css';
|
||||
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import ErrorBoundary from 'src/components/ErrorBoundary';
|
||||
|
||||
import { PIVOT_COL_ID, GridSize } from './constants';
|
||||
import Header from './Header';
|
||||
|
||||
const gridComponents = {
|
||||
agColumnHeader: Header,
|
||||
};
|
||||
|
||||
export { GridSize };
|
||||
|
||||
export type ColDef = {
|
||||
type: string;
|
||||
field: string;
|
||||
};
|
||||
|
||||
export interface TableProps<RecordType> {
|
||||
/**
|
||||
* Data that will populate the each row and map to the column key.
|
||||
*/
|
||||
data: RecordType[];
|
||||
/**
|
||||
* Table column definitions.
|
||||
*/
|
||||
columns: {
|
||||
label: string;
|
||||
headerName?: string;
|
||||
width?: number;
|
||||
comparator?: (valueA: string | number, valueB: string | number) => number;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
}[];
|
||||
|
||||
size?: GridSize;
|
||||
|
||||
externalFilter?: AgGridReactProps['doesExternalFilterPass'];
|
||||
|
||||
height: number;
|
||||
|
||||
columnReorderable?: boolean;
|
||||
|
||||
sortable?: boolean;
|
||||
|
||||
enableActions?: boolean;
|
||||
|
||||
showRowNumber?: boolean;
|
||||
|
||||
usePagination?: boolean;
|
||||
|
||||
striped?: boolean;
|
||||
}
|
||||
|
||||
const onSortChanged: AgGridReactProps['onSortChanged'] = ({ api }) =>
|
||||
api.refreshCells();
|
||||
|
||||
function GridTable<RecordType extends object>({
|
||||
data,
|
||||
columns,
|
||||
sortable = true,
|
||||
columnReorderable,
|
||||
height,
|
||||
externalFilter,
|
||||
showRowNumber,
|
||||
enableActions,
|
||||
size = GridSize.Middle,
|
||||
striped,
|
||||
}: TableProps<RecordType>) {
|
||||
const theme = useTheme();
|
||||
const isExternalFilterPresent = useCallback(
|
||||
() => Boolean(externalFilter),
|
||||
[externalFilter],
|
||||
);
|
||||
const rowIndexLength = `${data.length}}`.length;
|
||||
const onKeyDown: AgGridReactProps<Record<string, any>>['onCellKeyDown'] =
|
||||
useCallback(({ event, column, data, value, api }) => {
|
||||
if (
|
||||
!document.getSelection?.()?.toString?.() &&
|
||||
event &&
|
||||
event.key === 'c' &&
|
||||
(event.ctrlKey || event.metaKey)
|
||||
) {
|
||||
const columns =
|
||||
column.getColId() === PIVOT_COL_ID
|
||||
? api
|
||||
.getAllDisplayedColumns()
|
||||
.filter((column: Column) => column.getColId() !== PIVOT_COL_ID)
|
||||
: [column];
|
||||
const record =
|
||||
column.getColId() === PIVOT_COL_ID
|
||||
? [
|
||||
columns.map((column: Column) => column.getColId()).join('\t'),
|
||||
columns
|
||||
.map((column: Column) => data?.[column.getColId()])
|
||||
.join('\t'),
|
||||
].join('\n')
|
||||
: String(value);
|
||||
copyTextToClipboard(() => Promise.resolve(record));
|
||||
}
|
||||
}, []);
|
||||
const columnDefs = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
field: PIVOT_COL_ID,
|
||||
valueGetter: 'node.rowIndex+1',
|
||||
cellClass: 'locked-col',
|
||||
width: 20 + rowIndexLength * 6,
|
||||
suppressNavigable: true,
|
||||
resizable: false,
|
||||
pinned: 'left' as const,
|
||||
sortable: false,
|
||||
...(columnReorderable && { suppressMovable: true }),
|
||||
},
|
||||
...columns.map(
|
||||
(
|
||||
{ label, headerName, width, render: cellRenderer, comparator },
|
||||
index,
|
||||
) => ({
|
||||
field: label,
|
||||
headerName,
|
||||
cellRenderer,
|
||||
sortable,
|
||||
comparator,
|
||||
...(index === columns.length - 1 && {
|
||||
flex: 1,
|
||||
width,
|
||||
minWidth: 150,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
].slice(showRowNumber ? 0 : 1),
|
||||
[rowIndexLength, columnReorderable, columns, showRowNumber, sortable],
|
||||
);
|
||||
const defaultColDef: AgGridReactProps['defaultColDef'] = {
|
||||
...(!columnReorderable && { suppressMovable: true }),
|
||||
resizable: true,
|
||||
sortable,
|
||||
filter: Boolean(enableActions),
|
||||
};
|
||||
|
||||
const rowHeight = theme.gridUnit * (size === GridSize.Middle ? 9 : 7);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Global
|
||||
styles={() => css`
|
||||
#grid-table.ag-theme-quartz {
|
||||
--ag-icon-font-family: agGridMaterial;
|
||||
--ag-grid-size: ${theme.gridUnit}px;
|
||||
--ag-font-size: ${theme.typography.sizes[
|
||||
size === GridSize.Middle ? 'm' : 's'
|
||||
]}px;
|
||||
--ag-font-family: ${theme.typography.families.sansSerif};
|
||||
--ag-row-height: ${rowHeight}px;
|
||||
${!striped &&
|
||||
`--ag-odd-row-background-color: ${theme.colors.grayscale.light5};`}
|
||||
--ag-border-color: ${theme.colors.grayscale.light2};
|
||||
--ag-row-border-color: ${theme.colors.grayscale.light2};
|
||||
--ag-header-background-color: ${theme.colors.grayscale.light4};
|
||||
}
|
||||
#grid-table .ag-cell {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.locked-col {
|
||||
background: var(--ag-row-border-color);
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
font-size: calc(var(--ag-font-size) * 0.9);
|
||||
color: var(--ag-disabled-foreground-color);
|
||||
}
|
||||
.ag-row-hover .locked-col {
|
||||
background: var(--ag-row-hover-color);
|
||||
}
|
||||
.ag-header-cell {
|
||||
overflow: hidden;
|
||||
}
|
||||
& [role='columnheader']:hover .customHeaderAction {
|
||||
display: block;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
<div
|
||||
id="grid-table"
|
||||
className="ag-theme-quartz"
|
||||
css={css`
|
||||
width: 100%;
|
||||
height: ${height}px;
|
||||
`}
|
||||
>
|
||||
<AgGridReact
|
||||
rowData={data}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
onSortChanged={onSortChanged}
|
||||
isExternalFilterPresent={isExternalFilterPresent}
|
||||
doesExternalFilterPass={externalFilter}
|
||||
components={gridComponents}
|
||||
gridOptions={{
|
||||
enableCellTextSelection: true,
|
||||
ensureDomOrder: true,
|
||||
suppressFieldDotNotation: true,
|
||||
headerHeight: rowHeight,
|
||||
rowSelection: 'multiple',
|
||||
rowHeight,
|
||||
}}
|
||||
onCellKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default GridTable;
|
||||
|
|
@ -27,10 +27,13 @@ import {
|
|||
BarChartOutlined,
|
||||
BellOutlined,
|
||||
BookOutlined,
|
||||
CaretDownOutlined,
|
||||
CalendarOutlined,
|
||||
CaretUpOutlined,
|
||||
CheckOutlined,
|
||||
CheckSquareOutlined,
|
||||
CloseOutlined,
|
||||
ColumnWidthOutlined,
|
||||
CommentOutlined,
|
||||
ConsoleSqlOutlined,
|
||||
CopyOutlined,
|
||||
|
|
@ -38,6 +41,7 @@ import {
|
|||
DatabaseOutlined,
|
||||
DeleteFilled,
|
||||
DownOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
EyeOutlined,
|
||||
|
|
@ -65,8 +69,11 @@ import {
|
|||
StopOutlined,
|
||||
SyncOutlined,
|
||||
TagsOutlined,
|
||||
UnlockOutlined,
|
||||
UpOutlined,
|
||||
UserOutlined,
|
||||
VerticalLeftOutlined,
|
||||
VerticalRightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { StyledIcon } from './Icon';
|
||||
import IconType from './IconType';
|
||||
|
|
@ -80,10 +87,13 @@ const AntdIcons = {
|
|||
BarChartOutlined,
|
||||
BellOutlined,
|
||||
BookOutlined,
|
||||
CaretDownOutlined,
|
||||
CalendarOutlined,
|
||||
CaretUpOutlined,
|
||||
CheckOutlined,
|
||||
CheckSquareOutlined,
|
||||
CloseOutlined,
|
||||
ColumnWidthOutlined,
|
||||
CommentOutlined,
|
||||
ConsoleSqlOutlined,
|
||||
CopyOutlined,
|
||||
|
|
@ -91,6 +101,7 @@ const AntdIcons = {
|
|||
DatabaseOutlined,
|
||||
DeleteFilled,
|
||||
DownOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
EyeOutlined,
|
||||
|
|
@ -118,8 +129,11 @@ const AntdIcons = {
|
|||
StopOutlined,
|
||||
SyncOutlined,
|
||||
TagsOutlined,
|
||||
UnlockOutlined,
|
||||
UpOutlined,
|
||||
UserOutlined,
|
||||
VerticalLeftOutlined,
|
||||
VerticalRightOutlined,
|
||||
};
|
||||
|
||||
const AntdEnhancedIcons = Object.keys(AntdIcons)
|
||||
|
|
|
|||
Loading…
Reference in New Issue