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",
|
"@visx/xychart": "^3.5.1",
|
||||||
"abortcontroller-polyfill": "^1.7.8",
|
"abortcontroller-polyfill": "^1.7.8",
|
||||||
"ace-builds": "^1.36.3",
|
"ace-builds": "^1.36.3",
|
||||||
|
"ag-grid-community": "32.2.1",
|
||||||
|
"ag-grid-react": "32.2.1",
|
||||||
"antd": "4.10.3",
|
"antd": "4.10.3",
|
||||||
"antd-v5": "npm:antd@^5.18.0",
|
"antd-v5": "npm:antd@^5.18.0",
|
||||||
"bootstrap": "^3.4.1",
|
"bootstrap": "^3.4.1",
|
||||||
|
|
@ -14478,6 +14480,32 @@
|
||||||
"node": ">= 10.0.0"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,8 @@
|
||||||
"@visx/xychart": "^3.5.1",
|
"@visx/xychart": "^3.5.1",
|
||||||
"abortcontroller-polyfill": "^1.7.8",
|
"abortcontroller-polyfill": "^1.7.8",
|
||||||
"ace-builds": "^1.36.3",
|
"ace-builds": "^1.36.3",
|
||||||
|
"ag-grid-community": "32.2.1",
|
||||||
|
"ag-grid-react": "32.2.1",
|
||||||
"antd": "4.10.3",
|
"antd": "4.10.3",
|
||||||
"antd-v5": "npm:antd@^5.18.0",
|
"antd-v5": "npm:antd@^5.18.0",
|
||||||
"bootstrap": "^3.4.1",
|
"bootstrap": "^3.4.1",
|
||||||
|
|
|
||||||
|
|
@ -354,7 +354,7 @@ describe('ResultSet', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const { getByRole } = setup(mockedProps, mockStore(initialState));
|
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 () => {
|
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', () => {
|
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,
|
name: /fetch data preview/i,
|
||||||
}),
|
}),
|
||||||
).toBeVisible();
|
).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', () => {
|
test('Async queries - renders "Refetch results" button when a query has no results', () => {
|
||||||
|
|
@ -429,7 +429,7 @@ describe('ResultSet', () => {
|
||||||
name: /refetch results/i,
|
name: /refetch results/i,
|
||||||
}),
|
}),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
expect(screen.queryByRole('treegrid')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Async queries - renders on the first call', () => {
|
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(
|
expect(
|
||||||
screen.queryByRole('button', {
|
screen.queryByRole('button', {
|
||||||
name: /fetch data preview/i,
|
name: /fetch data preview/i,
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ describe('FilterableTable', () => {
|
||||||
const { getByRole, getByText } = render(
|
const { getByRole, getByText } = render(
|
||||||
<FilterableTable {...mockedProps} />,
|
<FilterableTable {...mockedProps} />,
|
||||||
);
|
);
|
||||||
expect(getByRole('table')).toBeInTheDocument();
|
expect(getByRole('treegrid')).toBeInTheDocument();
|
||||||
mockedProps.data.forEach(({ b: columnBContent }) => {
|
mockedProps.data.forEach(({ b: columnBContent }) => {
|
||||||
expect(getByText(columnBContent)).toBeInTheDocument();
|
expect(getByText(columnBContent)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
@ -78,11 +78,10 @@ describe('FilterableTable sorting - RTL', () => {
|
||||||
};
|
};
|
||||||
render(<FilterableTable {...stringProps} />);
|
render(<FilterableTable {...stringProps} />);
|
||||||
|
|
||||||
const stringColumn = within(screen.getByRole('table'))
|
const stringColumn = within(screen.getByRole('treegrid'))
|
||||||
.getByText('columnA')
|
.getByText('columnA')
|
||||||
.closest('th');
|
.closest('[role=button]');
|
||||||
// Antd 4.x Table does not follow the table role structure. Need a hacky selector to point the cell item
|
const gridCells = screen.getByText('Bravo').closest('[role=rowgroup]');
|
||||||
const gridCells = screen.getByTitle('Bravo').closest('.virtual-grid');
|
|
||||||
|
|
||||||
// Original order
|
// Original order
|
||||||
expect(gridCells?.textContent).toEqual(
|
expect(gridCells?.textContent).toEqual(
|
||||||
|
|
@ -124,10 +123,10 @@ describe('FilterableTable sorting - RTL', () => {
|
||||||
};
|
};
|
||||||
render(<FilterableTable {...integerProps} />);
|
render(<FilterableTable {...integerProps} />);
|
||||||
|
|
||||||
const integerColumn = within(screen.getByRole('table'))
|
const integerColumn = within(screen.getByRole('treegrid'))
|
||||||
.getByText('columnB')
|
.getByText('columnB')
|
||||||
.closest('th');
|
.closest('[role=button]');
|
||||||
const gridCells = screen.getByTitle('21').closest('.virtual-grid');
|
const gridCells = screen.getByText('21').closest('[role=rowgroup]');
|
||||||
|
|
||||||
// Original order
|
// Original order
|
||||||
expect(gridCells?.textContent).toEqual(['21', '0', '623'].join(''));
|
expect(gridCells?.textContent).toEqual(['21', '0', '623'].join(''));
|
||||||
|
|
@ -159,10 +158,10 @@ describe('FilterableTable sorting - RTL', () => {
|
||||||
};
|
};
|
||||||
render(<FilterableTable {...floatProps} />);
|
render(<FilterableTable {...floatProps} />);
|
||||||
|
|
||||||
const floatColumn = within(screen.getByRole('table'))
|
const floatColumn = within(screen.getByRole('treegrid'))
|
||||||
.getByText('columnC')
|
.getByText('columnC')
|
||||||
.closest('th');
|
.closest('[role=button]');
|
||||||
const gridCells = screen.getByTitle('45.67').closest('.virtual-grid');
|
const gridCells = screen.getByText('45.67').closest('[role=rowgroup]');
|
||||||
|
|
||||||
// Original order
|
// Original order
|
||||||
expect(gridCells?.textContent).toEqual(
|
expect(gridCells?.textContent).toEqual(
|
||||||
|
|
@ -214,10 +213,10 @@ describe('FilterableTable sorting - RTL', () => {
|
||||||
};
|
};
|
||||||
render(<FilterableTable {...mixedFloatProps} />);
|
render(<FilterableTable {...mixedFloatProps} />);
|
||||||
|
|
||||||
const mixedFloatColumn = within(screen.getByRole('table'))
|
const mixedFloatColumn = within(screen.getByRole('treegrid'))
|
||||||
.getByText('columnD')
|
.getByText('columnD')
|
||||||
.closest('th');
|
.closest('[role=button]');
|
||||||
const gridCells = screen.getByTitle('48710.92').closest('.virtual-grid');
|
const gridCells = screen.getByText('48710.92').closest('[role=rowgroup]');
|
||||||
|
|
||||||
// Original order
|
// Original order
|
||||||
expect(gridCells?.textContent).toEqual(
|
expect(gridCells?.textContent).toEqual(
|
||||||
|
|
@ -312,10 +311,10 @@ describe('FilterableTable sorting - RTL', () => {
|
||||||
};
|
};
|
||||||
render(<FilterableTable {...dsProps} />);
|
render(<FilterableTable {...dsProps} />);
|
||||||
|
|
||||||
const dsColumn = within(screen.getByRole('table'))
|
const dsColumn = within(screen.getByRole('treegrid'))
|
||||||
.getByText('columnDS')
|
.getByText('columnDS')
|
||||||
.closest('th');
|
.closest('[role=button]');
|
||||||
const gridCells = screen.getByTitle('2021-01-01').closest('.virtual-grid');
|
const gridCells = screen.getByText('2021-01-01').closest('[role=rowgroup]');
|
||||||
|
|
||||||
// Original order
|
// Original order
|
||||||
expect(gridCells?.textContent).toEqual(
|
expect(gridCells?.textContent).toEqual(
|
||||||
|
|
|
||||||
|
|
@ -16,55 +16,20 @@
|
||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import _JSONbig from 'json-bigint';
|
import { useMemo, useRef, useCallback } from 'react';
|
||||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
import { styled } from '@superset-ui/core';
|
||||||
import { getMultipleTextDimensions, styled } from '@superset-ui/core';
|
|
||||||
import { useDebounceValue } from 'src/hooks/useDebounceValue';
|
|
||||||
import { useCellContentParser } from './useCellContentParser';
|
import { useCellContentParser } from './useCellContentParser';
|
||||||
import { renderResultCell } from './utils';
|
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,
|
// This regex handles all possible number formats in javascript, including ints, floats,
|
||||||
// exponential notation, NaN, and Infinity.
|
// exponential notation, NaN, and Infinity.
|
||||||
// See https://stackoverflow.com/a/30987109 for more details
|
// See https://stackoverflow.com/a/30987109 for more details
|
||||||
const ONLY_NUMBER_REGEX = /^(NaN|-?((\d*\.\d+|\d+)([Ee][+-]?\d+)?|Infinity))$/;
|
const ONLY_NUMBER_REGEX = /^(NaN|-?((\d*\.\d+|\d+)([Ee][+-]?\d+)?|Infinity))$/;
|
||||||
|
|
||||||
const StyledFilterableTable = styled.div`
|
const StyledFilterableTable = styled.div`
|
||||||
${({ theme }) => `
|
height: 100%;
|
||||||
height: 100%;
|
overflow: hidden;
|
||||||
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;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type CellDataType = string | number | null;
|
type CellDataType = string | number | null;
|
||||||
|
|
@ -79,12 +44,38 @@ export interface FilterableTableProps {
|
||||||
overscanColumnCount?: number;
|
overscanColumnCount?: number;
|
||||||
overscanRowCount?: number;
|
overscanRowCount?: number;
|
||||||
rowHeight?: number;
|
rowHeight?: number;
|
||||||
// need antd 5.0 to support striped color pattern
|
|
||||||
striped?: boolean;
|
striped?: boolean;
|
||||||
expandedColumns?: string[];
|
expandedColumns?: string[];
|
||||||
allowHTML?: boolean;
|
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 = ({
|
const FilterableTable = ({
|
||||||
orderedColumnKeys,
|
orderedColumnKeys,
|
||||||
data,
|
data,
|
||||||
|
|
@ -92,83 +83,13 @@ const FilterableTable = ({
|
||||||
filterText = '',
|
filterText = '',
|
||||||
expandedColumns = [],
|
expandedColumns = [],
|
||||||
allowHTML = true,
|
allowHTML = true,
|
||||||
|
striped,
|
||||||
}: FilterableTableProps) => {
|
}: 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({
|
const getCellContent = useCellContentParser({
|
||||||
columnKeys: orderedColumnKeys,
|
columnKeys: orderedColumnKeys,
|
||||||
expandedColumns,
|
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 hasMatch = (text: string, row: Datum) => {
|
||||||
const values: string[] = [];
|
const values: string[] = [];
|
||||||
Object.keys(row).forEach(key => {
|
Object.keys(row).forEach(key => {
|
||||||
|
|
@ -188,86 +109,52 @@ const FilterableTable = ({
|
||||||
return values.some(v => v.includes(lowerCaseText));
|
return values.some(v => v.includes(lowerCaseText));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse any numbers from strings so they'll sort correctly
|
const columns = useMemo(
|
||||||
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(
|
|
||||||
() =>
|
() =>
|
||||||
keyword ? list.filter((row: Datum) => hasMatch(keyword, row)) : list,
|
orderedColumnKeys.map(key => ({
|
||||||
[list, keyword],
|
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
|
const keyword = useRef<string | undefined>(filterText);
|
||||||
// and the height of the table container if the content overflows
|
keyword.current = filterText;
|
||||||
const totalTableHeight =
|
|
||||||
container.current && totalTableWidth.current > container.current.clientWidth
|
|
||||||
? height - SCROLL_BAR_HEIGHT
|
|
||||||
: height;
|
|
||||||
|
|
||||||
const columns = orderedColumnKeys.map(key => ({
|
const keywordFilter = useCallback(node => {
|
||||||
key,
|
if (keyword.current && node.data) {
|
||||||
title: key,
|
return hasMatch(keyword.current, node.data);
|
||||||
dataIndex: key,
|
}
|
||||||
width: widthsForColumnsByKey[key],
|
return true;
|
||||||
sorter: (a: Datum, b: Datum) => sortResults(key, a, b),
|
}, []);
|
||||||
render: (text: CellDataType) =>
|
|
||||||
renderResultCell({
|
|
||||||
cellData: text,
|
|
||||||
columnKey: key,
|
|
||||||
allowHTML,
|
|
||||||
getCellContent,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledFilterableTable
|
<StyledFilterableTable
|
||||||
className="filterable-table-container"
|
className="filterable-table-container"
|
||||||
data-test="table-container"
|
data-test="table-container"
|
||||||
ref={container}
|
|
||||||
>
|
>
|
||||||
{fitted && (
|
<GridTable
|
||||||
<Table
|
size={GridSize.Small}
|
||||||
loading={filterText !== keyword}
|
height={height}
|
||||||
size={TableSize.Small}
|
usePagination={false}
|
||||||
height={totalTableHeight + 42}
|
columns={columns}
|
||||||
usePagination={false}
|
data={data}
|
||||||
columns={columns}
|
externalFilter={keywordFilter}
|
||||||
data={filteredList}
|
showRowNumber
|
||||||
childrenColumnName=""
|
striped={striped}
|
||||||
virtualize
|
enableActions
|
||||||
bordered
|
columnReorderable
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</StyledFilterableTable>
|
</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,
|
BarChartOutlined,
|
||||||
BellOutlined,
|
BellOutlined,
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
|
CaretDownOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
|
CaretUpOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
CheckSquareOutlined,
|
CheckSquareOutlined,
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
|
ColumnWidthOutlined,
|
||||||
CommentOutlined,
|
CommentOutlined,
|
||||||
ConsoleSqlOutlined,
|
ConsoleSqlOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
|
|
@ -38,6 +41,7 @@ import {
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
DeleteFilled,
|
DeleteFilled,
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
|
|
@ -65,8 +69,11 @@ import {
|
||||||
StopOutlined,
|
StopOutlined,
|
||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
TagsOutlined,
|
TagsOutlined,
|
||||||
|
UnlockOutlined,
|
||||||
UpOutlined,
|
UpOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
VerticalLeftOutlined,
|
||||||
|
VerticalRightOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { StyledIcon } from './Icon';
|
import { StyledIcon } from './Icon';
|
||||||
import IconType from './IconType';
|
import IconType from './IconType';
|
||||||
|
|
@ -80,10 +87,13 @@ const AntdIcons = {
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
BellOutlined,
|
BellOutlined,
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
|
CaretDownOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
|
CaretUpOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
CheckSquareOutlined,
|
CheckSquareOutlined,
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
|
ColumnWidthOutlined,
|
||||||
CommentOutlined,
|
CommentOutlined,
|
||||||
ConsoleSqlOutlined,
|
ConsoleSqlOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
|
|
@ -91,6 +101,7 @@ const AntdIcons = {
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
DeleteFilled,
|
DeleteFilled,
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
|
|
@ -118,8 +129,11 @@ const AntdIcons = {
|
||||||
StopOutlined,
|
StopOutlined,
|
||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
TagsOutlined,
|
TagsOutlined,
|
||||||
|
UnlockOutlined,
|
||||||
UpOutlined,
|
UpOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
VerticalLeftOutlined,
|
||||||
|
VerticalRightOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const AntdEnhancedIcons = Object.keys(AntdIcons)
|
const AntdEnhancedIcons = Object.keys(AntdIcons)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue