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 f314748c14..229a88d6a6 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -31,6 +31,7 @@ export enum FeatureFlag { DASHBOARD_FILTERS_EXPERIMENTAL = 'DASHBOARD_FILTERS_EXPERIMENTAL', DASHBOARD_NATIVE_FILTERS = 'DASHBOARD_NATIVE_FILTERS', DASHBOARD_NATIVE_FILTERS_SET = 'DASHBOARD_NATIVE_FILTERS_SET', + DASHBOARD_VIRTUALIZATION = 'DASHBOARD_VIRTUALIZATION', DASHBOARD_RBAC = 'DASHBOARD_RBAC', DATAPANEL_CLOSED_BY_DEFAULT = 'DATAPANEL_CLOSED_BY_DEFAULT', DISABLE_DATASET_SOURCE_EDIT = 'DISABLE_DATASET_SOURCE_EDIT', diff --git a/superset-frontend/src/components/Chart/Chart.jsx b/superset-frontend/src/components/Chart/Chart.jsx index 893665ab15..38b092bc87 100644 --- a/superset-frontend/src/components/Chart/Chart.jsx +++ b/superset-frontend/src/components/Chart/Chart.jsx @@ -28,6 +28,7 @@ import ErrorBoundary from 'src/components/ErrorBoundary'; import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils'; import { URL_PARAMS } from 'src/constants'; import { getUrlParam } from 'src/utils/urlUtils'; +import { isCurrentUserBot } from 'src/utils/isBot'; import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; import ChartRenderer from './ChartRenderer'; import { ChartErrorMessage } from './ChartErrorMessage'; @@ -74,6 +75,7 @@ const propTypes = { ownState: PropTypes.object, postTransformProps: PropTypes.func, datasetsStatus: PropTypes.oneOf(['loading', 'error', 'complete']), + isInView: PropTypes.bool, }; const BLANK = {}; @@ -92,6 +94,7 @@ const defaultProps = { chartStackTrace: null, isDeactivatedViz: false, force: false, + isInView: true, }; const Styles = styled.div` @@ -307,11 +310,17 @@ class Chart extends React.PureComponent { width={width} >
- + {this.props.isInView || + !isFeatureEnabled(FeatureFlag.DASHBOARD_VIRTUALIZATION) || + isCurrentUserBot() ? ( + + ) : ( + + )}
{isLoading && !isDeactivatedViz && } diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx index 314dd6d1cb..9e3e3e94b6 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx @@ -92,6 +92,7 @@ const propTypes = { filterState: PropTypes.object, postTransformProps: PropTypes.func, datasetsStatus: PropTypes.oneOf(['loading', 'error', 'complete']), + isInView: PropTypes.bool, }; const defaultProps = { @@ -382,6 +383,7 @@ class Chart extends React.Component { filterboxMigrationState, postTransformProps, datasetsStatus, + isInView, } = this.props; const { width } = this.state; @@ -511,6 +513,7 @@ class Chart extends React.Component { filterboxMigrationState={filterboxMigrationState} postTransformProps={postTransformProps} datasetsStatus={datasetsStatus} + isInView={isInView} /> diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx index 62ad0584f0..4470d616fc 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx @@ -137,7 +137,7 @@ describe('ChartHolder', () => { rerender( - + , ); @@ -414,6 +414,7 @@ describe('ChartHolder', () => { deleteComponent={deleteComponent} fullSizeChartId={null} editMode + isInView /> , ); diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx index c3cffe338c..103fbf273e 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx @@ -67,6 +67,8 @@ interface ChartHolderProps { handleComponentDrop: (...args: unknown[]) => unknown; setFullSizeChartId: (chartId: number | null) => void; postAddSliceFromDashboard?: () => void; + + isInView: boolean; } const ChartHolder: React.FC = ({ @@ -91,6 +93,7 @@ const ChartHolder: React.FC = ({ handleComponentDrop, setFullSizeChartId, postAddSliceFromDashboard, + isInView, }) => { const { chartId } = component.meta; const isFullSize = fullSizeChartId === chartId; @@ -314,6 +317,7 @@ const ChartHolder: React.FC = ({ setControlValue={handleExtraControl} extraControls={extraControls} postTransformProps={handlePostTransformProps} + isInView={isInView} /> {editMode && ( diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row.jsx b/superset-frontend/src/dashboard/components/gridComponents/Row.jsx index b0037bf123..26980701c7 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Row.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Row.jsx @@ -19,6 +19,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; +import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import DragDroppable from 'src/dashboard/components/dnd/DragDroppable'; import DragHandle from 'src/dashboard/components/dnd/DragHandle'; @@ -32,6 +33,7 @@ import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu'; import { componentShape } from 'src/dashboard/util/propShapes'; import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions'; import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants'; +import { isCurrentUserBot } from 'src/utils/isBot'; const propTypes = { id: PropTypes.string.isRequired, @@ -61,6 +63,7 @@ class Row extends React.PureComponent { super(props); this.state = { isFocused: false, + isInView: false, }; this.handleDeleteComponent = this.handleDeleteComponent.bind(this); this.handleUpdateMeta = this.handleUpdateMeta.bind(this); @@ -69,6 +72,50 @@ class Row extends React.PureComponent { 'background', ); this.handleChangeFocus = this.handleChangeFocus.bind(this); + + this.containerRef = React.createRef(); + this.observerEnabler = null; + this.observerDisabler = null; + } + + // if chart not rendered - render it if it's less than 1 view height away from current viewport + // if chart rendered - remove it if it's more than 4 view heights away from current viewport + componentDidMount() { + if ( + isFeatureEnabled(FeatureFlag.DASHBOARD_VIRTUALIZATION) && + !isCurrentUserBot() + ) { + this.observerEnabler = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !this.state.isInView) { + this.setState({ isInView: true }); + } + }, + { + rootMargin: '100% 0px', + }, + ); + this.observerDisabler = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting && this.state.isInView) { + this.setState({ isInView: false }); + } + }, + { + rootMargin: '400% 0px', + }, + ); + const element = this.containerRef.current; + if (element) { + this.observerEnabler.observe(element); + this.observerDisabler.observe(element); + } + } + } + + componentWillUnmount() { + this.observerEnabler?.disconnect(); + this.observerDisabler?.disconnect(); } handleChangeFocus(nextFocus) { @@ -161,6 +208,7 @@ class Row extends React.PureComponent { backgroundStyle.className, )} data-test={`grid-row-${backgroundStyle.className}`} + ref={this.containerRef} > {rowItems.map((componentId, itemIndex) => ( ))} diff --git a/superset-frontend/src/utils/isBot.ts b/superset-frontend/src/utils/isBot.ts new file mode 100644 index 0000000000..8509a43c85 --- /dev/null +++ b/superset-frontend/src/utils/isBot.ts @@ -0,0 +1,21 @@ +/** + * 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. + */ + +// navigator.webdriver is true when browser is controlled by a bot +export const isCurrentUserBot = () => window?.navigator?.webdriver; diff --git a/superset-frontend/src/utils/isDashboardVirtualizationEnabled.ts b/superset-frontend/src/utils/isDashboardVirtualizationEnabled.ts new file mode 100644 index 0000000000..f27b8c849f --- /dev/null +++ b/superset-frontend/src/utils/isDashboardVirtualizationEnabled.ts @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export enum DASHBOARD_VIRTUALIZATION_MODE { + NONE = 'NONE', + VIEWPORT = 'VIEWPORT', + PAGINATED = 'PAGINATED', +} + +export const isDashboardVirtualizationEnabled = ( + virtualizationMode: DASHBOARD_VIRTUALIZATION_MODE, +) => + virtualizationMode === DASHBOARD_VIRTUALIZATION_MODE.VIEWPORT || + virtualizationMode === DASHBOARD_VIRTUALIZATION_MODE.PAGINATED; diff --git a/superset/config.py b/superset/config.py index a7b7e655ec..aec2dba890 100644 --- a/superset/config.py +++ b/superset/config.py @@ -425,6 +425,7 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = { # Feature is under active development and breaking changes are expected "DASHBOARD_NATIVE_FILTERS_SET": False, "DASHBOARD_FILTERS_EXPERIMENTAL": False, + "DASHBOARD_VIRTUALIZATION": False, "GLOBAL_ASYNC_QUERIES": False, "VERSIONED_EXPORT": True, "EMBEDDED_SUPERSET": False, @@ -761,7 +762,6 @@ SQLLAB_SCHEDULE_WARNING_MESSAGE = None # Force refresh while auto-refresh in dashboard DASHBOARD_AUTO_REFRESH_MODE: Literal["fetch", "force"] = "force" - # Default celery config is to use SQLA as a broker, in a production setting # you'll want to use a proper broker as specified here: # http://docs.celeryproject.org/en/latest/getting-started/brokers/index.html diff --git a/superset/views/base.py b/superset/views/base.py index b790ca709c..cf7868eaf1 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -103,6 +103,7 @@ FRONTEND_CONF_KEYS = ( "SQLALCHEMY_DISPLAY_TEXT", "GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL", "DASHBOARD_AUTO_REFRESH_MODE", + "DASHBOARD_VIRTUALIZATION", "SCHEDULED_QUERIES", "EXCEL_EXTENSIONS", "CSV_EXTENSIONS",