diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index be82a6135b8..3f0fc5b8d20 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -36,6 +36,7 @@ "@superset-ui/legacy-plugin-chart-world-map": "file:./plugins/legacy-plugin-chart-world-map", "@superset-ui/legacy-preset-chart-deckgl": "file:./plugins/legacy-preset-chart-deckgl", "@superset-ui/legacy-preset-chart-nvd3": "file:./plugins/legacy-preset-chart-nvd3", + "@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table", "@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram", "@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts", "@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars", @@ -13640,6 +13641,10 @@ "resolved": "plugins/legacy-preset-chart-nvd3", "link": true }, + "node_modules/@superset-ui/plugin-chart-ag-grid-table": { + "resolved": "plugins/plugin-chart-ag-grid-table", + "link": true + }, "node_modules/@superset-ui/plugin-chart-cartodiagram": { "resolved": "plugins/plugin-chart-cartodiagram", "link": true @@ -60047,6 +60052,40 @@ "@types/trusted-types": "^2.0.7" } }, + "plugins/plugin-chart-ag-grid-table": { + "name": "@superset-ui/plugin-chart-ag-grid-table", + "version": "0.20.3", + "license": "Apache-2.0", + "dependencies": { + "@react-icons/all-files": "^4.1.0", + "@types/d3-array": "^2.9.0", + "@types/react-table": "^7.7.20", + "ag-grid-community": "^33.1.1", + "ag-grid-react": "^33.1.1", + "classnames": "^2.5.1", + "d3-array": "^2.4.0", + "lodash": "^4.17.21", + "memoize-one": "^5.2.1", + "react-table": "^7.8.0", + "regenerator-runtime": "^0.14.1", + "xss": "^1.0.15" + }, + "peerDependencies": { + "@ant-design/icons": "^5.2.6", + "@superset-ui/chart-controls": "*", + "@superset-ui/core": "*", + "@testing-library/dom": "^8.20.1", + "@testing-library/jest-dom": "*", + "@testing-library/react": "^12.1.5", + "@testing-library/react-hooks": "*", + "@testing-library/user-event": "*", + "@types/classnames": "*", + "@types/react": "*", + "match-sorter": "^6.3.3", + "react": "^17.0.2", + "react-dom": "^17.0.2" + } + }, "plugins/plugin-chart-cartodiagram": { "name": "@superset-ui/plugin-chart-cartodiagram", "version": "0.0.1", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 6745fbe7802..9de8b4a3b6f 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -109,6 +109,7 @@ "@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars", "@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table", "@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table", + "@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table", "@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud", "@superset-ui/switchboard": "file:./packages/superset-ui-switchboard", "@types/d3-format": "^3.0.1", diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts index 3d341af70fa..0052f160927 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts @@ -55,6 +55,7 @@ export enum VizType { Step = 'echarts_timeseries_step', Sunburst = 'sunburst_v2', Table = 'table', + TableAgGrid = 'ag-grid-table', TimePivot = 'time_pivot', TimeTable = 'time_table', Timeseries = 'echarts_timeseries', diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index f488629a079..ca1c0439360 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -60,6 +60,8 @@ export enum FeatureFlag { SlackEnableAvatars = 'SLACK_ENABLE_AVATARS', EnableDashboardScreenshotEndpoints = 'ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS', EnableDashboardDownloadWebDriverScreenshot = 'ENABLE_DASHBOARD_DOWNLOAD_WEBDRIVER_SCREENSHOT', + TableV2TimeComparisonEnabled = 'TABLE_V2_TIME_COMPARISON_ENABLED', + AgGridTableEnabled = 'AG_GRID_TABLE_ENABLED', } export type ScheduleQueriesProps = { diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/package.json b/superset-frontend/plugins/plugin-chart-ag-grid-table/package.json new file mode 100644 index 00000000000..d756fec5076 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/package.json @@ -0,0 +1,58 @@ +{ + "name": "@superset-ui/plugin-chart-ag-grid-table", + "version": "0.20.3", + "description": "Superset Chart - Table", + "keywords": [ + "superset" + ], + "homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-ag-grid-table#readme", + "bugs": { + "url": "https://github.com/apache/superset/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/apache/superset.git", + "directory": "superset-frontend/plugins/plugin-chart-ag-grid-table" + }, + "license": "Apache-2.0", + "author": "Superset", + "sideEffects": false, + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], + "dependencies": { + "@react-icons/all-files": "^4.1.0", + "@types/d3-array": "^2.9.0", + "@types/react-table": "^7.7.20", + "ag-grid-community": "^33.1.1", + "ag-grid-react": "^33.1.1", + "classnames": "^2.5.1", + "d3-array": "^2.4.0", + "lodash": "^4.17.21", + "memoize-one": "^5.2.1", + "react-table": "^7.8.0", + "regenerator-runtime": "^0.14.1", + "xss": "^1.0.15" + }, + "peerDependencies": { + "@ant-design/icons": "^5.2.6", + "@superset-ui/chart-controls": "*", + "@superset-ui/core": "*", + "@testing-library/dom": "^8.20.1", + "@testing-library/jest-dom": "*", + "@testing-library/react": "^12.1.5", + "@testing-library/react-hooks": "*", + "@testing-library/user-event": "*", + "@types/classnames": "*", + "@types/react": "*", + "match-sorter": "^6.3.3", + "react": "^17.0.2", + "react-dom": "^17.0.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/CustomHeader.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/CustomHeader.tsx new file mode 100644 index 00000000000..11401c77200 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/CustomHeader.tsx @@ -0,0 +1,187 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable theme-colors/no-literal-colors */ +/* + * 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 { useRef, useState } from 'react'; +import { t } from '@superset-ui/core'; +import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons'; +import FilterIcon from './Filter'; +import KebabMenu from './KebabMenu'; +import { + CustomColDef, + CustomHeaderParams, + SortState, + UserProvidedColDef, +} from '../../types'; +import CustomPopover from './CustomPopover'; +import { + Container, + FilterIconWrapper, + HeaderContainer, + HeaderLabel, + MenuContainer, + SortIconWrapper, +} from '../../styles'; + +const getSortIcon = (sortState: SortState[], colId: string | null) => { + if (!sortState?.length || !colId) return null; + const { colId: currentCol, sort } = sortState[0]; + if (currentCol === colId) { + return sort === 'asc' ? ( + + ) : sort === 'desc' ? ( + + ) : null; + } + return null; +}; + +const CustomHeader: React.FC = ({ + displayName, + enableSorting, + setSort, + context, + column, + api, +}) => { + const { initialSortState, onColumnHeaderClicked } = context; + const colId = column?.getColId(); + const colDef = column?.getColDef() as CustomColDef; + const userColDef = column.getUserProvidedColDef() as UserProvidedColDef; + const isPercentMetric = colDef?.context?.isPercentMetric; + + const [isFilterVisible, setFilterVisible] = useState(false); + const [isMenuVisible, setMenuVisible] = useState(false); + const filterRef = useRef(null); + const isFilterActive = column?.isFilterActive(); + + const currentSort = initialSortState?.[0]; + const isMain = userColDef?.isMain; + const isTimeComparison = !isMain && userColDef?.timeComparisonKey; + const sortKey = isMain ? colId.replace('Main', '').trim() : colId; + + // Sorting logic + const clearSort = () => { + onColumnHeaderClicked({ column: { colId: sortKey, sort: null } }); + setSort(null, false); + }; + + const applySort = (direction: 'asc' | 'desc') => { + onColumnHeaderClicked({ column: { colId: sortKey, sort: direction } }); + setSort(direction, false); + }; + + const getNextSortDirection = (): 'asc' | 'desc' | null => { + if (currentSort?.colId !== colId) return 'asc'; + if (currentSort?.sort === 'asc') return 'desc'; + return null; + }; + + const toggleSort = () => { + if (!enableSorting || isTimeComparison) return; + + const next = getNextSortDirection(); + if (next) applySort(next); + else clearSort(); + }; + + const handleFilterClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + setFilterVisible(!isFilterVisible); + + const filterInstance = await api.getColumnFilterInstance(column); + const filterEl = filterInstance?.eGui; + if (filterEl && filterRef.current) { + filterRef.current.innerHTML = ''; + filterRef.current.appendChild(filterEl); + } + }; + + const handleMenuClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setMenuVisible(!isMenuVisible); + }; + + const isCurrentColSorted = currentSort?.colId === colId; + const currentDirection = isCurrentColSorted ? currentSort?.sort : null; + const shouldShowAsc = + !isTimeComparison && (!currentDirection || currentDirection === 'desc'); + const shouldShowDesc = + !isTimeComparison && (!currentDirection || currentDirection === 'asc'); + + const menuContent = ( + + {shouldShowAsc && ( +
applySort('asc')} className="menu-item"> + {t('Sort Ascending')} +
+ )} + {shouldShowDesc && ( +
applySort('desc')} className="menu-item"> + {t('Sort Descending')} +
+ )} + {currentSort && currentSort?.colId === colId && ( +
+ {t('Clear Sort')} +
+ )} +
+ ); + + return ( + + + {displayName} + + {getSortIcon(initialSortState, colId)} + + + + } + isOpen={isFilterVisible} + onClose={() => setFilterVisible(false)} + > + + + + + + {!isPercentMetric && !isTimeComparison && ( + setMenuVisible(false)} + > +
+ +
+
+ )} +
+ ); +}; + +export default CustomHeader; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/CustomPopover.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/CustomPopover.tsx new file mode 100644 index 00000000000..0fe0bc7474f --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/CustomPopover.tsx @@ -0,0 +1,104 @@ +/** + * 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 { useEffect, useRef, useState, cloneElement } from 'react'; +import { PopoverContainer, PopoverWrapper } from '../../styles'; + +interface Props { + content: React.ReactNode; + children: React.ReactElement; + isOpen: boolean; + onClose: () => void; +} + +const CustomPopover: React.FC = ({ + content, + children, + isOpen, + onClose, +}) => { + const [position, setPosition] = useState({ top: 0, left: 0 }); + const triggerRef = useRef(null); + const popoverRef = useRef(null); + + useEffect(() => { + const updatePosition = () => { + const rect = triggerRef.current?.getBoundingClientRect(); + if (rect) { + const popoverWidth = popoverRef.current?.offsetWidth || 200; + const windowWidth = window.innerWidth; + const rightEdgePosition = rect.left + 10 + 160 + popoverWidth; + + // Check if popover would spill out of the window + const shouldUseOffset = rightEdgePosition <= windowWidth; + + setPosition({ + top: rect.bottom + 8, + left: Math.max( + 0, + rect.right - + (popoverRef.current?.offsetWidth || 0) + + (shouldUseOffset ? 170 : 0), + ), + }); + } + }; + + if (isOpen) { + updatePosition(); + document.addEventListener('mousedown', handleClickOutside); + window.addEventListener('scroll', updatePosition); + window.addEventListener('resize', updatePosition); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + window.removeEventListener('scroll', updatePosition); + window.removeEventListener('resize', updatePosition); + }; + }, [isOpen]); + + const handleClickOutside = (event: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(event.target as Node) && + !triggerRef.current?.contains(event.target as Node) + ) { + onClose(); + } + }; + + return ( + + {cloneElement(children, { ref: triggerRef })} + {isOpen && ( + + {content} + + )} + + ); +}; + +export default CustomPopover; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/Filter.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/Filter.tsx new file mode 100644 index 00000000000..63a70c6b3e5 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/Filter.tsx @@ -0,0 +1,27 @@ +/** + * 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. + */ +const FilterIcon = () => ( + + + + + +); + +export default FilterIcon; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/KebabMenu.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/KebabMenu.tsx new file mode 100644 index 00000000000..7fec32415bd --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/KebabMenu.tsx @@ -0,0 +1,33 @@ +/** + * 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. + */ +const KebabMenu = ({ size = 14 }) => ( + + + + + +); + +export default KebabMenu; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/Pagination.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/Pagination.tsx new file mode 100644 index 00000000000..1a81b7f264c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/Pagination.tsx @@ -0,0 +1,139 @@ +/** + * 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. + */ +/* eslint-disable theme-colors/no-literal-colors */ +import { t } from '@superset-ui/core'; +import { + VerticalLeftOutlined, + VerticalRightOutlined, + LeftOutlined, + RightOutlined, +} from '@ant-design/icons'; +import { Select } from '@superset-ui/core/components'; +import { + PaginationContainer, + SelectWrapper, + PageInfo, + PageCount, + PageButton, + ButtonGroup, +} from '../../styles'; + +interface PaginationProps { + currentPage: number; + pageSize: number; + totalRows: number; + pageSizeOptions: number[]; + onServerPaginationChange: (pageNumber: number, pageSize: number) => void; + onServerPageSizeChange: (pageSize: number) => void; + sliceId: number; +} + +const Pagination: React.FC = ({ + currentPage = 0, + pageSize = 10, + totalRows = 0, + pageSizeOptions = [10, 20, 50, 100, 200], + onServerPaginationChange = () => {}, + onServerPageSizeChange = () => {}, + sliceId, +}) => { + const totalPages = Math.ceil(totalRows / pageSize); + const startRow = currentPage * pageSize + 1; + const endRow = Math.min((currentPage + 1) * pageSize, totalRows); + + const handleNextPage = (disabled: boolean) => () => { + if (disabled) return; + onServerPaginationChange(currentPage + 1, pageSize); + }; + + const handlePrevPage = (disabled: boolean) => () => { + if (disabled) return; + onServerPaginationChange(currentPage - 1, pageSize); + }; + + const handleNavigateToFirstPage = (disabled: boolean) => () => { + if (disabled) return; + onServerPaginationChange(0, pageSize); + }; + + const handleNavigateToLastPage = (disabled: boolean) => () => { + if (disabled) return; + onServerPaginationChange(totalPages - 1, pageSize); + }; + + const selectOptions = pageSizeOptions.map(size => ({ + value: size, + label: size, + })); + + return ( + + {t('Page Size:')} + + + + + + )} + + + params.api.sizeColumnsToFit()} + rowSelection="multiple" + animateRows + onCellClicked={handleCrossFilter} + initialState={gridInitialState} + suppressAggFuncInHeader + rowGroupPanelShow="always" + enableCellTextSelection + quickFilterText={serverPagination ? '' : quickFilterText} + suppressMovableColumns={!allowRearrangeColumns} + pagination={pagination} + paginationPageSize={pageSize} + paginationPageSizeSelector={PAGE_SIZE_OPTIONS} + suppressDragLeaveHidesColumns + pinnedBottomRowData={showTotals ? [cleanedTotals] : undefined} + context={{ + onColumnHeaderClicked: handleColumnHeaderClick, + initialSortState: getInitialSortState( + serverPaginationData?.sortBy || [], + ), + isActiveFilterValue, + }} + /> + {serverPagination && ( + + )} + + ); + }, +); + +AgGridDataTable.displayName = 'AgGridDataTable'; + +export default memo(AgGridDataTable); diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx new file mode 100644 index 00000000000..58273070d7c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx @@ -0,0 +1,292 @@ +/** + * 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 { + DataRecord, + DataRecordValue, + GenericDataType, + getTimeFormatterForGranularity, + t, +} from '@superset-ui/core'; +import { useCallback, useEffect, useState, useMemo } from 'react'; +import { isEqual } from 'lodash'; + +import { CellClickedEvent, IMenuActionParams } from 'ag-grid-community'; +import { + AgGridTableChartTransformedProps, + InputColumn, + SearchOption, + SortByItem, +} from './types'; +import AgGridDataTable from './AgGridTable'; +import { updateTableOwnState } from './utils/externalAPIs'; +import TimeComparisonVisibility from './AgGridTable/components/TimeComparisonVisibility'; +import { useColDefs } from './utils/useColDefs'; +import { getCrossFilterDataMask } from './utils/getCrossFilterDataMask'; +import { StyledChartContainer } from './styles'; + +const getGridHeight = (height: number, includeSearch: boolean | undefined) => { + let calculatedGridHeight = height; + if (includeSearch) { + calculatedGridHeight -= 16; + } + return calculatedGridHeight - 80; +}; + +export default function TableChart( + props: AgGridTableChartTransformedProps & {}, +) { + const { + height, + columns, + data, + includeSearch, + allowRearrangeColumns, + pageSize, + serverPagination, + rowCount, + setDataMask, + serverPaginationData, + slice_id, + percentMetrics, + hasServerPageLengthChanged, + serverPageLength, + emitCrossFilters, + filters, + timeGrain, + isRawRecords, + alignPositiveNegative, + showCellBars, + isUsingTimeComparison, + colorPositiveNegative, + totals, + showTotals, + columnColorFormatters, + basicColorFormatters, + width, + } = props; + + const [searchOptions, setSearchOptions] = useState([]); + + useEffect(() => { + const options = columns + .filter(col => col?.dataType === GenericDataType.String) + .map(column => ({ + value: column.key, + label: column.label, + })); + + if (!isEqual(options, searchOptions)) { + setSearchOptions(options || []); + } + }, [columns]); + + const comparisonColumns = [ + { key: 'all', label: t('Display all') }, + { key: '#', label: '#' }, + { key: '△', label: '△' }, + { key: '%', label: '%' }, + ]; + + const [selectedComparisonColumns, setSelectedComparisonColumns] = useState([ + comparisonColumns?.[0]?.key, + ]); + + const filteredColumns = useMemo(() => { + if (!isUsingTimeComparison) { + return columns; + } + if ( + selectedComparisonColumns.length === 0 || + selectedComparisonColumns.includes('all') + ) { + return columns?.filter(col => col?.config?.visible !== false); + } + + return columns + .filter( + col => + !col.originalLabel || + (col?.label || '').includes('Main') || + selectedComparisonColumns.includes(col.label), + ) + .filter(col => col?.config?.visible !== false); + }, [columns, selectedComparisonColumns]); + + const colDefs = useColDefs({ + columns: isUsingTimeComparison + ? (filteredColumns as InputColumn[]) + : (columns as InputColumn[]), + data, + serverPagination, + isRawRecords, + defaultAlignPN: alignPositiveNegative, + showCellBars, + colorPositiveNegative, + totals, + columnColorFormatters, + allowRearrangeColumns, + basicColorFormatters, + isUsingTimeComparison, + emitCrossFilters, + alignPositiveNegative, + slice_id, + }); + + const gridHeight = getGridHeight(height, includeSearch); + + const isActiveFilterValue = useCallback( + function isActiveFilterValue(key: string, val: DataRecordValue) { + return !!filters && filters[key]?.includes(val); + }, + [filters], + ); + + const timestampFormatter = useCallback( + value => getTimeFormatterForGranularity(timeGrain)(value), + [timeGrain], + ); + + const toggleFilter = useCallback( + (event: CellClickedEvent | IMenuActionParams) => { + if ( + emitCrossFilters && + event.column && + !( + event.column.getColDef().context?.isMetric || + event.column.getColDef().context?.isPercentMetric + ) + ) { + const crossFilterProps = { + key: event.column.getColId(), + value: event.value, + filters, + timeGrain, + isActiveFilterValue, + timestampFormatter, + }; + setDataMask(getCrossFilterDataMask(crossFilterProps).dataMask); + } + }, + [emitCrossFilters, setDataMask, filters, timeGrain], + ); + + const handleServerPaginationChange = useCallback( + (pageNumber: number, pageSize: number) => { + const modifiedOwnState = { + ...serverPaginationData, + currentPage: pageNumber, + pageSize, + }; + updateTableOwnState(setDataMask, modifiedOwnState); + }, + [setDataMask], + ); + + const handlePageSizeChange = useCallback( + (pageSize: number) => { + const modifiedOwnState = { + ...serverPaginationData, + currentPage: 0, + pageSize, + }; + updateTableOwnState(setDataMask, modifiedOwnState); + }, + [setDataMask], + ); + + const handleChangeSearchCol = (searchCol: string) => { + if (!isEqual(searchCol, serverPaginationData?.searchColumn)) { + const modifiedOwnState = { + ...(serverPaginationData || {}), + searchColumn: searchCol, + searchText: '', + }; + updateTableOwnState(setDataMask, modifiedOwnState); + } + }; + + const handleSearch = useCallback( + (searchText: string) => { + const modifiedOwnState = { + ...(serverPaginationData || {}), + searchColumn: + serverPaginationData?.searchColumn || searchOptions[0]?.value, + searchText, + currentPage: 0, // Reset to first page when searching + }; + updateTableOwnState(setDataMask, modifiedOwnState); + }, + [setDataMask, searchOptions], + ); + + const handleSortByChange = useCallback( + (sortBy: SortByItem[]) => { + if (!serverPagination) return; + const modifiedOwnState = { + ...serverPaginationData, + sortBy, + }; + updateTableOwnState(setDataMask, modifiedOwnState); + }, + [setDataMask, serverPagination], + ); + + const renderTimeComparisonVisibility = (): JSX.Element => ( + + ); + + return ( + + null + } + cleanedTotals={totals || {}} + showTotals={showTotals} + width={width} + /> + + ); +} diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts new file mode 100644 index 00000000000..64b091e87fd --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts @@ -0,0 +1,340 @@ +/** + * 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 { + AdhocColumn, + buildQueryContext, + ensureIsArray, + getMetricLabel, + isPhysicalColumn, + QueryFormOrderBy, + QueryMode, + QueryObject, + removeDuplicates, + PostProcessingRule, + BuildQuery, +} from '@superset-ui/core'; +import { + isTimeComparison, + timeCompareOperator, +} from '@superset-ui/chart-controls'; +import { isEmpty } from 'lodash'; +import { TableChartFormData } from './types'; +import { updateTableOwnState } from './utils/externalAPIs'; + +/** + * Infer query mode from form data. If `all_columns` is set, then raw records mode, + * otherwise defaults to aggregation mode. + * + * The same logic is used in `controlPanel` with control values as well. + */ +export function getQueryMode(formData: TableChartFormData) { + const { query_mode: mode } = formData; + if (mode === QueryMode.Aggregate || mode === QueryMode.Raw) { + return mode; + } + const rawColumns = formData?.all_columns; + const hasRawColumns = rawColumns && rawColumns.length > 0; + return hasRawColumns ? QueryMode.Raw : QueryMode.Aggregate; +} + +const buildQuery: BuildQuery = ( + formData: TableChartFormData, + options, +) => { + const { + percent_metrics: percentMetrics, + order_desc: orderDesc = false, + extra_form_data, + } = formData; + const queryMode = getQueryMode(formData); + const sortByMetric = ensureIsArray(formData.timeseries_limit_metric)[0]; + const time_grain_sqla = + extra_form_data?.time_grain_sqla || formData.time_grain_sqla; + let formDataCopy = formData; + // never include time in raw records mode + if (queryMode === QueryMode.Raw) { + formDataCopy = { + ...formData, + include_time: false, + }; + } + + const addComparisonPercentMetrics = (metrics: string[], suffixes: string[]) => + metrics.reduce((acc, metric) => { + const newMetrics = suffixes.map(suffix => `${metric}__${suffix}`); + return acc.concat([metric, ...newMetrics]); + }, []); + + return buildQueryContext(formDataCopy, baseQueryObject => { + let { metrics, orderby = [], columns = [] } = baseQueryObject; + const { extras = {} } = baseQueryObject; + let postProcessing: PostProcessingRule[] = []; + const nonCustomNorInheritShifts = ensureIsArray( + formData.time_compare, + ).filter((shift: string) => shift !== 'custom' && shift !== 'inherit'); + const customOrInheritShifts = ensureIsArray(formData.time_compare).filter( + (shift: string) => shift === 'custom' || shift === 'inherit', + ); + + let timeOffsets: string[] = []; + + // Shifts for non-custom or non inherit time comparison + if ( + isTimeComparison(formData, baseQueryObject) && + !isEmpty(nonCustomNorInheritShifts) + ) { + timeOffsets = nonCustomNorInheritShifts; + } + + // Shifts for custom or inherit time comparison + if ( + isTimeComparison(formData, baseQueryObject) && + !isEmpty(customOrInheritShifts) + ) { + if (customOrInheritShifts.includes('custom')) { + timeOffsets = timeOffsets.concat([formData.start_date_offset]); + } + if (customOrInheritShifts.includes('inherit')) { + timeOffsets = timeOffsets.concat(['inherit']); + } + } + + let temporalColumnAdded = false; + let temporalColumn = null; + + if (queryMode === QueryMode.Aggregate) { + metrics = metrics || []; + // override orderby with timeseries metric when in aggregation mode + if (sortByMetric) { + orderby = [[sortByMetric, !orderDesc]]; + } else if (metrics?.length > 0) { + // default to ordering by first metric in descending order + // when no "sort by" metric is set (regardless if "SORT DESC" is set to true) + orderby = [[metrics[0], false]]; + } + // add postprocessing for percent metrics only when in aggregation mode + if (percentMetrics && percentMetrics.length > 0) { + const percentMetricsLabelsWithTimeComparison = isTimeComparison( + formData, + baseQueryObject, + ) + ? addComparisonPercentMetrics( + percentMetrics.map(getMetricLabel), + timeOffsets, + ) + : percentMetrics.map(getMetricLabel); + const percentMetricLabels = removeDuplicates( + percentMetricsLabelsWithTimeComparison, + ); + metrics = removeDuplicates( + metrics.concat(percentMetrics), + getMetricLabel, + ); + postProcessing = [ + { + operation: 'contribution', + options: { + columns: percentMetricLabels, + rename_columns: percentMetricLabels.map(x => `%${x}`), + }, + }, + ]; + } + // Add the operator for the time comparison if some is selected + if (!isEmpty(timeOffsets)) { + postProcessing.push(timeCompareOperator(formData, baseQueryObject)); + } + + const temporalColumnsLookup = formData?.temporal_columns_lookup; + // Filter out the column if needed and prepare the temporal column object + + columns = columns.filter(col => { + const shouldBeAdded = + isPhysicalColumn(col) && + time_grain_sqla && + temporalColumnsLookup?.[col]; + + if (shouldBeAdded && !temporalColumnAdded) { + temporalColumn = { + timeGrain: time_grain_sqla, + columnType: 'BASE_AXIS', + sqlExpression: col, + label: col, + expressionType: 'SQL', + } as AdhocColumn; + temporalColumnAdded = true; + return false; // Do not include this in the output; it's added separately + } + return true; + }); + + // So we ensure the temporal column is added first + if (temporalColumn) { + columns = [temporalColumn, ...columns]; + } + } + + const moreProps: Partial = {}; + const ownState = options?.ownState ?? {}; + // Build Query flag to check if its for either download as csv, excel or json + const isDownloadQuery = + ['csv', 'xlsx'].includes(formData?.result_format || '') || + (formData?.result_format === 'json' && + formData?.result_type === 'results'); + + if (isDownloadQuery) { + moreProps.row_limit = Number(formDataCopy.row_limit) || 0; + moreProps.row_offset = 0; + } + + if (!isDownloadQuery && formDataCopy.server_pagination) { + const pageSize = ownState.pageSize ?? formDataCopy.server_page_length; + const currentPage = ownState.currentPage ?? 0; + + moreProps.row_limit = pageSize; + moreProps.row_offset = currentPage * pageSize; + } + + // getting sort by in case of server pagination from own state + let sortByFromOwnState: QueryFormOrderBy[] | undefined; + if (Array.isArray(ownState?.sortBy) && ownState?.sortBy.length > 0) { + const sortByItem = ownState?.sortBy[0]; + sortByFromOwnState = [[sortByItem?.key, !sortByItem?.desc]]; + } + + let queryObject = { + ...baseQueryObject, + columns, + extras, + orderby: + formData.server_pagination && sortByFromOwnState + ? sortByFromOwnState + : orderby, + metrics, + post_processing: postProcessing, + time_offsets: timeOffsets, + ...moreProps, + }; + + if ( + formData.server_pagination && + options?.extras?.cachedChanges?.[formData.slice_id] && + JSON.stringify(options?.extras?.cachedChanges?.[formData.slice_id]) !== + JSON.stringify(queryObject.filters) + ) { + queryObject = { ...queryObject, row_offset: 0 }; + const modifiedOwnState = { + ...(options?.ownState || {}), + currentPage: 0, + pageSize: queryObject.row_limit ?? 0, + }; + updateTableOwnState(options?.hooks?.setDataMask, modifiedOwnState); + } + // Because we use same buildQuery for all table on the page we need split them by id + options?.hooks?.setCachedChanges({ + [formData.slice_id]: queryObject.filters, + }); + + const extraQueries: QueryObject[] = []; + if ( + metrics?.length && + formData.show_totals && + queryMode === QueryMode.Aggregate + ) { + extraQueries.push({ + ...queryObject, + columns: [], + row_limit: 0, + row_offset: 0, + post_processing: [], + order_desc: undefined, // we don't need orderby stuff here, + orderby: undefined, // because this query will be used for get total aggregation. + }); + } + + const interactiveGroupBy = formData.extra_form_data?.interactive_groupby; + if (interactiveGroupBy && queryObject.columns) { + queryObject.columns = [ + ...new Set([...queryObject.columns, ...interactiveGroupBy]), + ]; + } + + if (formData.server_pagination) { + // Add search filter if search text exists + if (ownState.searchText && ownState?.searchColumn) { + queryObject = { + ...queryObject, + filters: [ + ...(queryObject.filters || []), + { + col: ownState?.searchColumn, + op: 'ILIKE', + val: `${ownState.searchText}%`, + }, + ], + }; + } + } + + // Now since row limit control is always visible even + // in case of server pagination + // we must use row limit from form data + if (formData.server_pagination && !isDownloadQuery) { + return [ + { ...queryObject }, + { + ...queryObject, + time_offsets: [], + row_limit: Number(formData?.row_limit) ?? 0, + row_offset: 0, + post_processing: [], + is_rowcount: true, + }, + ...extraQueries, + ]; + } + + return [queryObject, ...extraQueries]; + }); +}; + +// Use this closure to cache changing of external filters, if we have server pagination we need reset page to 0, after +// external filter changed +export const cachedBuildQuery = (): BuildQuery => { + let cachedChanges: any = {}; + const setCachedChanges = (newChanges: any) => { + cachedChanges = { ...cachedChanges, ...newChanges }; + }; + + return (formData, options) => + buildQuery( + { ...formData }, + { + extras: { cachedChanges }, + ownState: options?.ownState ?? {}, + hooks: { + ...options?.hooks, + setDataMask: () => {}, + setCachedChanges, + }, + }, + ); +}; + +export default cachedBuildQuery(); diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/consts.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/consts.ts new file mode 100644 index 00000000000..d4db087c304 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/consts.ts @@ -0,0 +1,33 @@ +/** + * 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 { formatSelectOptions } from '@superset-ui/chart-controls'; +import { addLocaleData } from '@superset-ui/core'; +import i18n from './i18n'; + +addLocaleData(i18n); + +export const SERVER_PAGE_SIZE_OPTIONS = formatSelectOptions([ + 10, 20, 50, 100, 200, +]); + +export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100, 200]; + +export const CUSTOM_AGG_FUNCS = { + queryTotal: 'Metric total', +}; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/controlPanel.tsx new file mode 100644 index 00000000000..173e8f1828e --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/controlPanel.tsx @@ -0,0 +1,753 @@ +/* eslint-disable camelcase */ +/** + * 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 { + ColumnMeta, + ColumnOption, + ControlConfig, + ControlPanelConfig, + ControlPanelsContainerProps, + ControlPanelState, + ControlState, + ControlStateMapping, + D3_TIME_FORMAT_OPTIONS, + Dataset, + DEFAULT_MAX_ROW, + DEFAULT_MAX_ROW_TABLE_SERVER, + defineSavedMetrics, + formatSelectOptions, + getStandardizedControls, + QueryModeLabel, + sections, + sharedControls, +} from '@superset-ui/chart-controls'; +import { + ensureIsArray, + FeatureFlag, + GenericDataType, + getMetricLabel, + isAdhocColumn, + isFeatureEnabled, + isPhysicalColumn, + legacyValidateInteger, + QueryFormColumn, + QueryFormMetric, + QueryMode, + SMART_DATE_ID, + t, + validateMaxValue, + validateServerPagination, +} from '@superset-ui/core'; + +import { isEmpty, last } from 'lodash'; +import { PAGE_SIZE_OPTIONS, SERVER_PAGE_SIZE_OPTIONS } from './consts'; +import { ColorSchemeEnum } from './types'; + +/** + * Generate comparison column names for a given column. + */ +const generateComparisonColumns = (colname: string) => [ + `${t('Main')} ${colname}`, + `# ${colname}`, + `△ ${colname}`, + `% ${colname}`, +]; + +/** + * Generate column types for the comparison columns. + */ +const generateComparisonColumnTypes = (count: number) => + Array(count).fill(GenericDataType.Numeric); + +function getQueryMode(controls: ControlStateMapping): QueryMode { + const mode = controls?.query_mode?.value; + if (mode === QueryMode.Aggregate || mode === QueryMode.Raw) { + return mode as QueryMode; + } + const rawColumns = controls?.all_columns?.value as + | QueryFormColumn[] + | undefined; + const hasRawColumns = rawColumns && rawColumns.length > 0; + return hasRawColumns ? QueryMode.Raw : QueryMode.Aggregate; +} + +const processComparisonColumns = (columns: any[], suffix: string) => + columns + .map(col => { + if (!col.label.includes(suffix)) { + return [ + { + label: `${t('Main')} ${col.label}`, + value: `${t('Main')} ${col.value}`, + }, + { + label: `# ${col.label}`, + value: `# ${col.value}`, + }, + { + label: `△ ${col.label}`, + value: `△ ${col.value}`, + }, + { + label: `% ${col.label}`, + value: `% ${col.value}`, + }, + ]; + } + return []; + }) + .flat(); + +/** + * Visibility check + */ +function isQueryMode(mode: QueryMode) { + return ({ controls }: Pick) => + getQueryMode(controls) === mode; +} + +const isAggMode = isQueryMode(QueryMode.Aggregate); +const isRawMode = isQueryMode(QueryMode.Raw); + +const validateAggControlValues = ( + controls: ControlStateMapping, + values: any[], +) => { + const areControlsEmpty = values.every(val => ensureIsArray(val).length === 0); + return areControlsEmpty && isAggMode({ controls }) + ? [t('Group By, Metrics or Percentage Metrics must have a value')] + : []; +}; + +const queryMode: ControlConfig<'RadioButtonControl'> = { + type: 'RadioButtonControl', + label: t('Query mode'), + default: null, + options: [ + [QueryMode.Aggregate, QueryModeLabel[QueryMode.Aggregate]], + [QueryMode.Raw, QueryModeLabel[QueryMode.Raw]], + ], + mapStateToProps: ({ controls }) => ({ value: getQueryMode(controls) }), + rerender: ['all_columns', 'groupby', 'metrics', 'percent_metrics'], +}; + +const allColumnsControl: typeof sharedControls.groupby = { + ...sharedControls.groupby, + label: t('Columns'), + description: t('Columns to display'), + multi: true, + freeForm: true, + allowAll: true, + commaChoosesOption: false, + optionRenderer: c => , + valueRenderer: c => , + valueKey: 'column_name', + mapStateToProps: ({ datasource, controls }, controlState) => ({ + options: datasource?.columns || [], + queryMode: getQueryMode(controls), + externalValidationErrors: + isRawMode({ controls }) && ensureIsArray(controlState?.value).length === 0 + ? [t('must have a value')] + : [], + }), + visibility: isRawMode, + resetOnHide: false, +}; + +const percentMetricsControl: typeof sharedControls.metrics = { + ...sharedControls.metrics, + label: t('Percentage metrics'), + description: t( + 'Select one or many metrics to display, that will be displayed in the percentages of total. ' + + 'Percentage metrics will be calculated only from data within the row limit. ' + + 'You can use an aggregation function on a column or write custom SQL to create a percentage metric.', + ), + visibility: isAggMode, + resetOnHide: false, + mapStateToProps: ({ datasource, controls }, controlState) => ({ + columns: datasource?.columns || [], + savedMetrics: defineSavedMetrics(datasource), + datasource, + datasourceType: datasource?.type, + queryMode: getQueryMode(controls), + externalValidationErrors: validateAggControlValues(controls, [ + controls.groupby?.value, + controls.metrics?.value, + controlState?.value, + ]), + }), + rerender: ['groupby', 'metrics'], + default: [], + validators: [], +}; + +/* +Options for row limit control +*/ + +export const ROW_LIMIT_OPTIONS_TABLE = [ + 10, 50, 100, 250, 500, 1000, 5000, 10000, 50000, 100000, 150000, 200000, + 250000, 300000, 350000, 400000, 450000, 500000, +]; + +const config: ControlPanelConfig = { + controlPanelSections: [ + { + label: t('Query'), + expanded: true, + controlSetRows: [ + [ + { + name: 'query_mode', + config: queryMode, + }, + ], + [ + { + name: 'groupby', + override: { + visibility: isAggMode, + resetOnHide: false, + mapStateToProps: ( + state: ControlPanelState, + controlState: ControlState, + ) => { + const { controls } = state; + const originalMapStateToProps = + sharedControls?.groupby?.mapStateToProps; + const newState = + originalMapStateToProps?.(state, controlState) ?? {}; + newState.externalValidationErrors = validateAggControlValues( + controls, + [controls.metrics?.value, controlState.value], + ); + + return newState; + }, + rerender: ['metrics', 'percent_metrics'], + }, + }, + ], + [ + { + name: 'time_grain_sqla', + config: { + ...sharedControls.time_grain_sqla, + visibility: ({ controls }) => { + const dttmLookup = Object.fromEntries( + ensureIsArray(controls?.groupby?.options).map(option => [ + option.column_name, + option.is_dttm, + ]), + ); + + return ensureIsArray(controls?.groupby.value) + .map(selection => { + if (isAdhocColumn(selection)) { + return true; + } + if (isPhysicalColumn(selection)) { + return !!dttmLookup[selection]; + } + return false; + }) + .some(Boolean); + }, + }, + }, + 'temporal_columns_lookup', + ], + [ + { + name: 'metrics', + override: { + validators: [], + visibility: isAggMode, + resetOnHide: false, + mapStateToProps: ( + { controls, datasource, form_data }: ControlPanelState, + controlState: ControlState, + ) => ({ + columns: datasource?.columns[0]?.hasOwnProperty('filterable') + ? (datasource as Dataset)?.columns?.filter( + (c: ColumnMeta) => c.filterable, + ) + : datasource?.columns, + savedMetrics: defineSavedMetrics(datasource), + // current active adhoc metrics + selectedMetrics: + form_data.metrics || + (form_data.metric ? [form_data.metric] : []), + datasource, + externalValidationErrors: validateAggControlValues(controls, [ + controls.groupby?.value, + controlState.value, + ]), + }), + rerender: ['groupby'], + }, + }, + { + name: 'all_columns', + config: allColumnsControl, + }, + ], + [ + { + name: 'percent_metrics', + config: percentMetricsControl, + }, + ], + ['adhoc_filters'], + [ + { + name: 'timeseries_limit_metric', + override: { + visibility: isAggMode, + resetOnHide: false, + }, + }, + { + name: 'order_by_cols', + config: { + type: 'SelectControl', + label: t('Ordering'), + description: t('Order results by selected columns'), + multi: true, + default: [], + mapStateToProps: ({ datasource }) => ({ + choices: datasource?.hasOwnProperty('order_by_choices') + ? (datasource as Dataset)?.order_by_choices + : datasource?.columns || [], + }), + visibility: isRawMode, + resetOnHide: false, + }, + }, + ], + [ + { + name: 'order_desc', + config: { + type: 'CheckboxControl', + label: t('Sort descending'), + default: true, + description: t( + 'If enabled, this control sorts the results/values descending, otherwise it sorts the results ascending.', + ), + visibility: ({ controls }: ControlPanelsContainerProps) => { + const hasSortMetric = Boolean( + controls?.timeseries_limit_metric?.value, + ); + return hasSortMetric && isAggMode({ controls }); + }, + resetOnHide: false, + }, + }, + ], + [ + { + name: 'server_pagination', + config: { + type: 'CheckboxControl', + label: t('Server pagination'), + description: t( + 'Enable server side pagination of results (experimental feature)', + ), + default: false, + }, + }, + ], + [ + { + name: 'server_page_length', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Server Page Length'), + default: 10, + choices: SERVER_PAGE_SIZE_OPTIONS, + description: t('Rows per page, 0 means no pagination'), + visibility: ({ controls }: ControlPanelsContainerProps) => + Boolean(controls?.server_pagination?.value), + }, + }, + ], + [ + { + name: 'row_limit', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Row limit'), + clearable: false, + mapStateToProps: state => ({ + maxValue: state?.common?.conf?.TABLE_VIZ_MAX_ROW_SERVER, + server_pagination: state?.form_data?.server_pagination, + maxValueWithoutServerPagination: + state?.common?.conf?.SQL_MAX_ROW, + }), + validators: [ + legacyValidateInteger, + (v, state) => + validateMaxValue( + v, + state?.maxValue || DEFAULT_MAX_ROW_TABLE_SERVER, + ), + (v, state) => + validateServerPagination( + v, + state?.server_pagination, + state?.maxValueWithoutServerPagination || DEFAULT_MAX_ROW, + state?.maxValue || DEFAULT_MAX_ROW_TABLE_SERVER, + ), + ], + // Re run the validations when this control value + validationDependancies: ['server_pagination'], + default: 10000, + choices: formatSelectOptions(ROW_LIMIT_OPTIONS_TABLE), + description: t( + 'Limits the number of the rows that are computed in the query that is the source of the data used for this chart.', + ), + }, + override: { + default: 1000, + }, + }, + ], + [ + { + name: 'show_totals', + config: { + type: 'CheckboxControl', + label: t('Show summary'), + default: false, + description: t( + 'Show total aggregations of selected metrics. Note that row limit does not apply to the result.', + ), + visibility: isAggMode, + resetOnHide: false, + }, + }, + ], + ], + }, + { + label: t('Options'), + expanded: true, + controlSetRows: [ + [ + { + name: 'table_timestamp_format', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Timestamp format'), + default: SMART_DATE_ID, + renderTrigger: true, + clearable: false, + choices: D3_TIME_FORMAT_OPTIONS, + description: t('D3 time format for datetime columns'), + }, + }, + ], + [ + { + name: 'page_length', + config: { + type: 'SelectControl', + freeForm: true, + renderTrigger: true, + label: t('Page length'), + default: null, + choices: PAGE_SIZE_OPTIONS, + description: t('Rows per page, 0 means no pagination'), + visibility: ({ controls }: ControlPanelsContainerProps) => + !controls?.server_pagination?.value, + }, + }, + null, + ], + [ + { + name: 'include_search', + config: { + type: 'CheckboxControl', + label: t('Search box'), + renderTrigger: true, + default: false, + description: t('Whether to include a client-side search box'), + }, + }, + ], + [ + { + name: 'column_config', + config: { + type: 'ColumnConfigControl', + label: t('Customize columns'), + description: t('Further customize how to display each column'), + width: 400, + height: 320, + renderTrigger: true, + shouldMapStateToProps() { + return true; + }, + mapStateToProps(explore, _, chart) { + const timeComparisonValue = + explore?.controls?.time_compare?.value; + const { colnames: _colnames, coltypes: _coltypes } = + chart?.queriesResponse?.[0] ?? {}; + let colnames: string[] = _colnames || []; + let coltypes: GenericDataType[] = _coltypes || []; + const childColumnMap: Record = {}; + const timeComparisonColumnMap: Record = {}; + + if (!isEmpty(timeComparisonValue)) { + /** + * Replace numeric columns with sets of comparison columns. + */ + const updatedColnames: string[] = []; + const updatedColtypes: GenericDataType[] = []; + + colnames + .filter( + colname => + last(colname.split('__')) !== timeComparisonValue, + ) + .forEach((colname, index) => { + if ( + explore.form_data.metrics?.some( + metric => getMetricLabel(metric) === colname, + ) || + explore.form_data.percent_metrics?.some( + (metric: QueryFormMetric) => + getMetricLabel(metric) === colname, + ) + ) { + const comparisonColumns = + generateComparisonColumns(colname); + comparisonColumns.forEach((name, idx) => { + updatedColnames.push(name); + updatedColtypes.push( + ...generateComparisonColumnTypes(4), + ); + timeComparisonColumnMap[name] = true; + if (idx === 0 && name.startsWith('Main ')) { + childColumnMap[name] = false; + } else { + childColumnMap[name] = true; + } + }); + } else { + updatedColnames.push(colname); + updatedColtypes.push(coltypes[index]); + childColumnMap[colname] = false; + timeComparisonColumnMap[colname] = false; + } + }); + + colnames = updatedColnames; + coltypes = updatedColtypes; + } + return { + columnsPropsObject: { + colnames, + coltypes, + childColumnMap, + timeComparisonColumnMap, + }, + }; + }, + }, + }, + ], + ], + }, + { + label: t('Visual formatting'), + expanded: true, + controlSetRows: [ + [ + { + name: 'show_cell_bars', + config: { + type: 'CheckboxControl', + label: t('Show Cell bars'), + renderTrigger: true, + default: true, + description: t( + 'Whether to display a bar chart background in table columns', + ), + }, + }, + ], + [ + { + name: 'align_pn', + config: { + type: 'CheckboxControl', + label: t('Align +/-'), + renderTrigger: true, + default: false, + description: t( + 'Whether to align background charts with both positive and negative values at 0', + ), + }, + }, + ], + [ + { + name: 'color_pn', + config: { + type: 'CheckboxControl', + label: t('add colors to cell bars for +/-'), + renderTrigger: true, + default: true, + description: t( + 'Whether to colorize numeric values by whether they are positive or negative', + ), + }, + }, + ], + [ + { + name: 'comparison_color_enabled', + config: { + type: 'CheckboxControl', + label: t('basic conditional formatting'), + renderTrigger: true, + visibility: ({ controls }) => + !isEmpty(controls?.time_compare?.value), + default: false, + description: t( + 'This will be applied to the whole table. Arrows (↑ and ↓) will be added to ' + + 'main columns for increase and decrease. Basic conditional formatting can be ' + + 'overwritten by conditional formatting below.', + ), + }, + }, + ], + [ + { + name: 'comparison_color_scheme', + config: { + type: 'SelectControl', + label: t('color type'), + default: ColorSchemeEnum.Green, + renderTrigger: true, + choices: [ + [ColorSchemeEnum.Green, 'Green for increase, red for decrease'], + [ColorSchemeEnum.Red, 'Red for increase, green for decrease'], + ], + visibility: ({ controls }) => + !isEmpty(controls?.time_compare?.value) && + Boolean(controls?.comparison_color_enabled?.value), + description: t( + 'Adds color to the chart symbols based on the positive or ' + + 'negative change from the comparison value.', + ), + }, + }, + ], + [ + { + name: 'conditional_formatting', + config: { + type: 'ConditionalFormattingControl', + renderTrigger: true, + label: t('Custom Conditional Formatting'), + extraColorChoices: [ + { + value: ColorSchemeEnum.Green, + label: t('Green for increase, red for decrease'), + }, + { + value: ColorSchemeEnum.Red, + label: t('Red for increase, green for decrease'), + }, + ], + description: t( + 'Apply conditional color formatting to numeric columns', + ), + shouldMapStateToProps() { + return true; + }, + mapStateToProps(explore, _, chart) { + const verboseMap = explore?.datasource?.hasOwnProperty( + 'verbose_map', + ) + ? (explore?.datasource as Dataset)?.verbose_map + : (explore?.datasource?.columns ?? {}); + const chartStatus = chart?.chartStatus; + const { colnames, coltypes } = + chart?.queriesResponse?.[0] ?? {}; + const numericColumns = + Array.isArray(colnames) && Array.isArray(coltypes) + ? colnames + .filter( + (colname: string, index: number) => + coltypes[index] === GenericDataType.Numeric, + ) + .map((colname: string) => ({ + value: colname, + label: Array.isArray(verboseMap) + ? colname + : (verboseMap[colname] ?? colname), + })) + : []; + const columnOptions = explore?.controls?.time_compare?.value + ? processComparisonColumns( + numericColumns || [], + ensureIsArray( + explore?.controls?.time_compare?.value, + )[0]?.toString() || '', + ) + : numericColumns; + + return { + removeIrrelevantConditions: chartStatus === 'success', + columnOptions, + verboseMap, + }; + }, + }, + }, + ], + ], + }, + { + ...sections.timeComparisonControls({ + multi: false, + showCalculationType: false, + showFullChoices: false, + }), + visibility: ({ controls }) => + isAggMode({ controls }) && + isFeatureEnabled(FeatureFlag.TableV2TimeComparisonEnabled), + }, + ], + formDataOverrides: formData => ({ + ...formData, + metrics: getStandardizedControls().popAllMetrics(), + groupby: getStandardizedControls().popAllColumns(), + }), +}; + +export default config; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/i18n.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/i18n.ts new file mode 100644 index 00000000000..3ea82b00e07 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/i18n.ts @@ -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 { Locale } from '@superset-ui/core'; + +const en = { + 'Query Mode': [''], + Aggregate: [''], + 'Raw Records': [''], + 'Emit Filter Events': [''], + 'Show Cell Bars': [''], + 'page_size.show': ['Show'], + 'page_size.all': ['All'], + 'page_size.entries': ['entries'], + 'table.previous_page': ['Previous'], + 'table.next_page': ['Next'], + 'search.num_records': ['%s record', '%s records...'], +}; + +const translations: Partial> = { + en, + fr: { + 'Query Mode': [''], + Aggregate: [''], + 'Raw Records': [''], + 'Emit Filter Events': [''], + 'Show Cell Bars': [''], + 'page_size.show': ['Afficher'], + 'page_size.all': ['tous'], + 'page_size.entries': ['entrées'], + 'table.previous_page': ['Précédent'], + 'table.next_page': ['Suivante'], + 'search.num_records': ['%s enregistrement', '%s enregistrements...'], + }, + zh: { + 'Query Mode': ['查询模式'], + Aggregate: ['分组聚合'], + 'Raw Records': ['原始数据'], + 'Emit Filter Events': ['关联看板过滤器'], + 'Show Cell Bars': ['为指标添加条状图背景'], + 'page_size.show': ['每页显示'], + 'page_size.all': ['全部'], + 'page_size.entries': ['条'], + 'table.previous_page': ['上一页'], + 'table.next_page': ['下一页'], + 'search.num_records': ['%s条记录...'], + }, +}; + +export default translations; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/Table.jpg b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/Table.jpg new file mode 100644 index 00000000000..431a63a9632 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/Table.jpg differ diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/Table2.jpg b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/Table2.jpg new file mode 100644 index 00000000000..0ec74d54a1b Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/Table2.jpg differ diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/Table3.jpg b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/Table3.jpg new file mode 100644 index 00000000000..532aeeeed7d Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/Table3.jpg differ diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/thumbnail.png b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/thumbnail.png new file mode 100644 index 00000000000..296702c954a Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/thumbnail.png differ diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/thumbnailLarge.png b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/thumbnailLarge.png new file mode 100644 index 00000000000..133d2804f84 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/images/thumbnailLarge.png differ diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/index.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/index.ts new file mode 100644 index 00000000000..a5650637d5b --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/index.ts @@ -0,0 +1,71 @@ +/** + * 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 { Behavior, ChartMetadata, ChartPlugin, t } from '@superset-ui/core'; +import transformProps from './transformProps'; +import thumbnail from './images/thumbnail.png'; +import example1 from './images/Table.jpg'; +import example2 from './images/Table2.jpg'; +import example3 from './images/Table3.jpg'; +import controlPanel from './controlPanel'; +import buildQuery from './buildQuery'; +import { TableChartFormData, TableChartProps } from './types'; + +// must export something for the module to be exist in dev mode +export { default as __hack__ } from './types'; +export * from './types'; + +const metadata = new ChartMetadata({ + behaviors: [ + Behavior.InteractiveChart, + Behavior.DrillToDetail, + Behavior.DrillBy, + ], + category: t('Table'), + canBeAnnotationTypes: ['EVENT', 'INTERVAL'], + description: t( + 'Classic row-by-column spreadsheet like view of a dataset. Use tables to showcase a view into the underlying data or to show aggregated metrics.', + ), + exampleGallery: [{ url: example1 }, { url: example2 }, { url: example3 }], + name: t('Table V2'), + tags: [ + t('Additive'), + t('Business'), + t('Pattern'), + t('Featured'), + t('Report'), + t('Sequential'), + t('Tabular'), + ], + thumbnail, +}); + +export default class AgGridTableChartPlugin extends ChartPlugin< + TableChartFormData, + TableChartProps +> { + constructor() { + super({ + loadChart: () => import('./AgGridTableChart'), + metadata, + transformProps, + controlPanel, + buildQuery, + }); + } +} diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/renderers/NumericCellRenderer.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/renderers/NumericCellRenderer.tsx new file mode 100644 index 00000000000..450a5fbd2e5 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/renderers/NumericCellRenderer.tsx @@ -0,0 +1,206 @@ +/** + * 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 { styled } from '@superset-ui/core'; +import { CustomCellRendererProps } from 'ag-grid-react'; +import { BasicColorFormatterType, InputColumn } from '../types'; +import { useIsDark } from '../utils/useTableTheme'; + +const StyledTotalCell = styled.div` + ${() => ` + font-weight: bold; + `} +`; + +const CellContainer = styled.div<{ backgroundColor?: string; align?: string }>` + display: flex; + background-color: ${({ backgroundColor }) => + backgroundColor || 'transparent'}; + justify-content: ${({ align }) => align || 'left'}; +`; + +const ArrowContainer = styled.div<{ arrowColor?: string }>` + margin-right: 10px; + color: ${({ arrowColor }) => arrowColor || 'inherit'}; +`; + +const Bar = styled.div<{ + offset: number; + percentage: number; + background: string; +}>` + position: absolute; + left: ${({ offset }) => `${offset}%`}; + top: 0; + height: 100%; + width: ${({ percentage }) => `${percentage}%`}; + background-color: ${({ background }) => background}; + z-index: 1; +`; + +type ValueRange = [number, number]; + +/** + * Cell background width calculation for horizontal bar chart + */ +function cellWidth({ + value, + valueRange, + alignPositiveNegative, +}: { + value: number; + valueRange: ValueRange; + alignPositiveNegative: boolean; +}) { + const [minValue, maxValue] = valueRange; + if (alignPositiveNegative) { + const perc = Math.abs(Math.round((value / maxValue) * 100)); + return perc; + } + const posExtent = Math.abs(Math.max(maxValue, 0)); + const negExtent = Math.abs(Math.min(minValue, 0)); + const tot = posExtent + negExtent; + const perc2 = Math.round((Math.abs(value) / tot) * 100); + return perc2; +} + +/** + * Cell left margin (offset) calculation for horizontal bar chart elements + * when alignPositiveNegative is not set + */ +function cellOffset({ + value, + valueRange, + alignPositiveNegative, +}: { + value: number; + valueRange: ValueRange; + alignPositiveNegative: boolean; +}) { + if (alignPositiveNegative) { + return 0; + } + const [minValue, maxValue] = valueRange; + const posExtent = Math.abs(Math.max(maxValue, 0)); + const negExtent = Math.abs(Math.min(minValue, 0)); + const tot = posExtent + negExtent; + return Math.round((Math.min(negExtent + value, negExtent) / tot) * 100); +} + +/** + * Cell background color calculation for horizontal bar chart + */ +function cellBackground({ + value, + colorPositiveNegative = false, + isDarkTheme = false, +}: { + value: number; + colorPositiveNegative: boolean; + isDarkTheme: boolean; +}) { + if (!colorPositiveNegative) { + return isDarkTheme ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'; // transparent or neutral + } + + const r = value < 0 ? 150 : 0; + const g = value >= 0 ? 150 : 0; + return `rgba(${r},${g},0,0.2)`; +} + +export const NumericCellRenderer = ( + params: CustomCellRendererProps & { + allowRenderHtml: boolean; + columns: InputColumn[]; + hasBasicColorFormatters: boolean | undefined; + col: InputColumn; + basicColorFormatters: { + [Key: string]: BasicColorFormatterType; + }[]; + valueRange: any; + alignPositiveNegative: boolean; + colorPositiveNegative: boolean; + }, +) => { + const { + value, + valueFormatted, + node, + hasBasicColorFormatters, + col, + basicColorFormatters, + valueRange, + alignPositiveNegative, + colorPositiveNegative, + } = params; + + const isDarkTheme = useIsDark(); + + if (node?.rowPinned === 'bottom') { + return {valueFormatted ?? value}; + } + + let arrow = ''; + let arrowColor = ''; + if (hasBasicColorFormatters && col?.metricName) { + arrow = + basicColorFormatters?.[node?.rowIndex as number]?.[col.metricName] + ?.mainArrow; + arrowColor = + basicColorFormatters?.[node?.rowIndex as number]?.[ + col.metricName + ]?.arrowColor?.toLowerCase(); + } + + const alignment = + col?.config?.horizontalAlign || (col?.isNumeric ? 'right' : 'left'); + + if (!valueRange) { + return ( + + {arrow && ( + {arrow} + )} +
{valueFormatted ?? value}
+
+ ); + } + + const CellWidth = cellWidth({ + value: value as number, + valueRange, + alignPositiveNegative, + }); + const CellOffset = cellOffset({ + value: value as number, + valueRange, + alignPositiveNegative, + }); + const background = cellBackground({ + value: value as number, + colorPositiveNegative, + isDarkTheme, + }); + + return ( +
+ + {valueFormatted ?? value} +
+ ); +}; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/renderers/TextCellRenderer.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/renderers/TextCellRenderer.tsx new file mode 100644 index 00000000000..d71d1ee1bc8 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/renderers/TextCellRenderer.tsx @@ -0,0 +1,71 @@ +/* eslint-disable camelcase */ +/** + * 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 { isProbablyHTML, sanitizeHtml, t } from '@superset-ui/core'; +import { InfoCircleOutlined } from '@ant-design/icons'; +import { Tooltip } from '@superset-ui/core/components'; +import { CellRendererProps } from '../types'; +import { SummaryContainer, SummaryText } from '../styles'; + +const SUMMARY_TOOLTIP_TEXT = t( + 'Show total aggregations of selected metrics. Note that row limit does not apply to the result.', +); + +export const TextCellRenderer = (params: CellRendererProps) => { + const { node, api, colDef, columns, allowRenderHtml, value, valueFormatted } = + params; + + if (node?.rowPinned === 'bottom') { + const cols = api.getAllGridColumns().filter(col => col.isVisible()); + const colAggCheck = !cols[0].getAggFunc(); + if (cols.length > 1 && colAggCheck && columns[0].key === colDef?.field) { + return ( + + {t('Summary')} + + + + + ); + } + if (!value) { + return null; + } + } + + if (!(typeof value === 'string' || value instanceof Date)) { + return valueFormatted ?? value; + } + + if (typeof value === 'string') { + if (value.startsWith('http://') || value.startsWith('https://')) { + return ( + + {value} + + ); + } + if (allowRenderHtml && isProbablyHTML(value)) { + return
; + } + } + + return
{valueFormatted ?? value}
; +}; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/styles/index.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/styles/index.tsx new file mode 100644 index 00000000000..a80ff873613 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/styles/index.tsx @@ -0,0 +1,405 @@ +/** + * 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 { css, styled } from '@superset-ui/core'; +import { Select } from '@superset-ui/core/components'; + +/* Components for AgGridTable */ +// Header Styles +export const Container = styled.div` + ${({ theme }) => ` + display: flex; + width: 100%; + + .three-dots-menu { + align-self: center; + margin-left: ${theme.sizeUnit}px; + cursor: pointer; + padding: ${theme.sizeUnit / 2}px; + border-radius: ${theme.borderRadius}px; + margin-top: ${theme.sizeUnit * 0.75}px; + } + `} +`; + +export const HeaderContainer = styled.div` + ${({ theme }) => ` + width: 100%; + display: flex; + align-items: center; + cursor: pointer; + padding: 0 ${theme.sizeUnit * 2}px; + overflow: hidden; + `} +`; + +export const HeaderLabel = styled.span` + ${({ theme }) => ` + font-weight: ${theme.fontWeightStrong}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; + max-width: 100%; + `} +`; + +export const SortIconWrapper = styled.div` + ${({ theme }) => ` + display: flex; + align-items: center; + margin-left: ${theme.sizeUnit * 2}px; + `} +`; + +export const FilterIconWrapper = styled.div<{ isFilterActive?: boolean }>` + align-self: flex-end; + margin-left: auto; + cursor: pointer; + + padding: 3px 4px; + overflow: hidden; + cursor: pointer; + border-radius: 4px; + + ${({ isFilterActive }) => + isFilterActive && + css` + background: linear-gradient( + var(--ag-icon-button-active-background-color), + var(--ag-icon-button-active-background-color) + ); + ::after { + background-color: var(--ag-accent-color); + border-radius: 50%; + content: ''; + height: 6px; + position: absolute; + right: 4px; + width: 6px; + } + `} + + svg { + ${({ isFilterActive }) => + isFilterActive && + css` + clip-path: path('M8,0C8,4.415 11.585,8 16,8L16,16L0,16L0,0L8,0Z'); + color: var(--ag-icon-button-active-color); + `} + + :hover { + ${({ isFilterActive }) => + !isFilterActive && + css` + background-color: var(--ag-icon-button-hover-background-color); + box-shadow: 0 0 0 var(--ag-icon-button-background-spread) + var(--ag-icon-button-hover-background-color); + color: var(--ag-icon-button-hover-color); + border-radius: var(--ag-icon-button-border-radius); + `} + } + } +`; + +export const MenuContainer = styled.div` + ${({ theme }) => ` + min-width: ${theme.sizeUnit * 45}px; + padding: ${theme.sizeUnit}px 0; + + .menu-item { + padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 4}px; + cursor: pointer; + display: flex; + align-items: center; + gap: ${theme.sizeUnit * 2}px; + + &:hover { + background-color: ${theme.colors.primary.light4}; + } + } + + .menu-divider { + height: 1px; + background-color: ${theme.colors.grayscale.light2}; + margin: ${theme.sizeUnit}px 0; + } + `} +`; + +export const PopoverWrapper = styled.div` + position: relative; + display: inline-block; +`; + +export const PopoverContainer = styled.div` + ${({ theme }) => + ` + position: fixed; + box-shadow: var(--ag-menu-shadow); + border-radius: ${theme.sizeUnit}px; + z-index: 99; + min-width: ${theme.sizeUnit * 50}px; + background: var(--ag-menu-background-color); + border: var(--ag-menu-border); + box-shadow: var(--ag-menu-shadow); + color: var(--ag-menu-text-color); + + `} +`; + +export const PaginationContainer = styled.div` + ${({ theme }) => ` + border: 1px solid ${theme.colors.grayscale.light2}; + display: flex; + align-items: center; + justify-content: flex-end; + padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 4}px; + border-top: 1px solid ${theme.colors.grayscale.light2}; + font-size: ${theme.fontSize}px; + color: ${theme.colorTextBase}; + transform: translateY(-${theme.sizeUnit}px); + background: ${theme.colorBgBase}; + `} +`; + +export const SelectWrapper = styled.div` + ${({ theme }) => ` + position: relative; + margin-left: ${theme.sizeUnit * 2}px; + display: inline-block; + min-width: ${theme.sizeUnit * 17}px; + overflow: hidden; + `} +`; + +export const PageInfo = styled.span` + ${({ theme }) => ` + margin: 0 ${theme.sizeUnit * 6}px; + span { + font-weight: ${theme.fontWeightStrong}; + } + `} +`; + +export const PageCount = styled.span` + ${({ theme }) => ` + span { + font-weight: ${theme.fontWeightStrong}; + } + `} +`; + +export const ButtonGroup = styled.div` + ${({ theme }) => ` + display: flex; + gap: ${theme.sizeUnit * 3}px; + `} +`; + +export const PageButton = styled.div<{ disabled?: boolean }>` + ${({ theme, disabled }) => ` + cursor: ${disabled ? 'not-allowed' : 'pointer'}; + display: flex; + align-items: center; + justify-content: center; + + svg { + height: ${theme.sizeUnit * 3}px; + width: ${theme.sizeUnit * 3}px; + fill: ${disabled ? theme.colors.grayscale.light1 : theme.colors.grayscale.dark2}; + } + `} +`; + +export const StyledSelect = styled(Select)` + ${({ theme }) => ` + width: ${theme.sizeUnit * 30}px; + margin-right: ${theme.sizeUnit * 2}px; + `} +`; + +// Time Comparison Visibility Styles +export const InfoText = styled.div` + max-width: 242px; + ${({ theme }) => ` + padding: 0 ${theme.sizeUnit * 2}px; + color: ${theme.colors.grayscale.base}; + font-size: ${theme.fontSizeSM}px; + `} +`; + +export const ColumnLabel = styled.span` + ${({ theme }) => ` + color: ${theme.colors.grayscale.dark2}; + `} +`; + +export const CheckIconWrapper = styled.span` + ${({ theme }) => ` + float: right; + font-size: ${theme.fontSizeSM}px; + `} +`; + +// Text Cell Renderer Styles +export const SummaryContainer = styled.div` + ${({ theme }) => ` + display: flex; + align-items: center; + gap: ${theme.sizeUnit}px; + `} +`; + +export const SummaryText = styled.div` + ${({ theme }) => ` + font-weight: ${theme.fontWeightStrong}; + `} +`; + +// Table Container Styles +export const StyledChartContainer = styled.div<{ + height: number; +}>` + ${({ theme, height }) => css` + height: ${height}px; + + --ag-background-color: ${theme.colorBgBase}; + --ag-foreground-color: ${theme.colorText}; + --ag-header-background-color: ${theme.colorBgBase}; + --ag-header-foreground-color: ${theme.colorText}; + + .dt-is-filter { + cursor: pointer; + :hover { + background-color: ${theme.colorPrimaryBgHover}; + } + } + + .dt-is-active-filter { + background: ${theme.colors.primary.light3}; + :hover { + background-color: ${theme.colorPrimaryBgHover}; + } + } + + .dt-truncate-cell { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .dt-truncate-cell:hover { + overflow: visible; + white-space: normal; + height: auto; + } + + .ag-container { + border-radius: 0px; + border: var(--ag-wrapper-border); + } + + .ag-input-wrapper { + ::before { + z-index: 100; + } + } + + .filter-popover { + z-index: 1 !important; + } + + .search-container { + display: flex; + justify-content: flex-end; + margin-bottom: ${theme.sizeUnit * 4}px; + } + + .dropdown-controls-container { + display: flex; + justify-content: flex-end; + } + + .time-comparison-dropdown { + display: flex; + padding-right: ${theme.sizeUnit * 4}px; + padding-top: ${theme.sizeUnit * 1.75}px; + height: fit-content; + } + + .ag-header, + .ag-row, + .ag-spanned-row { + font-size: ${theme.fontSizeSM}px; + font-weight: ${theme.fontWeightStrong}; + } + + .ag-root-wrapper { + border-radius: 0px; + } + .search-by-text-container { + display: flex; + align-items: center; + } + + .search-by-text { + margin-right: ${theme.sizeUnit * 2}px; + } + + .ant-popover-inner { + padding: 0px; + } + + .input-container { + margin-left: auto; + } + + .input-wrapper { + position: relative; + display: flex; + align-items: center; + overflow: visible; + } + + .input-wrapper svg { + pointer-events: none; + transform: translate(${theme.sizeUnit * 7}px, ${theme.sizeUnit / 2}px); + color: ${theme.colors.grayscale.base}; + } + + .input-wrapper input { + color: ${theme.colorText}; + font-size: ${theme.fontSizeSM}px; + padding: ${theme.sizeUnit * 1.5}px ${theme.sizeUnit * 3}px + ${theme.sizeUnit * 1.5}px ${theme.sizeUnit * 8}px; + line-height: 1.8; + border-radius: ${theme.borderRadius}px; + border: 1px solid ${theme.colors.grayscale.light2}; + background-color: transparent; + outline: none; + + &:focus { + border-color: ${theme.colors.primary.base}; + } + + &::placeholder { + color: ${theme.colors.grayscale.light1}; + } + } + `} +`; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts new file mode 100644 index 00000000000..a53e3eb2c69 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts @@ -0,0 +1,741 @@ +/** + * 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 memoizeOne from 'memoize-one'; +import { + ComparisonType, + Currency, + CurrencyFormatter, + DataRecord, + ensureIsArray, + extractTimegrain, + FeatureFlag, + GenericDataType, + getMetricLabel, + getNumberFormatter, + getTimeFormatter, + getTimeFormatterForGranularity, + isFeatureEnabled, + NumberFormats, + QueryMode, + SMART_DATE_ID, + t, + TimeFormats, + TimeFormatter, +} from '@superset-ui/core'; + +import { isEmpty, isEqual } from 'lodash'; +import { + ConditionalFormattingConfig, + getColorFormatters, +} from '@superset-ui/chart-controls'; +import isEqualColumns from './utils/isEqualColumns'; +import DateWithFormatter from './utils/DateWithFormatter'; +import { + DataColumnMeta, + TableChartProps, + AgGridTableChartTransformedProps, + TableColumnConfig, + ColorSchemeEnum, + BasicColorFormatterType, +} from './types'; + +const { PERCENT_3_POINT } = NumberFormats; +const { DATABASE_DATETIME } = TimeFormats; + +function isNumeric(key: string, data: DataRecord[] = []) { + return data.every( + x => x[key] === null || x[key] === undefined || typeof x[key] === 'number', + ); +} + +function isPositiveNumber(value: string | number | null | undefined) { + const num = Number(value); + return ( + value !== null && + value !== undefined && + value !== '' && + !Number.isNaN(num) && + num > 0 + ); +} + +const processComparisonTotals = ( + comparisonSuffix: string, + totals?: DataRecord[], +): DataRecord | undefined => { + if (!totals) { + return totals; + } + const transformedTotals: DataRecord = {}; + totals.map((totalRecord: DataRecord) => + Object.keys(totalRecord).forEach(key => { + if (totalRecord[key] !== undefined && !key.includes(comparisonSuffix)) { + transformedTotals[`Main ${key}`] = + parseInt(transformedTotals[`Main ${key}`]?.toString() || '0', 10) + + parseInt(totalRecord[key]?.toString() || '0', 10); + transformedTotals[`# ${key}`] = + parseInt(transformedTotals[`# ${key}`]?.toString() || '0', 10) + + parseInt( + totalRecord[`${key}__${comparisonSuffix}`]?.toString() || '0', + 10, + ); + const { valueDifference, percentDifferenceNum } = calculateDifferences( + transformedTotals[`Main ${key}`] as number, + transformedTotals[`# ${key}`] as number, + ); + transformedTotals[`△ ${key}`] = valueDifference; + transformedTotals[`% ${key}`] = percentDifferenceNum; + } + }), + ); + + return transformedTotals; +}; + +const getComparisonColConfig = ( + label: string, + parentColKey: string, + columnConfig: Record, +) => { + const comparisonKey = `${label} ${parentColKey}`; + const comparisonColConfig = columnConfig[comparisonKey] || {}; + return comparisonColConfig; +}; + +const getComparisonColFormatter = ( + label: string, + parentCol: DataColumnMeta, + columnConfig: Record, + savedFormat: string | undefined, + savedCurrency: Currency | undefined, +) => { + const currentColConfig = getComparisonColConfig( + label, + parentCol.key, + columnConfig, + ); + const hasCurrency = currentColConfig.currencyFormat?.symbol; + const currentColNumberFormat = + // fallback to parent's number format if not set + currentColConfig.d3NumberFormat || parentCol.config?.d3NumberFormat; + let { formatter } = parentCol; + if (label === '%') { + formatter = getNumberFormatter(currentColNumberFormat || PERCENT_3_POINT); + } else if (currentColNumberFormat || hasCurrency) { + const currency = currentColConfig.currencyFormat || savedCurrency; + const numberFormat = currentColNumberFormat || savedFormat; + formatter = currency + ? new CurrencyFormatter({ + d3Format: numberFormat, + currency, + }) + : getNumberFormatter(numberFormat); + } + return formatter; +}; + +const calculateDifferences = ( + originalValue: number, + comparisonValue: number, +) => { + const valueDifference = originalValue - comparisonValue; + let percentDifferenceNum; + if (!originalValue && !comparisonValue) { + percentDifferenceNum = 0; + } else if (!originalValue || !comparisonValue) { + percentDifferenceNum = originalValue ? 1 : -1; + } else { + percentDifferenceNum = + (originalValue - comparisonValue) / Math.abs(comparisonValue); + } + return { valueDifference, percentDifferenceNum }; +}; + +const processComparisonDataRecords = memoizeOne( + function processComparisonDataRecords( + originalData: DataRecord[] | undefined, + originalColumns: DataColumnMeta[], + comparisonSuffix: string, + ) { + // Transform data + return originalData?.map(originalItem => { + const transformedItem: DataRecord = {}; + originalColumns.forEach(origCol => { + if ( + (origCol.isMetric || origCol.isPercentMetric) && + !origCol.key.includes(comparisonSuffix) && + origCol.isNumeric + ) { + const originalValue = originalItem[origCol.key] || 0; + const comparisonValue = origCol.isMetric + ? originalItem?.[`${origCol.key}__${comparisonSuffix}`] || 0 + : originalItem[`%${origCol.key.slice(1)}__${comparisonSuffix}`] || + 0; + const { valueDifference, percentDifferenceNum } = + calculateDifferences( + originalValue as number, + comparisonValue as number, + ); + + transformedItem[`Main ${origCol.key}`] = originalValue; + transformedItem[`# ${origCol.key}`] = comparisonValue; + transformedItem[`△ ${origCol.key}`] = valueDifference; + transformedItem[`% ${origCol.key}`] = percentDifferenceNum; + } + }); + + Object.keys(originalItem).forEach(key => { + const isMetricOrPercentMetric = originalColumns.some( + col => col.key === key && (col.isMetric || col.isPercentMetric), + ); + if (!isMetricOrPercentMetric) { + transformedItem[key] = originalItem[key]; + } + }); + + return transformedItem; + }); + }, +); + +const processComparisonColumns = ( + columns: DataColumnMeta[], + props: TableChartProps, + comparisonSuffix: string, +) => + columns + .map(col => { + const { + datasource: { columnFormats, currencyFormats }, + rawFormData: { column_config: columnConfig = {} }, + } = props; + const savedFormat = columnFormats?.[col.key]; + const savedCurrency = currencyFormats?.[col.key]; + const originalLabel = col.label; + if ( + (col.isMetric || col.isPercentMetric) && + !col.key.includes(comparisonSuffix) && + col.isNumeric + ) { + return [ + { + ...col, + originalLabel, + metricName: col.key, + label: t('Main'), + key: `${t('Main')} ${col.key}`, + config: getComparisonColConfig(t('Main'), col.key, columnConfig), + formatter: getComparisonColFormatter( + t('Main'), + col, + columnConfig, + savedFormat, + savedCurrency, + ), + }, + { + ...col, + originalLabel, + metricName: col.key, + label: `#`, + key: `# ${col.key}`, + config: getComparisonColConfig(`#`, col.key, columnConfig), + formatter: getComparisonColFormatter( + `#`, + col, + columnConfig, + savedFormat, + savedCurrency, + ), + }, + { + ...col, + originalLabel, + metricName: col.key, + label: `△`, + key: `△ ${col.key}`, + config: getComparisonColConfig(`△`, col.key, columnConfig), + formatter: getComparisonColFormatter( + `△`, + col, + columnConfig, + savedFormat, + savedCurrency, + ), + }, + { + ...col, + originalLabel, + metricName: col.key, + label: `%`, + key: `% ${col.key}`, + config: getComparisonColConfig(`%`, col.key, columnConfig), + formatter: getComparisonColFormatter( + `%`, + col, + columnConfig, + savedFormat, + savedCurrency, + ), + }, + ]; + } + if ( + !col.isMetric && + !col.isPercentMetric && + !col.key.includes(comparisonSuffix) + ) { + return [col]; + } + return []; + }) + .flat(); + +const serverPageLengthMap = new Map(); + +const processDataRecords = memoizeOne(function processDataRecords( + data: DataRecord[] | undefined, + columns: DataColumnMeta[], +) { + if (!data?.[0]) { + return data || []; + } + const timeColumns = columns.filter( + column => column.dataType === GenericDataType.Temporal, + ); + + if (timeColumns.length > 0) { + return data.map(x => { + const datum = { ...x }; + timeColumns.forEach(({ key, formatter }) => { + // Convert datetime with a custom date class so we can use `String(...)` + // formatted value for global search, and `date.getTime()` for sorting. + datum[key] = new DateWithFormatter(x[key], { + formatter: formatter as TimeFormatter, + }); + }); + return datum; + }); + } + return data; +}); + +const processColumns = memoizeOne(function processColumns( + props: TableChartProps, +) { + const { + datasource: { columnFormats, currencyFormats, verboseMap }, + rawFormData: { + table_timestamp_format: tableTimestampFormat, + metrics: metrics_, + percent_metrics: percentMetrics_, + column_config: columnConfig = {}, + }, + queriesData, + } = props; + const granularity = extractTimegrain(props.rawFormData); + const { data: records, colnames, coltypes } = queriesData[0] || {}; + // convert `metrics` and `percentMetrics` to the key names in `data.records` + const metrics = (metrics_ ?? []).map(getMetricLabel); + const rawPercentMetrics = (percentMetrics_ ?? []).map(getMetricLabel); + // column names for percent metrics always starts with a '%' sign. + const percentMetrics = rawPercentMetrics.map((x: string) => `%${x}`); + const metricsSet = new Set(metrics); + const percentMetricsSet = new Set(percentMetrics); + const rawPercentMetricsSet = new Set(rawPercentMetrics); + + const columns: DataColumnMeta[] = (colnames || []) + .filter( + key => + // if a metric was only added to percent_metrics, they should not show up in the table. + !(rawPercentMetricsSet.has(key) && !metricsSet.has(key)), + ) + .map((key: string, i) => { + const dataType = coltypes[i]; + const config = columnConfig[key] || {}; + // for the purpose of presentation, only numeric values are treated as metrics + // because users can also add things like `MAX(str_col)` as a metric. + const isMetric = metricsSet.has(key) && isNumeric(key, records); + const isPercentMetric = percentMetricsSet.has(key); + const label = + isPercentMetric && verboseMap?.hasOwnProperty(key.replace('%', '')) + ? `%${verboseMap[key.replace('%', '')]}` + : verboseMap?.[key] || key; + const isTime = dataType === GenericDataType.Temporal; + const isNumber = dataType === GenericDataType.Numeric; + const savedFormat = columnFormats?.[key]; + const savedCurrency = currencyFormats?.[key]; + const numberFormat = config.d3NumberFormat || savedFormat; + const currency = config.currencyFormat?.symbol + ? config.currencyFormat + : savedCurrency; + + let formatter; + + if (isTime || config.d3TimeFormat) { + // string types may also apply d3-time format + // pick adhoc format first, fallback to column level formats defined in + // datasource + const customFormat = config.d3TimeFormat || savedFormat; + const timeFormat = customFormat || tableTimestampFormat; + // When format is "Adaptive Formatting" (smart_date) + if (timeFormat === SMART_DATE_ID) { + if (granularity) { + // time column use formats based on granularity + formatter = getTimeFormatterForGranularity(granularity); + } else if (customFormat) { + // other columns respect the column-specific format + formatter = getTimeFormatter(customFormat); + } else if (isNumeric(key, records)) { + // if column is numeric values, it is considered a timestamp64 + formatter = getTimeFormatter(DATABASE_DATETIME); + } else { + // if no column-specific format, print cell as is + formatter = String; + } + } else if (timeFormat) { + formatter = getTimeFormatter(timeFormat); + } + } else if (isPercentMetric) { + // percent metrics have a default format + formatter = getNumberFormatter(numberFormat || PERCENT_3_POINT); + } else if (isMetric || (isNumber && (numberFormat || currency))) { + formatter = currency + ? new CurrencyFormatter({ + d3Format: numberFormat, + currency, + }) + : getNumberFormatter(numberFormat); + } + return { + key, + label, + dataType, + isNumeric: dataType === GenericDataType.Numeric, + isMetric, + isPercentMetric, + formatter, + config, + }; + }); + return [metrics, percentMetrics, columns] as [ + typeof metrics, + typeof percentMetrics, + typeof columns, + ]; +}, isEqualColumns); + +/** + * Automatically set page size based on number of cells. + */ +const getPageSize = ( + pageSize: number | string | null | undefined, + numRecords: number, + numColumns: number, +) => { + if (typeof pageSize === 'number') { + // NaN is also has typeof === 'number' + return pageSize || 0; + } + if (typeof pageSize === 'string') { + return Number(pageSize) || 0; + } + // when pageSize not set, automatically add pagination if too many records + return numRecords * numColumns > 5000 ? 200 : 0; +}; + +const transformProps = ( + chartProps: TableChartProps, +): AgGridTableChartTransformedProps => { + const { + height, + width, + rawFormData: formData, + queriesData = [], + ownState: serverPaginationData, + filterState, + hooks: { setDataMask = () => {} }, + emitCrossFilters, + } = chartProps; + + const { + include_search: includeSearch = false, + page_length: pageLength, + order_desc: sortDesc = false, + slice_id, + time_compare, + comparison_type, + server_pagination: serverPagination = false, + server_page_length: serverPageLength = 10, + query_mode: queryMode, + align_pn: alignPositiveNegative = true, + show_cell_bars: showCellBars = true, + color_pn: colorPositiveNegative = true, + show_totals: showTotals, + conditional_formatting: conditionalFormatting, + comparison_color_enabled: comparisonColorEnabled = false, + comparison_color_scheme: comparisonColorScheme = ColorSchemeEnum.Green, + } = formData; + + const allowRearrangeColumns = true; + + const calculateBasicStyle = ( + percentDifferenceNum: number, + colorOption: ColorSchemeEnum, + ) => { + if (percentDifferenceNum === 0) { + return { + arrow: '', + arrowColor: '', + // eslint-disable-next-line theme-colors/no-literal-colors + backgroundColor: 'rgba(0,0,0,0.2)', + }; + } + const isPositive = percentDifferenceNum > 0; + const arrow = isPositive ? '↑' : '↓'; + const arrowColor = + colorOption === ColorSchemeEnum.Green + ? isPositive + ? ColorSchemeEnum.Green + : ColorSchemeEnum.Red + : isPositive + ? ColorSchemeEnum.Red + : ColorSchemeEnum.Green; + const backgroundColor = + colorOption === ColorSchemeEnum.Green + ? `rgba(${isPositive ? '0,150,0' : '150,0,0'},0.2)` + : `rgba(${isPositive ? '150,0,0' : '0,150,0'},0.2)`; + + return { arrow, arrowColor, backgroundColor }; + }; + + const getBasicColorFormatter = memoizeOne(function getBasicColorFormatter( + originalData: DataRecord[] | undefined, + originalColumns: DataColumnMeta[], + selectedColumns?: ConditionalFormattingConfig[], + ) { + // Transform data + const relevantColumns = selectedColumns + ? originalColumns.filter(col => + selectedColumns.some(scol => scol?.column?.includes(col.key)), + ) + : originalColumns; + + return originalData?.map(originalItem => { + const item: { [key: string]: BasicColorFormatterType } = {}; + relevantColumns.forEach(origCol => { + if ( + (origCol.isMetric || origCol.isPercentMetric) && + !origCol.key.includes(ensureIsArray(timeOffsets)[0]) && + origCol.isNumeric + ) { + const originalValue = originalItem[origCol.key] || 0; + const comparisonValue = origCol.isMetric + ? originalItem?.[ + `${origCol.key}__${ensureIsArray(timeOffsets)[0]}` + ] || 0 + : originalItem[ + `%${origCol.key.slice(1)}__${ensureIsArray(timeOffsets)[0]}` + ] || 0; + const { percentDifferenceNum } = calculateDifferences( + originalValue as number, + comparisonValue as number, + ); + + if (selectedColumns) { + selectedColumns.forEach(col => { + if (col?.column?.includes(origCol.key)) { + const { arrow, arrowColor, backgroundColor } = + calculateBasicStyle( + percentDifferenceNum, + col.colorScheme || comparisonColorScheme, + ); + item[col.column] = { + mainArrow: arrow, + arrowColor, + backgroundColor, + }; + } + }); + } else { + const { arrow, arrowColor, backgroundColor } = calculateBasicStyle( + percentDifferenceNum, + comparisonColorScheme, + ); + item[`${origCol.key}`] = { + mainArrow: arrow, + arrowColor, + backgroundColor, + }; + } + } + }); + return item; + }); + }); + + const getBasicColorFormatterForColumn = ( + originalData: DataRecord[] | undefined, + originalColumns: DataColumnMeta[], + conditionalFormatting?: ConditionalFormattingConfig[], + ) => { + const selectedColumns = conditionalFormatting?.filter( + (config: ConditionalFormattingConfig) => + config.column && + (config.colorScheme === ColorSchemeEnum.Green || + config.colorScheme === ColorSchemeEnum.Red), + ); + + return selectedColumns?.length + ? getBasicColorFormatter(originalData, originalColumns, selectedColumns) + : undefined; + }; + + const isUsingTimeComparison = + !isEmpty(time_compare) && + queryMode === QueryMode.Aggregate && + comparison_type === ComparisonType.Values && + isFeatureEnabled(FeatureFlag.TableV2TimeComparisonEnabled); + + let hasServerPageLengthChanged = false; + + const pageLengthFromMap = serverPageLengthMap.get(slice_id); + if (!isEqual(pageLengthFromMap, serverPageLength)) { + serverPageLengthMap.set(slice_id, serverPageLength); + hasServerPageLengthChanged = true; + } + + const [, percentMetrics, columns] = processColumns(chartProps); + + const timeGrain = extractTimegrain(formData); + + const nonCustomNorInheritShifts = ensureIsArray(formData.time_compare).filter( + (shift: string) => shift !== 'custom' && shift !== 'inherit', + ); + const customOrInheritShifts = ensureIsArray(formData.time_compare).filter( + (shift: string) => shift === 'custom' || shift === 'inherit', + ); + + let timeOffsets: string[] = []; + + if (isUsingTimeComparison && !isEmpty(nonCustomNorInheritShifts)) { + timeOffsets = nonCustomNorInheritShifts; + } + + // Shifts for custom or inherit time comparison + if (isUsingTimeComparison && !isEmpty(customOrInheritShifts)) { + if (customOrInheritShifts.includes('custom')) { + timeOffsets = timeOffsets.concat([formData.start_date_offset]); + } + if (customOrInheritShifts.includes('inherit')) { + timeOffsets = timeOffsets.concat(['inherit']); + } + } + const comparisonSuffix = isUsingTimeComparison + ? ensureIsArray(timeOffsets)[0] + : ''; + + let comparisonColumns: DataColumnMeta[] = []; + + if (isUsingTimeComparison) { + comparisonColumns = processComparisonColumns( + columns, + chartProps, + comparisonSuffix, + ); + } + + let baseQuery; + let countQuery; + let rowCount; + let totalQuery; + if (serverPagination) { + [baseQuery, countQuery, totalQuery] = queriesData; + rowCount = (countQuery?.data?.[0]?.rowcount as number) ?? 0; + } else { + [baseQuery, totalQuery] = queriesData; + rowCount = baseQuery?.rowcount ?? 0; + } + + const data = processDataRecords(baseQuery?.data, columns); + const comparisonData = processComparisonDataRecords( + baseQuery?.data, + columns, + comparisonSuffix, + ); + + const passedData = isUsingTimeComparison ? comparisonData || [] : data; + const passedColumns = isUsingTimeComparison ? comparisonColumns : columns; + + const basicColorFormatters = + comparisonColorEnabled && getBasicColorFormatter(baseQuery?.data, columns); + const columnColorFormatters = + getColorFormatters(conditionalFormatting, passedData) ?? []; + + const basicColorColumnFormatters = getBasicColorFormatterForColumn( + baseQuery?.data, + columns, + conditionalFormatting, + ); + + const hasPageLength = isPositiveNumber(pageLength); + + const totals = + showTotals && queryMode === QueryMode.Aggregate + ? isUsingTimeComparison + ? processComparisonTotals(comparisonSuffix, totalQuery?.data) + : totalQuery?.data[0] + : undefined; + + return { + height, + width, + data: passedData, + columns: passedColumns, + percentMetrics, + setDataMask, + sortDesc, + includeSearch, + pageSize: getPageSize(pageLength, data.length, columns.length), + filters: filterState.filters, + emitCrossFilters, + allowRearrangeColumns, + slice_id, + serverPagination, + rowCount, + serverPaginationData, + hasServerPageLengthChanged, + serverPageLength, + hasPageLength, + timeGrain, + isRawRecords: queryMode === QueryMode.Raw, + alignPositiveNegative, + showCellBars, + isUsingTimeComparison, + colorPositiveNegative, + totals, + showTotals, + columnColorFormatters, + basicColorColumnFormatters, + basicColorFormatters, + formData, + }; +}; + +export default transformProps; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts new file mode 100644 index 00000000000..249ee4150fe --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts @@ -0,0 +1,263 @@ +/** + * 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 { ColorFormatters } from '@superset-ui/chart-controls'; +import { + NumberFormatter, + TimeFormatter, + TimeGranularity, + QueryFormMetric, + ChartProps, + DataRecord, + DataRecordValue, + DataRecordFilters, + GenericDataType, + QueryMode, + ChartDataResponseResult, + QueryFormData, + SetDataMaskHook, + CurrencyFormatter, + Currency, + JsonObject, + Metric, +} from '@superset-ui/core'; +import { ColDef, Column, IHeaderParams } from 'ag-grid-community'; +import { CustomCellRendererProps } from 'ag-grid-react'; + +export type CustomFormatter = (value: DataRecordValue) => string; + +export type TableColumnConfig = { + d3NumberFormat?: string; + d3SmallNumberFormat?: string; + d3TimeFormat?: string; + columnWidth?: number; + horizontalAlign?: 'left' | 'right' | 'center'; + showCellBars?: boolean; + alignPositiveNegative?: boolean; + colorPositiveNegative?: boolean; + truncateLongCells?: boolean; + currencyFormat?: Currency; + visible?: boolean; + customColumnName?: string; + displayTypeIcon?: boolean; +}; + +export interface DataColumnMeta { + // `key` is what is called `label` in the input props + key: string; + // `label` is verbose column name used for rendering + label: string; + // `originalLabel` preserves the original label when time comparison transforms the labels + originalLabel?: string; + dataType: GenericDataType; + formatter?: + | TimeFormatter + | NumberFormatter + | CustomFormatter + | CurrencyFormatter; + isMetric?: boolean; + isPercentMetric?: boolean; + isNumeric?: boolean; + config?: TableColumnConfig; + isChildColumn?: boolean; +} + +export interface TableChartData { + records: DataRecord[]; + columns: string[]; +} + +export type TableChartFormData = QueryFormData & { + align_pn?: boolean; + color_pn?: boolean; + include_time?: boolean; + include_search?: boolean; + query_mode?: QueryMode; + page_length?: string | number | null; // null means auto-paginate + metrics?: QueryFormMetric[] | null; + percent_metrics?: QueryFormMetric[] | null; + timeseries_limit_metric?: QueryFormMetric[] | QueryFormMetric | null; + groupby?: QueryFormMetric[] | null; + all_columns?: QueryFormMetric[] | null; + order_desc?: boolean; + show_cell_bars?: boolean; + table_timestamp_format?: string; + time_grain_sqla?: TimeGranularity; + column_config?: Record; + allow_rearrange_columns?: boolean; +}; + +export interface TableChartProps extends ChartProps { + ownCurrentState?: { + pageSize?: number; + currentPage?: number; + }; + rawFormData: TableChartFormData; + queriesData: ChartDataResponseResult[]; +} + +export type BasicColorFormatterType = { + backgroundColor: string; + arrowColor: string; + mainArrow: string; +}; + +export type SortByItem = { + id: string; + key: string; + desc?: boolean; +}; + +export type SearchOption = { + value: string; + label: string; +}; + +export interface ServerPaginationData { + pageSize?: number; + currentPage?: number; + sortBy?: SortByItem[]; + searchText?: string; + searchColumn?: string; +} + +export interface AgGridTableChartTransformedProps< + D extends DataRecord = DataRecord, +> { + height: number; + width: number; + + setDataMask: SetDataMaskHook; + data: D[]; + columns: DataColumnMeta[]; + pageSize?: number; + sortDesc?: boolean; + includeSearch?: boolean; + filters?: DataRecordFilters; + emitCrossFilters?: boolean; + allowRearrangeColumns?: boolean; + allowRenderHtml?: boolean; + slice_id: number; + serverPagination: boolean; + rowCount: number; + serverPaginationData: JsonObject; + percentMetrics: string[]; + hasServerPageLengthChanged: boolean; + serverPageLength: number; + hasPageLength: boolean; + timeGrain: TimeGranularity | undefined; + isRawRecords: boolean; + alignPositiveNegative: boolean; + showCellBars: boolean; + isUsingTimeComparison: boolean; + colorPositiveNegative: boolean; + totals: DataRecord | undefined; + showTotals: boolean; + columnColorFormatters: ColorFormatters; + basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[]; + basicColorColumnFormatters?: { [Key: string]: BasicColorFormatterType }[]; + formData: TableChartFormData; +} + +export enum ColorSchemeEnum { + 'Green' = 'Green', + 'Red' = 'Red', +} + +export interface SortState { + colId: string; + sort: 'asc' | 'desc' | null; +} + +export interface CustomContext { + initialSortState: SortState[]; + onColumnHeaderClicked: (args: { column: SortState }) => void; +} + +export interface CustomHeaderParams extends IHeaderParams { + context: CustomContext; + column: Column; + slice_id: number; +} + +export interface UserProvidedColDef extends ColDef { + isMain?: boolean; + timeComparisonKey?: string; +} + +export interface CustomColDef extends ColDef { + context?: { + isMetric?: boolean; + isPercentMetric?: boolean; + isNumeric?: boolean; + }; +} + +export type TableDataColumnMeta = DataColumnMeta & { + config?: TableColumnConfig; +}; + +export interface InputColumn { + key: string; + label: string; + dataType: number; + isNumeric: boolean; + isMetric: boolean; + isPercentMetric: boolean; + config: Record; + formatter?: Function; + originalLabel?: string; + metricName?: string; +} + +export type CellRendererProps = CustomCellRendererProps & { + hasBasicColorFormatters: boolean | undefined; + col: InputColumn; + basicColorFormatters: { + [Key: string]: BasicColorFormatterType; + }[]; + valueRange: any; + alignPositiveNegative: boolean; + colorPositiveNegative: boolean; + allowRenderHtml: boolean; + columns: InputColumn[]; +}; + +export type Dataset = { + changed_by?: { + first_name: string; + last_name: string; + }; + created_by?: { + first_name: string; + last_name: string; + }; + changed_on_humanized: string; + created_on_humanized: string; + description: string; + table_name: string; + owners: { + first_name: string; + last_name: string; + }[]; + columns?: Column[]; + metrics?: Metric[]; + verbose_map?: Record; +}; + +export default {}; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/DateWithFormatter.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/DateWithFormatter.ts new file mode 100644 index 00000000000..c92c2ca1abb --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/DateWithFormatter.ts @@ -0,0 +1,55 @@ +/** + * 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 { + DataRecordValue, + normalizeTimestamp, + TimeFormatFunction, +} from '@superset-ui/core'; + +/** + * Extended Date object with a custom formatter, and retains the original input + * when the formatter is simple `String(..)`. + */ +export default class DateWithFormatter extends Date { + formatter: TimeFormatFunction; + + input: DataRecordValue; + + constructor( + input: DataRecordValue, + { formatter = String }: { formatter?: TimeFormatFunction } = {}, + ) { + let value = input; + // assuming timestamps without a timezone is in UTC time + if (typeof value === 'string') { + value = normalizeTimestamp(value); + } + + super(value as string); + + this.input = input; + this.formatter = formatter; + this.toString = (): string => { + if (this.formatter === String) { + return String(this.input); + } + return this.formatter ? this.formatter(this) : Date.toString.call(this); + }; + } +} diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/dateFilterComparator.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/dateFilterComparator.ts new file mode 100644 index 00000000000..46fe46de5e8 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/dateFilterComparator.ts @@ -0,0 +1,43 @@ +/** + * 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. + */ + +const dateFilterComparator = (filterDate: Date, cellValue: Date) => { + const cellDate = new Date(cellValue); + cellDate.setHours(0, 0, 0, 0); + if (Number.isNaN(cellDate?.getTime())) return -1; + + const cellDay = cellDate.getDate(); + const cellMonth = cellDate.getMonth(); + const cellYear = cellDate.getFullYear(); + + const filterDay = filterDate.getDate(); + const filterMonth = filterDate.getMonth(); + const filterYear = filterDate.getFullYear(); + + if (cellYear < filterYear) return -1; + if (cellYear > filterYear) return 1; + if (cellMonth < filterMonth) return -1; + if (cellMonth > filterMonth) return 1; + if (cellDay < filterDay) return -1; + if (cellDay > filterDay) return 1; + + return 0; +}; + +export default dateFilterComparator; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/extent.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/extent.ts new file mode 100644 index 00000000000..4b326800d2f --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/extent.ts @@ -0,0 +1,48 @@ +/** + * 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 { isNil } from 'lodash'; + +export default function extent( + values: T[], +) { + let min: T | undefined; + let max: T | undefined; + // eslint-disable-next-line no-restricted-syntax + for (const value of values) { + if (value !== null) { + if (isNil(min)) { + if (value !== undefined) { + min = value; + max = value; + } + } else if (value !== undefined) { + if (min > value) { + min = value; + } + if (!isNil(max)) { + if (max < value) { + max = value; + } + } + } + } + } + return [min, max]; +} diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/externalAPIs.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/externalAPIs.ts new file mode 100644 index 00000000000..78b50f007c6 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/externalAPIs.ts @@ -0,0 +1,38 @@ +/* eslint-disable camelcase */ +/** + * 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 { SetDataMaskHook } from '@superset-ui/core'; +import { SortByItem } from '../types'; + +interface TableOwnState { + currentPage?: number; + pageSize?: number; + sortColumn?: string; + sortOrder?: 'asc' | 'desc'; + searchText?: string; + sortBy?: SortByItem[]; +} + +export const updateTableOwnState = ( + setDataMask: SetDataMaskHook = () => {}, + modifiedOwnState: TableOwnState, +) => + setDataMask({ + ownState: modifiedOwnState, + }); diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/filterValueGetter.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/filterValueGetter.ts new file mode 100644 index 00000000000..a6e8a7c75fb --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/filterValueGetter.ts @@ -0,0 +1,34 @@ +/* eslint-disable camelcase */ +/** + * 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 { ValueGetterParams } from 'ag-grid-community'; + +const filterValueGetter = (params: ValueGetterParams) => { + const raw = params.data[params.colDef.field as string]; + const formatter = params.colDef.valueFormatter as Function; + if (!raw || !formatter) return null; + const formatted = formatter({ + value: raw, + }); + + const numeric = parseFloat(String(formatted).replace('%', '').trim()); + return Number.isNaN(numeric) ? null : numeric; +}; + +export default filterValueGetter; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/formatValue.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/formatValue.ts new file mode 100644 index 00000000000..4b85e3fa9f6 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/formatValue.ts @@ -0,0 +1,115 @@ +/** + * 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 { + CurrencyFormatter, + DataRecordValue, + GenericDataType, + getNumberFormatter, + isDefined, + isProbablyHTML, + sanitizeHtml, +} from '@superset-ui/core'; +import { ValueFormatterParams, ValueGetterParams } from 'ag-grid-community'; +import { DataColumnMeta, InputColumn } from '../types'; +import DateWithFormatter from './DateWithFormatter'; + +/** + * Format text for cell value. + */ +function formatValue( + formatter: DataColumnMeta['formatter'], + value: DataRecordValue, +): [boolean, string] { + // render undefined as empty string + if (value === undefined) { + return [false, '']; + } + // render null as `N/A` + if ( + value === null || + // null values in temporal columns are wrapped in a Date object, so make sure we + // handle them here too + (value instanceof DateWithFormatter && value.input === null) + ) { + return [false, 'N/A']; + } + if (formatter) { + return [false, formatter(value as number)]; + } + if (typeof value === 'string') { + return isProbablyHTML(value) ? [true, sanitizeHtml(value)] : [false, value]; + } + return [false, value.toString()]; +} + +export function formatColumnValue( + column: DataColumnMeta, + value: DataRecordValue, +) { + const { dataType, formatter, config = {} } = column; + const isNumber = dataType === GenericDataType.Numeric; + const smallNumberFormatter = + config.d3SmallNumberFormat === undefined + ? formatter + : config.currencyFormat + ? new CurrencyFormatter({ + d3Format: config.d3SmallNumberFormat, + currency: config.currencyFormat, + }) + : getNumberFormatter(config.d3SmallNumberFormat); + return formatValue( + isNumber && typeof value === 'number' && Math.abs(value) < 1 + ? smallNumberFormatter + : formatter, + value, + ); +} + +export const valueFormatter = ( + params: ValueFormatterParams, + col: InputColumn, +): string => { + const { value, node } = params; + if ( + isDefined(value) && + value !== '' && + !(value instanceof DateWithFormatter && value.input === null) + ) { + return col.formatter?.(value) || value; + } + if (node?.level === -1) { + return ''; + } + return 'N/A'; +}; + +export const valueGetter = (params: ValueGetterParams, col: InputColumn) => { + // @ts-ignore + if (params?.colDef?.isMain) { + const modifiedColId = `Main ${params.column.getColId()}`; + return params.data[modifiedColId]; + } + if (isDefined(params.data?.[params.column.getColId()])) { + return params.data[params.column.getColId()]; + } + if (col.isNumeric) { + return undefined; + } + return ''; +}; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getAggFunc.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getAggFunc.ts new file mode 100644 index 00000000000..e84a34f3102 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getAggFunc.ts @@ -0,0 +1,28 @@ +/* eslint-disable camelcase */ +/** + * 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 { CUSTOM_AGG_FUNCS } from '../consts'; +import { InputColumn } from '../types'; + +export const getAggFunc = (col: InputColumn) => + col.isMetric || col.isPercentMetric + ? CUSTOM_AGG_FUNCS.queryTotal + : col.isNumeric + ? 'sum' + : undefined; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCellClass.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCellClass.ts new file mode 100644 index 00000000000..889d8c1225c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCellClass.ts @@ -0,0 +1,46 @@ +/** + * 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 { CellClassParams } from 'ag-grid-community'; +import { InputColumn } from '../types'; + +type GetCellClassParams = CellClassParams & { + col: InputColumn; + emitCrossFilters: boolean | undefined; +}; + +const getCellClass = (params: GetCellClassParams) => { + const { col, emitCrossFilters } = params; + const isActiveFilterValue = params?.context?.isActiveFilterValue; + let className = ''; + if (emitCrossFilters) { + if (!col?.isMetric) { + className += ' dt-is-filter'; + } + if (isActiveFilterValue?.(col?.key, params?.value)) { + className += ' dt-is-active-filter'; + } + if (col?.config?.truncateLongCells) { + className += ' dt-truncate-cell'; + } + } + return className; +}; + +export default getCellClass; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCellStyle.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCellStyle.ts new file mode 100644 index 00000000000..035a3733f39 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCellStyle.ts @@ -0,0 +1,82 @@ +/** + * 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 { ColorFormatters } from '@superset-ui/chart-controls'; +import { CellClassParams } from 'ag-grid-community'; +import { BasicColorFormatterType, InputColumn } from '../types'; + +type CellStyleParams = CellClassParams & { + hasColumnColorFormatters: boolean | undefined; + columnColorFormatters: ColorFormatters; + hasBasicColorFormatters: boolean | undefined; + basicColorFormatters?: { + [Key: string]: BasicColorFormatterType; + }[]; + col: InputColumn; +}; + +const getCellStyle = (params: CellStyleParams) => { + const { + value, + colDef, + rowIndex, + hasBasicColorFormatters, + basicColorFormatters, + hasColumnColorFormatters, + columnColorFormatters, + col, + node, + } = params; + let backgroundColor; + if (hasColumnColorFormatters) { + columnColorFormatters! + .filter(formatter => { + const colTitle = formatter?.column?.includes('Main') + ? formatter?.column?.replace('Main', '').trim() + : formatter?.column; + return colTitle === colDef.field; + }) + .forEach(formatter => { + const formatterResult = + value || value === 0 ? formatter.getColorFromValue(value) : false; + if (formatterResult) { + backgroundColor = formatterResult; + } + }); + } + + if ( + hasBasicColorFormatters && + col?.metricName && + node?.rowPinned !== 'bottom' + ) { + backgroundColor = + basicColorFormatters?.[rowIndex]?.[col.metricName]?.backgroundColor; + } + + const textAlign = + col?.config?.horizontalAlign || (col?.isNumeric ? 'right' : 'left'); + + return { + backgroundColor: backgroundColor || '', + textAlign, + }; +}; + +export default getCellStyle; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCrossFilterDataMask.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCrossFilterDataMask.ts new file mode 100644 index 00000000000..a64817903bd --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCrossFilterDataMask.ts @@ -0,0 +1,103 @@ +/** + * 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 { + DataRecordFilters, + DataRecordValue, + DTTM_ALIAS, + ensureIsArray, + TimeGranularity, +} from '@superset-ui/core'; + +type GetCrossFilterDataMaskProps = { + key: string; + value: DataRecordValue; + filters?: DataRecordFilters; + timeGrain?: TimeGranularity; + isActiveFilterValue: (key: string, val: DataRecordValue) => boolean; + timestampFormatter: (value: DataRecordValue) => string; +}; + +export const getCrossFilterDataMask = ({ + key, + value, + filters, + timeGrain, + isActiveFilterValue, + timestampFormatter, +}: GetCrossFilterDataMaskProps) => { + let updatedFilters = { ...(filters || {}) }; + if (filters && isActiveFilterValue(key, value)) { + updatedFilters = {}; + } else { + updatedFilters = { + [key]: [value], + }; + } + if (Array.isArray(updatedFilters[key]) && updatedFilters[key].length === 0) { + delete updatedFilters[key]; + } + + const groupBy = Object.keys(updatedFilters); + const groupByValues = Object.values(updatedFilters); + const labelElements: string[] = []; + groupBy.forEach(col => { + const isTimestamp = col === DTTM_ALIAS; + const filterValues = ensureIsArray(updatedFilters?.[col]); + if (filterValues.length) { + const valueLabels = filterValues.map(value => + isTimestamp ? timestampFormatter(value) : value, + ); + labelElements.push(`${valueLabels.join(', ')}`); + } + }); + + return { + dataMask: { + extraFormData: { + filters: + groupBy.length === 0 + ? [] + : groupBy.map(col => { + const val = ensureIsArray(updatedFilters?.[col]); + if (!val.length) + return { + col, + op: 'IS NULL' as const, + }; + return { + col, + op: 'IN' as const, + val: val.map(el => (el instanceof Date ? el.getTime() : el!)), + grain: col === DTTM_ALIAS ? timeGrain : undefined, + }; + }), + }, + filterState: { + label: labelElements.join(', '), + value: groupByValues.length ? groupByValues : null, + filters: + updatedFilters && Object.keys(updatedFilters).length + ? updatedFilters + : null, + }, + }, + isCurrentValueSelected: isActiveFilterValue(key, value), + }; +}; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getInitialSortState.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getInitialSortState.ts new file mode 100644 index 00000000000..8e77d781dd6 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getInitialSortState.ts @@ -0,0 +1,65 @@ +/** + * 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. + */ +// All ag grid sort related stuff +import { GridState, SortModelItem } from 'ag-grid-community'; +import { SortByItem } from '../types'; + +const getInitialSortState = (sortBy?: SortByItem[]): SortModelItem[] => { + if (Array.isArray(sortBy) && sortBy.length > 0) { + return [ + { + colId: sortBy[0]?.id, + sort: sortBy[0]?.desc ? 'desc' : 'asc', + }, + ]; + } + return []; +}; + +export const shouldSort = ({ + colId, + sortDir, + percentMetrics, + serverPagination, + gridInitialState, +}: { + colId: string; + sortDir: string; + percentMetrics: string[]; + serverPagination: boolean; + gridInitialState: GridState; +}) => { + // percent metrics are not sortable + if (percentMetrics.includes(colId)) return false; + // if server pagination is not enabled, return false + // since this is server pagination sort + if (!serverPagination) return false; + + const { + colId: initialColId = '', + sort: initialSortDir, + }: Partial = gridInitialState?.sort?.sortModel?.[0] || {}; + + // if the initial sort is the same as the current sort, return false + if (initialColId === colId && initialSortDir === sortDir) return false; + + return true; +}; + +export default getInitialSortState; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/isEqualColumns.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/isEqualColumns.ts new file mode 100644 index 00000000000..28731c73c27 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/isEqualColumns.ts @@ -0,0 +1,46 @@ +/** + * 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 { isEqualArray } from '@superset-ui/core'; +import { TableChartProps } from '../types'; + +export default function isEqualColumns( + propsA: TableChartProps[], + propsB: TableChartProps[], +) { + const a = propsA[0]; + const b = propsB[0]; + return ( + a.datasource.columnFormats === b.datasource.columnFormats && + a.datasource.currencyFormats === b.datasource.currencyFormats && + a.datasource.verboseMap === b.datasource.verboseMap && + a.formData.tableTimestampFormat === b.formData.tableTimestampFormat && + a.formData.timeGrainSqla === b.formData.timeGrainSqla && + JSON.stringify(a.formData.columnConfig || null) === + JSON.stringify(b.formData.columnConfig || null) && + isEqualArray(a.formData.metrics, b.formData.metrics) && + isEqualArray(a.queriesData?.[0]?.colnames, b.queriesData?.[0]?.colnames) && + isEqualArray(a.queriesData?.[0]?.coltypes, b.queriesData?.[0]?.coltypes) && + JSON.stringify(a.formData.extraFilters || null) === + JSON.stringify(b.formData.extraFilters || null) && + JSON.stringify(a.formData.extraFormData || null) === + JSON.stringify(b.formData.extraFormData || null) && + JSON.stringify(a.rawFormData.column_config || null) === + JSON.stringify(b.rawFormData.column_config || null) + ); +} diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/useColDefs.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/useColDefs.ts new file mode 100644 index 00000000000..67671aafab4 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/useColDefs.ts @@ -0,0 +1,327 @@ +/* eslint-disable camelcase */ +/** + * 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 { ColDef } from 'ag-grid-community'; +import { useCallback, useMemo } from 'react'; +import { DataRecord, GenericDataType } from '@superset-ui/core'; +import { ColorFormatters } from '@superset-ui/chart-controls'; +import { extent as d3Extent, max as d3Max } from 'd3-array'; +import { + BasicColorFormatterType, + CellRendererProps, + InputColumn, +} from '../types'; +import getCellClass from './getCellClass'; +import filterValueGetter from './filterValueGetter'; +import dateFilterComparator from './dateFilterComparator'; +import { getAggFunc } from './getAggFunc'; +import { TextCellRenderer } from '../renderers/TextCellRenderer'; +import { NumericCellRenderer } from '../renderers/NumericCellRenderer'; +import CustomHeader from '../AgGridTable/components/CustomHeader'; +import { valueFormatter, valueGetter } from './formatValue'; +import getCellStyle from './getCellStyle'; + +interface InputData { + [key: string]: any; +} + +type UseColDefsProps = { + columns: InputColumn[]; + data: InputData[]; + serverPagination: boolean; + isRawRecords: boolean; + defaultAlignPN: boolean; + showCellBars: boolean; + colorPositiveNegative: boolean; + totals: DataRecord | undefined; + columnColorFormatters: ColorFormatters; + allowRearrangeColumns?: boolean; + basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[]; + isUsingTimeComparison?: boolean; + emitCrossFilters?: boolean; + alignPositiveNegative: boolean; + slice_id: number; +}; + +type ValueRange = [number, number]; + +function getValueRange( + key: string, + alignPositiveNegative: boolean, + data: InputData[], +) { + if (typeof data?.[0]?.[key] === 'number') { + const nums = data.map(row => row[key]) as number[]; + return ( + alignPositiveNegative ? [0, d3Max(nums.map(Math.abs))] : d3Extent(nums) + ) as ValueRange; + } + return null; +} + +const getCellDataType = (col: InputColumn) => { + switch (col.dataType) { + case GenericDataType.Numeric: + return 'number'; + case GenericDataType.Temporal: + return 'date'; + case GenericDataType.Boolean: + return 'boolean'; + default: + return 'text'; + } +}; + +const getFilterType = (col: InputColumn) => { + switch (col.dataType) { + case GenericDataType.Numeric: + return 'agNumberColumnFilter'; + case GenericDataType.String: + return 'agMultiColumnFilter'; + case GenericDataType.Temporal: + return 'agDateColumnFilter'; + default: + return true; + } +}; + +function getHeaderLabel(col: InputColumn) { + let headerLabel: string | undefined; + + const hasOriginalLabel = !!col?.originalLabel; + const isMain = col?.key?.includes('Main'); + const hasDisplayTypeIcon = col?.config?.displayTypeIcon !== false; + const hasCustomColumnName = !!col?.config?.customColumnName; + + if (hasOriginalLabel && hasCustomColumnName) { + if ('displayTypeIcon' in col.config) { + headerLabel = + hasDisplayTypeIcon && !isMain + ? `${col.label} ${col.config.customColumnName}` + : col.config.customColumnName; + } else { + headerLabel = col.config.customColumnName; + } + } else if (hasOriginalLabel && isMain) { + headerLabel = col.originalLabel; + } else if (hasOriginalLabel && !hasDisplayTypeIcon) { + headerLabel = ''; + } else { + headerLabel = col?.label; + } + return headerLabel || ''; +} + +export const useColDefs = ({ + columns, + data, + serverPagination, + isRawRecords, + defaultAlignPN, + showCellBars, + colorPositiveNegative, + totals, + columnColorFormatters, + allowRearrangeColumns, + basicColorFormatters, + isUsingTimeComparison, + emitCrossFilters, + alignPositiveNegative, + slice_id, +}: UseColDefsProps) => { + const getCommonColProps = useCallback( + ( + col: InputColumn, + ): ColDef & { + isMain: boolean; + } => { + const { + config, + isMetric, + isPercentMetric, + isNumeric, + key: originalKey, + dataType, + originalLabel, + } = col; + + const alignPN = + config.alignPositiveNegative === undefined + ? defaultAlignPN + : config.alignPositiveNegative; + + const hasColumnColorFormatters = + isNumeric && + Array.isArray(columnColorFormatters) && + columnColorFormatters.length > 0; + + const hasBasicColorFormatters = + isUsingTimeComparison && + Array.isArray(basicColorFormatters) && + basicColorFormatters.length > 0; + + const isMain = originalKey?.includes('Main'); + const colId = isMain + ? originalKey.replace('Main', '').trim() + : originalKey; + const isTextColumn = + dataType === GenericDataType.String || + dataType === GenericDataType.Temporal; + + const valueRange = + !hasBasicColorFormatters && + !hasColumnColorFormatters && + showCellBars && + (config.showCellBars ?? true) && + (isMetric || isRawRecords || isPercentMetric) && + getValueRange(originalKey, alignPN || alignPositiveNegative, data); + + const filter = getFilterType(col); + + return { + field: colId, + headerName: getHeaderLabel(col), + valueFormatter: p => valueFormatter(p, col), + valueGetter: p => valueGetter(p, col), + cellStyle: p => + getCellStyle({ + ...p, + hasColumnColorFormatters, + columnColorFormatters, + hasBasicColorFormatters, + basicColorFormatters, + col, + }), + cellClass: p => + getCellClass({ + ...p, + col, + emitCrossFilters, + }), + minWidth: config?.columnWidth ?? 100, + filter, + ...(isPercentMetric && { + filterValueGetter, + }), + ...(dataType === GenericDataType.Temporal && { + filterParams: { + comparator: dateFilterComparator, + }, + }), + cellDataType: getCellDataType(col), + defaultAggFunc: getAggFunc(col), + initialAggFunc: getAggFunc(col), + ...(!(isMetric || isPercentMetric) && { + allowedAggFuncs: [ + 'sum', + 'min', + 'max', + 'count', + 'avg', + 'first', + 'last', + ], + }), + cellRenderer: (p: CellRendererProps) => + isTextColumn ? TextCellRenderer(p) : NumericCellRenderer(p), + cellRendererParams: { + allowRenderHtml: true, + columns, + hasBasicColorFormatters, + col, + basicColorFormatters, + valueRange, + alignPositiveNegative: alignPN || alignPositiveNegative, + colorPositiveNegative, + }, + context: { + isMetric, + isPercentMetric, + isNumeric, + }, + lockPinned: !allowRearrangeColumns, + sortable: !serverPagination || !isPercentMetric, + ...(serverPagination && { + headerComponent: CustomHeader, + comparator: () => 0, + headerComponentParams: { + slice_id, + }, + }), + isMain, + ...(!isMain && + originalLabel && { + columnGroupShow: 'open', + }), + ...(originalLabel && { + timeComparisonKey: originalLabel, + }), + wrapText: !config?.truncateLongCells, + autoHeight: !config?.truncateLongCells, + }; + }, + [ + columns, + data, + defaultAlignPN, + columnColorFormatters, + basicColorFormatters, + showCellBars, + colorPositiveNegative, + isUsingTimeComparison, + isRawRecords, + emitCrossFilters, + allowRearrangeColumns, + serverPagination, + alignPositiveNegative, + ], + ); + + const stringifiedCols = JSON.stringify(columns); + + const colDefs = useMemo(() => { + const groupIndexMap = new Map(); + + return columns.reduce((acc, col) => { + const colDef = getCommonColProps(col); + + if (col?.originalLabel) { + if (groupIndexMap.has(col.originalLabel)) { + const groupIdx = groupIndexMap.get(col.originalLabel)!; + (acc[groupIdx] as { children: ColDef[] }).children.push(colDef); + } else { + const group = { + headerName: col.originalLabel, + marryChildren: true, + openByDefault: true, + children: [colDef], + }; + groupIndexMap.set(col.originalLabel, acc.length); + acc.push(group); + } + } else { + acc.push(colDef); + } + + return acc; + }, []); + }, [stringifiedCols, getCommonColProps]); + + return colDefs; +}; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/useTableTheme.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/useTableTheme.ts new file mode 100644 index 00000000000..7736fe584a1 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/useTableTheme.ts @@ -0,0 +1,42 @@ +/** + * 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 { useTheme } from '@superset-ui/core'; +import { + colorSchemeDark, + colorSchemeLight, + themeQuartz, +} from 'ag-grid-community'; +// eslint-disable-next-line import/no-extraneous-dependencies +import tinycolor from 'tinycolor2'; + +export const useIsDark = () => { + const theme = useTheme(); + return tinycolor(theme.colorBgContainer).isDark(); +}; + +const useTableTheme = () => { + const baseTheme = themeQuartz; + const isDarkTheme = useIsDark(); + const tableTheme = isDarkTheme + ? baseTheme.withPart(colorSchemeDark) + : baseTheme.withPart(colorSchemeLight); + return tableTheme; +}; + +export default useTableTheme; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/tsconfig.json b/superset-frontend/plugins/plugin-chart-ag-grid-table/tsconfig.json new file mode 100644 index 00000000000..f60297e2489 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "declarationDir": "lib", + "outDir": "lib", + "rootDir": "src" + }, + "exclude": ["lib", "test"], + "extends": "../../tsconfig.json", + "include": ["src/**/*", "types/**/*", "../../types/**/*"], + "references": [ + { + "path": "../../packages/superset-ui-chart-controls" + }, + { + "path": "../../packages/superset-ui-core" + } + ] +} diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/types/external.d.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/types/external.d.ts new file mode 100644 index 00000000000..eb7f1895cb7 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/types/external.d.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +declare module '*.png'; +declare module '*.jpg'; +declare module 'regenerator-runtime/runtime'; diff --git a/superset-frontend/src/visualizations/presets/MainPreset.js b/superset-frontend/src/visualizations/presets/MainPreset.js index 387570a3262..4b6227fce6c 100644 --- a/superset-frontend/src/visualizations/presets/MainPreset.js +++ b/superset-frontend/src/visualizations/presets/MainPreset.js @@ -80,6 +80,7 @@ import { import { PivotTableChartPlugin as PivotTableChartPluginV2 } from '@superset-ui/plugin-chart-pivot-table'; import { HandlebarsChartPlugin } from '@superset-ui/plugin-chart-handlebars'; import { FilterPlugins } from 'src/constants'; +import AgGridTableChartPlugin from '@superset-ui/plugin-chart-ag-grid-table'; import TimeTableChartPlugin from '../TimeTable'; export default class MainPreset extends Preset { @@ -94,6 +95,10 @@ export default class MainPreset extends Preset { ] : []; + const agGridTablePlugin = isFeatureEnabled(FeatureFlag.AgGridTableEnabled) + ? [new AgGridTableChartPlugin().configure({ key: VizType.TableAgGrid })] + : []; + super({ name: 'Legacy charts', presets: [new DeckGLChartPreset()], @@ -187,6 +192,7 @@ export default class MainPreset extends Preset { ], }).configure({ key: VizType.Cartodiagram }), ...experimentalPlugins, + ...agGridTablePlugin, ], }); } diff --git a/superset/config.py b/superset/config.py index 6d847efe72e..8463ead8ba9 100644 --- a/superset/config.py +++ b/superset/config.py @@ -579,6 +579,10 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = { # Allow metrics and columns to be grouped into (potentially nested) folders in the # chart builder "DATASET_FOLDERS": False, + # Enable Table V2 Viz plugin + "AG_GRID_TABLE_ENABLED": False, + # Enable Table v2 time comparison feature + "TABLE_V2_TIME_COMPARISON_ENABLED": False, } # ------------------------------