From 38eecfc5d47b50f5ab24840d68e715ce2fb52709 Mon Sep 17 00:00:00 2001 From: "JUST.in DO IT" Date: Wed, 27 Mar 2024 11:25:55 -0700 Subject: [PATCH] perf(explore): virtualized datasource field sections (#27625) --- superset-frontend/package-lock.json | 19 ++ superset-frontend/package.json | 1 + .../DatasourcePanel/DatasourcePanel.test.tsx | 12 + .../DatasourcePanelItem.test.tsx | 168 +++++++++++++ .../DatasourcePanel/DatasourcePanelItem.tsx | 234 ++++++++++++++++++ .../components/DatasourcePanel/index.tsx | 219 ++++------------ .../components/ExploreViewContainer/index.jsx | 36 +-- 7 files changed, 504 insertions(+), 185 deletions(-) create mode 100644 superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx create mode 100644 superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 3181407af..90ce4b0a7 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -200,6 +200,7 @@ "@types/react-table": "^7.7.19", "@types/react-transition-group": "^4.4.10", "@types/react-ultimate-pagination": "^1.2.0", + "@types/react-virtualized-auto-sizer": "^1.0.4", "@types/react-window": "^1.8.5", "@types/redux-localstorage": "^1.0.8", "@types/redux-mock-store": "^1.0.2", @@ -23347,6 +23348,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-virtualized-auto-sizer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.4.tgz", + "integrity": "sha512-nhYwlFiYa8M3S+O2T9QO/e1FQUYMr/wJENUdf/O0dhRi1RS/93rjrYQFYdbUqtdFySuhrtnEDX29P6eKOttY+A==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-window": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz", @@ -89843,6 +89853,15 @@ "@types/react": "*" } }, + "@types/react-virtualized-auto-sizer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.4.tgz", + "integrity": "sha512-nhYwlFiYa8M3S+O2T9QO/e1FQUYMr/wJENUdf/O0dhRi1RS/93rjrYQFYdbUqtdFySuhrtnEDX29P6eKOttY+A==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-window": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 7f3a80ea7..1c2c4770e 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -266,6 +266,7 @@ "@types/react-table": "^7.7.19", "@types/react-transition-group": "^4.4.10", "@types/react-ultimate-pagination": "^1.2.0", + "@types/react-virtualized-auto-sizer": "^1.0.4", "@types/react-window": "^1.8.5", "@types/redux-localstorage": "^1.0.8", "@types/redux-mock-store": "^1.0.2", diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx index 95258f443..452ee4609 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx @@ -30,6 +30,17 @@ import { import { DatasourceType } from '@superset-ui/core'; import DatasourceControl from 'src/explore/components/controls/DatasourceControl'; +jest.mock( + 'react-virtualized-auto-sizer', + () => + ({ + children, + }: { + children: (params: { height: number }) => React.ReactChild; + }) => + children({ height: 500 }), +); + const datasource: IDatasource = { id: 1, type: DatasourceType.Table, @@ -69,6 +80,7 @@ const props: DatasourcePanelProps = { actions: { setControlValue: jest.fn(), }, + width: 300, }; const search = (value: string, input: HTMLElement) => { diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx new file mode 100644 index 000000000..76c4d58e2 --- /dev/null +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx @@ -0,0 +1,168 @@ +/** + * 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 React from 'react'; + +import { + columns, + metrics, +} from 'src/explore/components/DatasourcePanel/fixtures'; +import { fireEvent, render, within } from 'spec/helpers/testing-library'; +import DatasourcePanelItem from './DatasourcePanelItem'; + +const mockData = { + metricSlice: metrics, + columnSlice: columns, + totalMetrics: Math.max(metrics.length, 10), + totalColumns: Math.max(columns.length, 13), + width: 300, + showAllMetrics: false, + onShowAllMetricsChange: jest.fn(), + showAllColumns: false, + onShowAllColumnsChange: jest.fn(), + collapseMetrics: false, + onCollapseMetricsChange: jest.fn(), + collapseColumns: false, + onCollapseColumnsChange: jest.fn(), +}; + +test('renders each item accordingly', () => { + const { getByText, getByTestId, rerender, container } = render( + , + { useDnd: true }, + ); + + expect(getByText('Metrics')).toBeInTheDocument(); + rerender(); + expect( + getByText( + `Showing ${mockData.metricSlice.length} of ${mockData.totalMetrics}`, + ), + ).toBeInTheDocument(); + mockData.metricSlice.forEach((metric, metricIndex) => { + rerender( + , + ); + expect(getByTestId('DatasourcePanelDragOption')).toBeInTheDocument(); + expect( + within(getByTestId('DatasourcePanelDragOption')).getByText( + metric.metric_name, + ), + ).toBeInTheDocument(); + }); + rerender( + , + ); + expect(container).toHaveTextContent(''); + + const startIndexOfColumnSection = mockData.metricSlice.length + 3; + rerender( + , + ); + expect(getByText('Columns')).toBeInTheDocument(); + rerender( + , + ); + expect( + getByText( + `Showing ${mockData.columnSlice.length} of ${mockData.totalColumns}`, + ), + ).toBeInTheDocument(); + mockData.columnSlice.forEach((column, columnIndex) => { + rerender( + , + ); + expect(getByTestId('DatasourcePanelDragOption')).toBeInTheDocument(); + expect( + within(getByTestId('DatasourcePanelDragOption')).getByText( + column.column_name, + ), + ).toBeInTheDocument(); + }); +}); + +test('can collapse metrics and columns', () => { + mockData.onCollapseMetricsChange.mockClear(); + mockData.onCollapseColumnsChange.mockClear(); + const { queryByText, getByRole, rerender } = render( + , + { useDnd: true }, + ); + fireEvent.click(getByRole('button')); + expect(mockData.onCollapseMetricsChange).toBeCalled(); + expect(mockData.onCollapseColumnsChange).not.toBeCalled(); + + const startIndexOfColumnSection = mockData.metricSlice.length + 3; + rerender( + , + ); + fireEvent.click(getByRole('button')); + expect(mockData.onCollapseColumnsChange).toBeCalled(); + + rerender( + , + ); + expect( + queryByText( + `Showing ${mockData.metricSlice.length} of ${mockData.totalMetrics}`, + ), + ).not.toBeInTheDocument(); + + rerender( + , + ); + expect(queryByText('Columns')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx new file mode 100644 index 000000000..ab89019da --- /dev/null +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx @@ -0,0 +1,234 @@ +/** + * 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 React, { CSSProperties } from 'react'; +import { css, Metric, styled, t, useTheme } from '@superset-ui/core'; + +import Icons from 'src/components/Icons'; +import DatasourcePanelDragOption from './DatasourcePanelDragOption'; +import { DndItemType } from '../DndItemType'; +import { DndItemValue } from './types'; + +export type DataSourcePanelColumn = { + is_dttm?: boolean | null; + description?: string | null; + expression?: string | null; + is_certified?: number | null; + column_name?: string | null; + name?: string | null; + type?: string; +}; + +type Props = { + index: number; + style: CSSProperties; + data: { + metricSlice: Metric[]; + columnSlice: DataSourcePanelColumn[]; + totalMetrics: number; + totalColumns: number; + width: number; + showAllMetrics: boolean; + onShowAllMetricsChange: (showAll: boolean) => void; + showAllColumns: boolean; + onShowAllColumnsChange: (showAll: boolean) => void; + collapseMetrics: boolean; + onCollapseMetricsChange: (collapse: boolean) => void; + collapseColumns: boolean; + onCollapseColumnsChange: (collapse: boolean) => void; + }; +}; + +export const DEFAULT_MAX_COLUMNS_LENGTH = 50; +export const DEFAULT_MAX_METRICS_LENGTH = 50; +export const ITEM_HEIGHT = 30; + +const Button = styled.button` + background: none; + border: none; + text-decoration: underline; + color: ${({ theme }) => theme.colors.primary.dark1}; +`; + +const ButtonContainer = styled.div` + text-align: center; + padding-top: 2px; +`; + +const LabelWrapper = styled.div` + ${({ theme }) => css` + overflow: hidden; + text-overflow: ellipsis; + font-size: ${theme.typography.sizes.s}px; + background-color: ${theme.colors.grayscale.light4}; + margin: ${theme.gridUnit * 2}px 0; + border-radius: 4px; + padding: 0 ${theme.gridUnit}px; + + &:first-of-type { + margin-top: 0; + } + &:last-of-type { + margin-bottom: 0; + } + + padding: 0; + cursor: pointer; + &:hover { + background-color: ${theme.colors.grayscale.light3}; + } + + & > span { + white-space: nowrap; + } + + .option-label { + display: inline; + } + + .metric-option { + & > svg { + min-width: ${theme.gridUnit * 4}px; + } + & > .option-label { + overflow: hidden; + text-overflow: ellipsis; + } + } + `} +`; + +const SectionHeaderButton = styled.button` + display: flex; + justify-content: space-between; + align-items: center; + border: none; + background: transparent; + width: 100%; + padding-inline: 0px; +`; + +const SectionHeader = styled.span` + ${({ theme }) => ` + font-size: ${theme.typography.sizes.m}px; + line-height: 1.3; + `} +`; + +const DatasourcePanelItem: React.FC = ({ index, style, data }) => { + const { + metricSlice: _metricSlice, + columnSlice, + totalMetrics, + totalColumns, + width, + showAllMetrics, + onShowAllMetricsChange, + showAllColumns, + onShowAllColumnsChange, + collapseMetrics, + onCollapseMetricsChange, + collapseColumns, + onCollapseColumnsChange, + } = data; + const metricSlice = collapseMetrics ? [] : _metricSlice; + + const EXTRA_LINES = collapseMetrics ? 1 : 2; + const isColumnSection = collapseMetrics + ? index >= 1 + : index > metricSlice.length + EXTRA_LINES; + const HEADER_LINE = isColumnSection + ? metricSlice.length + EXTRA_LINES + 1 + : 0; + const SUBTITLE_LINE = HEADER_LINE + 1; + const BOTTOM_LINE = + (isColumnSection ? columnSlice.length : metricSlice.length) + + (collapseMetrics ? HEADER_LINE : SUBTITLE_LINE) + + 1; + const collapsed = isColumnSection ? collapseColumns : collapseMetrics; + const setCollapse = isColumnSection + ? onCollapseColumnsChange + : onCollapseMetricsChange; + const showAll = isColumnSection ? showAllColumns : showAllMetrics; + const setShowAll = isColumnSection + ? onShowAllColumnsChange + : onShowAllMetricsChange; + const theme = useTheme(); + + return ( +
+ {index === HEADER_LINE && ( + setCollapse(!collapsed)}> + + {isColumnSection ? t('Columns') : t('Metrics')} + + {collapsed ? ( + + ) : ( + + )} + + )} + {index === SUBTITLE_LINE && !collapsed && ( +
+ {isColumnSection + ? t(`Showing %s of %s`, columnSlice?.length, totalColumns) + : t(`Showing %s of %s`, metricSlice?.length, totalMetrics)} +
+ )} + {index > SUBTITLE_LINE && index < BOTTOM_LINE && ( + + + + )} + {index === BOTTOM_LINE && + !collapsed && + (isColumnSection + ? totalColumns > DEFAULT_MAX_COLUMNS_LENGTH + : totalMetrics > DEFAULT_MAX_METRICS_LENGTH) && ( + + + + )} +
+ ); +}; + +export default DatasourcePanelItem; diff --git a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx index 99f6b48b8..395b70061 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { css, DatasourceType, @@ -27,10 +27,11 @@ import { } from '@superset-ui/core'; import { ControlConfig } from '@superset-ui/chart-controls'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { FixedSizeList as List } from 'react-window'; import { debounce, isArray } from 'lodash'; import { matchSorter, rankings } from 'match-sorter'; -import Collapse from 'src/components/Collapse'; import Alert from 'src/components/Alert'; import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal'; import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils'; @@ -38,23 +39,16 @@ import { Input } from 'src/components/Input'; import { FAST_DEBOUNCE } from 'src/constants'; import { ExploreActions } from 'src/explore/actions/exploreActions'; import Control from 'src/explore/components/Control'; -import DatasourcePanelDragOption from './DatasourcePanelDragOption'; -import { DndItemType } from '../DndItemType'; -import { DndItemValue } from './types'; +import DatasourcePanelItem, { + ITEM_HEIGHT, + DataSourcePanelColumn, + DEFAULT_MAX_COLUMNS_LENGTH, + DEFAULT_MAX_METRICS_LENGTH, +} from './DatasourcePanelItem'; interface DatasourceControl extends ControlConfig { datasource?: IDatasource; } - -export interface DataSourcePanelColumn { - is_dttm?: boolean | null; - description?: string | null; - expression?: string | null; - is_certified?: number | null; - column_name?: string | null; - name?: string | null; - type?: string; -} export interface IDatasource { metrics: Metric[]; columns: DataSourcePanelColumn[]; @@ -76,22 +70,10 @@ export interface Props { }; actions: Partial & Pick; // we use this props control force update when this panel resize - shouldForceUpdate?: number; + width: number; formData?: QueryFormData; } -const Button = styled.button` - background: none; - border: none; - text-decoration: underline; - color: ${({ theme }) => theme.colors.primary.dark1}; -`; - -const ButtonContainer = styled.div` - text-align: center; - padding-top: 2px; -`; - const DatasourceContainer = styled.div` ${({ theme }) => css` background-color: ${theme.colors.grayscale.light5}; @@ -104,8 +86,9 @@ const DatasourceContainer = styled.div` height: auto; } .field-selections { - padding: 0 0 ${4 * theme.gridUnit}px; + padding: 0 0 ${theme.gridUnit}px; overflow: auto; + height: 100%; } .field-length { margin-bottom: ${theme.gridUnit * 2}px; @@ -127,56 +110,6 @@ const DatasourceContainer = styled.div` `}; `; -const LabelWrapper = styled.div` - ${({ theme }) => css` - overflow: hidden; - text-overflow: ellipsis; - font-size: ${theme.typography.sizes.s}px; - background-color: ${theme.colors.grayscale.light4}; - margin: ${theme.gridUnit * 2}px 0; - border-radius: 4px; - padding: 0 ${theme.gridUnit}px; - - &:first-of-type { - margin-top: 0; - } - &:last-of-type { - margin-bottom: 0; - } - - padding: 0; - cursor: pointer; - &:hover { - background-color: ${theme.colors.grayscale.light3}; - } - - & > span { - white-space: nowrap; - } - - .option-label { - display: inline; - } - - .metric-option { - & > svg { - min-width: ${theme.gridUnit * 4}px; - } - & > .option-label { - overflow: hidden; - text-overflow: ellipsis; - } - } - `} -`; - -const SectionHeader = styled.span` - ${({ theme }) => ` - font-size: ${theme.typography.sizes.m}px; - line-height: 1.3; - `} -`; - const StyledInfoboxWrapper = styled.div` ${({ theme }) => css` margin: 0 ${theme.gridUnit * 2.5}px; @@ -187,27 +120,14 @@ const StyledInfoboxWrapper = styled.div` `} `; -const LabelContainer = (props: { - children: React.ReactElement; - className: string; -}) => { - const labelRef = useRef(null); - const extendedProps = { - labelRef, - }; - return ( - - {React.cloneElement(props.children, extendedProps)} - - ); -}; +const BORDER_WIDTH = 2; export default function DataSourcePanel({ datasource, formData, controls: { datasource: datasourceControl }, actions, - shouldForceUpdate, + width, }: Props) { const { columns: _columns, metrics } = datasource; // display temporal column first @@ -233,9 +153,8 @@ export default function DataSourcePanel({ }); const [showAllMetrics, setShowAllMetrics] = useState(false); const [showAllColumns, setShowAllColumns] = useState(false); - - const DEFAULT_MAX_COLUMNS_LENGTH = 50; - const DEFAULT_MAX_METRICS_LENGTH = 50; + const [collapseMetrics, setCollapseMetrics] = useState(false); + const [collapseColumns, setCollapseColumns] = useState(false); const search = useMemo( () => @@ -385,78 +304,40 @@ export default function DataSourcePanel({ /> )} - - {metrics?.length && ( - {t('Metrics')}} - key="metrics" + + {({ height }) => ( + -
- {t( - `Showing %s of %s`, - metricSlice?.length, - lists?.metrics.length, - )} -
- {metricSlice?.map?.((m: Metric) => ( - - - - ))} - {lists?.metrics?.length > DEFAULT_MAX_METRICS_LENGTH ? ( - - - - ) : ( - <> - )} -
+ {DatasourcePanelItem} + )} - {t('Columns')}} - key="column" - > -
- {t( - `Showing %s of %s`, - columnSlice.length, - lists.columns.length, - )} -
- {columnSlice.map(col => ( - - - - ))} - {lists.columns.length > DEFAULT_MAX_COLUMNS_LENGTH ? ( - - - - ) : ( - <> - )} -
-
+ ), @@ -470,8 +351,10 @@ export default function DataSourcePanel({ search, showAllColumns, showAllMetrics, + collapseMetrics, + collapseColumns, datasourceIsSaveable, - shouldForceUpdate, + width, ], ); diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index 0da43ebdc..1aeb45cb1 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -229,6 +229,20 @@ const updateHistory = debounce( 1000, ); +const defaultSidebarsWidth = { + controls_width: 320, + datasource_width: 300, +}; + +function getSidebarWidths(key) { + return getItem(key, defaultSidebarsWidth[key]); +} + +function setSidebarWidths(key, dimension) { + const newDimension = Number(getSidebarWidths(key)) + dimension.width; + setItem(key, newDimension); +} + function ExploreViewContainer(props) { const dynamicPluginContext = usePluginContext(); const dynamicPlugin = dynamicPluginContext.dynamicPlugins[props.vizType]; @@ -243,16 +257,13 @@ function ExploreViewContainer(props) { ); const [isCollapsed, setIsCollapsed] = useState(false); - const [shouldForceUpdate, setShouldForceUpdate] = useState(-1); + const [width, setWidth] = useState( + getSidebarWidths(LocalStorageKeys.DatasourceWidth), + ); const tabId = useTabId(); const theme = useTheme(); - const defaultSidebarsWidth = { - controls_width: 320, - datasource_width: 300, - }; - const addHistory = useCallback( async ({ isReplace = false, title } = {}) => { const formData = props.dashboardId @@ -534,15 +545,6 @@ function ExploreViewContainer(props) { ); } - function getSidebarWidths(key) { - return getItem(key, defaultSidebarsWidth[key]); - } - - function setSidebarWidths(key, dimension) { - const newDimension = Number(getSidebarWidths(key)) + dimension.width; - setItem(key, newDimension); - } - if (props.standalone) { return renderChartContainer(); } @@ -593,7 +595,7 @@ function ExploreViewContainer(props) { /> { - setShouldForceUpdate(d?.width); + setWidth(ref.getBoundingClientRect().width); setSidebarWidths(LocalStorageKeys.DatasourceWidth, d); }} defaultSize={{ @@ -627,7 +629,7 @@ function ExploreViewContainer(props) { datasource={props.datasource} controls={props.controls} actions={props.actions} - shouldForceUpdate={shouldForceUpdate} + width={width} user={props.user} />