perf: Optimize Dashboard components (#31242)

This commit is contained in:
Kamil Gabryjelski 2024-12-02 15:04:39 +01:00 committed by GitHub
parent eab888c63a
commit 24d001e498
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 257 additions and 195 deletions

View File

@ -26,11 +26,7 @@ import getBootstrapData from 'src/utils/getBootstrapData';
import getChartIdsFromLayout from '../util/getChartIdsFromLayout';
import getLayoutComponentFromChartId from '../util/getLayoutComponentFromChartId';
import {
slicePropShape,
dashboardInfoPropShape,
dashboardStatePropShape,
} from '../util/propShapes';
import { slicePropShape } from '../util/propShapes';
import {
LOG_ACTIONS_HIDE_BROWSER_TAB,
LOG_ACTIONS_MOUNT_DASHBOARD,
@ -51,8 +47,10 @@ const propTypes = {
logEvent: PropTypes.func.isRequired,
clearDataMaskState: PropTypes.func.isRequired,
}).isRequired,
dashboardInfo: dashboardInfoPropShape.isRequired,
dashboardState: dashboardStatePropShape.isRequired,
dashboardId: PropTypes.number.isRequired,
editMode: PropTypes.bool,
isPublished: PropTypes.bool,
hasUnsavedChanges: PropTypes.bool,
slices: PropTypes.objectOf(slicePropShape).isRequired,
activeFilters: PropTypes.object.isRequired,
chartConfiguration: PropTypes.object,
@ -96,13 +94,13 @@ class Dashboard extends PureComponent {
componentDidMount() {
const bootstrapData = getBootstrapData();
const { dashboardState, layout } = this.props;
const { editMode, isPublished, layout } = this.props;
const eventData = {
is_soft_navigation: Logger.timeOriginOffset > 0,
is_edit_mode: dashboardState.editMode,
is_edit_mode: editMode,
mount_duration: Logger.getTimestamp(),
is_empty: isDashboardEmpty(layout),
is_published: dashboardState.isPublished,
is_published: isPublished,
bootstrap_data_length: bootstrapData.length,
};
const directLinkComponentId = getLocationHash();
@ -130,7 +128,7 @@ class Dashboard extends PureComponent {
const currentChartIds = getChartIdsFromLayout(this.props.layout);
const nextChartIds = getChartIdsFromLayout(nextProps.layout);
if (this.props.dashboardInfo.id !== nextProps.dashboardInfo.id) {
if (this.props.dashboardId !== nextProps.dashboardId) {
// single-page-app navigation check
return;
}
@ -157,10 +155,14 @@ class Dashboard extends PureComponent {
}
applyCharts() {
const { hasUnsavedChanges, editMode } = this.props.dashboardState;
const {
activeFilters,
ownDataCharts,
chartConfiguration,
hasUnsavedChanges,
editMode,
} = this.props;
const { appliedFilters, appliedOwnDataCharts } = this;
const { activeFilters, ownDataCharts, chartConfiguration } = this.props;
if (
isFeatureEnabled(FeatureFlag.DashboardCrossFilters) &&
!chartConfiguration

View File

@ -208,7 +208,9 @@ describe('DashboardBuilder', () => {
});
it('should render a BuilderComponentPane if editMode=true and user selects "Insert Components" pane', () => {
const { queryAllByTestId } = setup({ dashboardState: { editMode: true } });
const { queryAllByTestId } = setup({
dashboardState: { ...mockState.dashboardState, editMode: true },
});
const builderComponents = queryAllByTestId('mock-builder-component-pane');
expect(builderComponents.length).toBeGreaterThanOrEqual(1);
});
@ -241,7 +243,7 @@ describe('DashboardBuilder', () => {
it('should display a loading spinner when saving is in progress', async () => {
const { findByAltText } = setup({
dashboardState: { dashboardIsSaving: true },
dashboardState: { ...mockState.dashboardState, dashboardIsSaving: true },
});
expect(await findByAltText('Loading...')).toBeVisible();

View File

@ -370,6 +370,10 @@ const StyledDashboardContent = styled.div<{
`}
`;
const ELEMENT_ON_SCREEN_OPTIONS = {
threshold: [1],
};
const DashboardBuilder: FC<DashboardBuilderProps> = () => {
const dispatch = useDispatch();
const uiConfig = useUiConfig();
@ -469,9 +473,9 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
nativeFiltersEnabled,
} = useNativeFilters();
const [containerRef, isSticky] = useElementOnScreen<HTMLDivElement>({
threshold: [1],
});
const [containerRef, isSticky] = useElementOnScreen<HTMLDivElement>(
ELEMENT_ON_SCREEN_OPTIONS,
);
const showFilterBar =
(crossFiltersEnabled || nativeFiltersEnabled) && !editMode;
@ -581,6 +585,43 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
? 0
: theme.gridUnit * 8;
const renderChild = useCallback(
adjustedWidth => {
const filterBarWidth = dashboardFiltersOpen
? adjustedWidth
: CLOSED_FILTER_BAR_WIDTH;
return (
<FiltersPanel
width={filterBarWidth}
hidden={isReport}
data-test="dashboard-filters-panel"
>
<StickyPanel ref={containerRef} width={filterBarWidth}>
<ErrorBoundary>
<FilterBar
orientation={FilterBarOrientation.Vertical}
verticalConfig={{
filtersOpen: dashboardFiltersOpen,
toggleFiltersBar: toggleDashboardFiltersOpen,
width: filterBarWidth,
height: filterBarHeight,
offset: filterBarOffset,
}}
/>
</ErrorBoundary>
</StickyPanel>
</FiltersPanel>
);
},
[
dashboardFiltersOpen,
toggleDashboardFiltersOpen,
filterBarHeight,
filterBarOffset,
isReport,
],
);
return (
<DashboardWrapper>
{showFilterBar &&
@ -593,33 +634,7 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
maxWidth={OPEN_FILTER_BAR_MAX_WIDTH}
initialWidth={OPEN_FILTER_BAR_WIDTH}
>
{adjustedWidth => {
const filterBarWidth = dashboardFiltersOpen
? adjustedWidth
: CLOSED_FILTER_BAR_WIDTH;
return (
<FiltersPanel
width={filterBarWidth}
hidden={isReport}
data-test="dashboard-filters-panel"
>
<StickyPanel ref={containerRef} width={filterBarWidth}>
<ErrorBoundary>
<FilterBar
orientation={FilterBarOrientation.Vertical}
verticalConfig={{
filtersOpen: dashboardFiltersOpen,
toggleFiltersBar: toggleDashboardFiltersOpen,
width: filterBarWidth,
height: filterBarHeight,
offset: filterBarOffset,
}}
/>
</ErrorBoundary>
</StickyPanel>
</FiltersPanel>
);
}}
{renderChild}
</ResizableSidebar>
</>
)}

View File

@ -18,8 +18,17 @@
*/
// ParentSize uses resize observer so the dashboard will update size
// when its container size changes, due to e.g., builder side panel opening
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
FC,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
import {
Filter,
Filters,
@ -43,6 +52,7 @@ import {
import { getChartIdsInFilterScope } from 'src/dashboard/util/getChartIdsInFilterScope';
import findTabIndexByComponentId from 'src/dashboard/util/findTabIndexByComponentId';
import { setInScopeStatusOfFilters } from 'src/dashboard/actions/nativeFilters';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import {
applyDashboardLabelsColorOnLoad,
updateDashboardLabelsColor,
@ -59,6 +69,21 @@ type DashboardContainerProps = {
topLevelTabs?: LayoutItem;
};
export const renderedChartIdsSelector = createSelector(
[(state: RootState) => state.charts],
charts =>
Object.values(charts)
.filter(chart => chart.chartStatus === 'rendered')
.map(chart => chart.id),
);
const useRenderedChartIds = () => {
const renderedChartIds = useSelector<RootState, number[]>(
renderedChartIdsSelector,
);
return useMemo(() => renderedChartIds, [JSON.stringify(renderedChartIds)]);
};
const useNativeFilterScopes = () => {
const nativeFilters = useSelector<RootState, Filters>(
state => state.nativeFilters?.filters,
@ -70,10 +95,12 @@ const useNativeFilterScopes = () => {
pick(filter, ['id', 'scope', 'type']),
)
: [],
[JSON.stringify(nativeFilters)],
[nativeFilters],
);
};
const TOP_OF_PAGE_RANGE = 220;
const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
const nativeFilterScopes = useNativeFilterScopes();
const dispatch = useDispatch();
@ -87,14 +114,10 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
const directPathToChild = useSelector<RootState, string[]>(
state => state.dashboardState.directPathToChild,
);
const chartIds = useSelector<RootState, number[]>(state =>
Object.values(state.charts).map(chart => chart.id),
);
const renderedChartIds = useSelector<RootState, number[]>(state =>
Object.values(state.charts)
.filter(chart => chart.chartStatus === 'rendered')
.map(chart => chart.id),
);
const chartIds = useChartIds();
const renderedChartIds = useRenderedChartIds();
const [dashboardLabelsColorInitiated, setDashboardLabelsColorInitiated] =
useState(false);
const prevRenderedChartIds = useRef<number[]>([]);
@ -136,11 +159,13 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
chartsInScope: [],
};
}
const chartsInScope: number[] = getChartIdsInFilterScope(
filterScope.scope,
chartIds,
dashboardLayout,
);
const tabsInScope = findTabsWithChartsInScope(
dashboardLayout,
chartsInScope,
@ -152,14 +177,14 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
};
});
dispatch(setInScopeStatusOfFilters(scopes));
}, [nativeFilterScopes, dashboardLayout, dispatch]);
}, [chartIds, JSON.stringify(nativeFilterScopes), dashboardLayout, dispatch]);
const childIds: string[] = topLevelTabs
? topLevelTabs.children
: [DASHBOARD_GRID_ID];
const childIds: string[] = useMemo(
() => (topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID]),
[topLevelTabs],
);
const min = Math.min(tabIndex, childIds.length - 1);
const activeKey = min === 0 ? DASHBOARD_GRID_ID : min.toString();
const TOP_OF_PAGE_RANGE = 220;
useEffect(() => {
if (shouldForceFreshSharedLabelsColors) {
@ -229,57 +254,63 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
};
}, [onBeforeUnload]);
const renderTabBar = useCallback(() => <></>, []);
const handleFocus = useCallback(e => {
if (
// prevent scrolling when tabbing to the tab pane
e.target.classList.contains('ant-tabs-tabpane') &&
window.scrollY < TOP_OF_PAGE_RANGE
) {
// prevent window from jumping down when tabbing
// if already at the top of the page
// to help with accessibility when using keyboard navigation
window.scrollTo(window.scrollX, 0);
}
}, []);
const renderParentSizeChildren = useCallback(
({ width }) => (
/*
We use a TabContainer irrespective of whether top-level tabs exist to maintain
a consistent React component tree. This avoids expensive mounts/unmounts of
the entire dashboard upon adding/removing top-level tabs, which would otherwise
happen because of React's diffing algorithm
*/
<Tabs
id={DASHBOARD_GRID_ID}
activeKey={activeKey}
renderTabBar={renderTabBar}
fullWidth={false}
animated={false}
allowOverflow
onFocus={handleFocus}
>
{childIds.map((id, index) => (
// Matching the key of the first TabPane irrespective of topLevelTabs
// lets us keep the same React component tree when !!topLevelTabs changes.
// This avoids expensive mounts/unmounts of the entire dashboard.
<Tabs.TabPane
key={index === 0 ? DASHBOARD_GRID_ID : index.toString()}
>
<DashboardGrid
gridComponent={dashboardLayout[id]}
// see isValidChild for why tabs do not increment the depth of their children
depth={DASHBOARD_ROOT_DEPTH + 1} // (topLevelTabs ? 0 : 1)}
width={width}
isComponentVisible={index === tabIndex}
/>
</Tabs.TabPane>
))}
</Tabs>
),
[activeKey, childIds, dashboardLayout, handleFocus, renderTabBar, tabIndex],
);
return (
<div className="grid-container" data-test="grid-container">
<ParentSize>
{({ width }) => (
/*
We use a TabContainer irrespective of whether top-level tabs exist to maintain
a consistent React component tree. This avoids expensive mounts/unmounts of
the entire dashboard upon adding/removing top-level tabs, which would otherwise
happen because of React's diffing algorithm
*/
<Tabs
id={DASHBOARD_GRID_ID}
activeKey={activeKey}
renderTabBar={() => <></>}
fullWidth={false}
animated={false}
allowOverflow
onFocus={e => {
if (
// prevent scrolling when tabbing to the tab pane
e.target.classList.contains('ant-tabs-tabpane') &&
window.scrollY < TOP_OF_PAGE_RANGE
) {
// prevent window from jumping down when tabbing
// if already at the top of the page
// to help with accessibility when using keyboard navigation
window.scrollTo(window.scrollX, 0);
}
}}
>
{childIds.map((id, index) => (
// Matching the key of the first TabPane irrespective of topLevelTabs
// lets us keep the same React component tree when !!topLevelTabs changes.
// This avoids expensive mounts/unmounts of the entire dashboard.
<Tabs.TabPane
key={index === 0 ? DASHBOARD_GRID_ID : index.toString()}
>
<DashboardGrid
gridComponent={dashboardLayout[id]}
// see isValidChild for why tabs do not increment the depth of their children
depth={DASHBOARD_ROOT_DEPTH + 1} // (topLevelTabs ? 0 : 1)}
width={width}
isComponentVisible={index === tabIndex}
/>
</Tabs.TabPane>
))}
</Tabs>
)}
</ParentSize>
<ParentSize>{renderParentSizeChildren}</ParentSize>
</div>
);
};
export default DashboardContainer;
export default memo(DashboardContainer);

View File

@ -17,7 +17,7 @@
* under the License.
*/
import { useSelector } from 'react-redux';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { RootState } from 'src/dashboard/types';
@ -34,7 +34,7 @@ export const useNativeFilters = () => {
);
const filters = useFilters();
const filterValues = Object.values(filters);
const filterValues = useMemo(() => Object.values(filters), [filters]);
const expandFilters = getUrlParam(URL_PARAMS.expandFilters);
const [dashboardFiltersOpen, setDashboardFiltersOpen] = useState(
expandFilters ?? !!filterValues.length,
@ -43,24 +43,28 @@ export const useNativeFilters = () => {
const nativeFiltersEnabled =
canEdit || (!canEdit && filterValues.length !== 0);
const requiredFirstFilter = filterValues.filter(
filter => filter.requiredFirst,
const requiredFirstFilter = useMemo(
() => filterValues.filter(filter => filter.requiredFirst),
[filterValues],
);
const dataMask = useNativeFiltersDataMask();
const missingInitialFilters = requiredFirstFilter
.filter(({ id }) => dataMask[id]?.filterState?.value === undefined)
.map(({ name }) => name);
const missingInitialFilters = useMemo(
() =>
requiredFirstFilter
.filter(({ id }) => dataMask[id]?.filterState?.value === undefined)
.map(({ name }) => name),
[requiredFirstFilter, dataMask],
);
const showDashboard =
isInitialized ||
!nativeFiltersEnabled ||
missingInitialFilters.length === 0;
const toggleDashboardFiltersOpen = useCallback(
(visible?: boolean) => {
setDashboardFiltersOpen(visible ?? !dashboardFiltersOpen);
},
[dashboardFiltersOpen],
);
const toggleDashboardFiltersOpen = useCallback((visible?: boolean) => {
setDashboardFiltersOpen(prevState => visible ?? !prevState);
}, []);
useEffect(() => {
if (

View File

@ -43,8 +43,10 @@ function mapStateToProps(state: RootState) {
return {
timeout: dashboardInfo.common?.conf?.SUPERSET_WEBSERVER_TIMEOUT,
userId: dashboardInfo.userId,
dashboardInfo,
dashboardState,
dashboardId: dashboardInfo.id,
editMode: dashboardState.editMode,
isPublished: dashboardState.isPublished,
hasUnsavedChanges: dashboardState.hasUnsavedChanges,
datasources,
chartConfiguration: dashboardInfo.metadata?.chart_configuration,
slices: sliceEntities.slices,

View File

@ -16,16 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { useCallback, memo, useMemo } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import { logEvent } from 'src/logger/actions';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { componentLookup } from 'src/dashboard/components/gridComponents';
import getDetailedComponentWidth from 'src/dashboard/util/getDetailedComponentWidth';
import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { componentShape } from 'src/dashboard/util/propShapes';
import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
import {
createComponent,
@ -47,86 +46,93 @@ const propTypes = {
renderHoverMenu: PropTypes.bool,
renderTabContent: PropTypes.bool,
onChangeTab: PropTypes.func,
component: componentShape.isRequired,
parentComponent: componentShape.isRequired,
createComponent: PropTypes.func.isRequired,
deleteComponent: PropTypes.func.isRequired,
updateComponents: PropTypes.func.isRequired,
handleComponentDrop: PropTypes.func.isRequired,
logEvent: PropTypes.func.isRequired,
directPathToChild: PropTypes.arrayOf(PropTypes.string),
directPathLastUpdated: PropTypes.number,
dashboardId: PropTypes.number.isRequired,
isComponentVisible: PropTypes.bool,
};
const defaultProps = {
isComponentVisible: true,
};
const DashboardComponent = props => {
const dispatch = useDispatch();
const dashboardLayout = useSelector(state => state.dashboardLayout.present);
const dashboardInfo = useSelector(state => state.dashboardInfo);
const editMode = useSelector(state => state.dashboardState.editMode);
const fullSizeChartId = useSelector(
state => state.dashboardState.fullSizeChartId,
);
const dashboardId = dashboardInfo.id;
const component = dashboardLayout[props.id];
const parentComponent = dashboardLayout[props.parentId];
const getComponentById = useCallback(
id => dashboardLayout[id],
[dashboardLayout],
);
const { isComponentVisible = true } = props;
const filters = getActiveFilters();
const embeddedMode = !dashboardInfo.userId;
function mapStateToProps(
{ dashboardLayout: undoableLayout, dashboardState, dashboardInfo },
ownProps,
) {
const dashboardLayout = undoableLayout.present;
const { id, parentId } = ownProps;
const component = dashboardLayout[id];
const props = {
component,
getComponentById: id => dashboardLayout[id],
parentComponent: dashboardLayout[parentId],
editMode: dashboardState.editMode,
filters: getActiveFilters(),
dashboardId: dashboardInfo.id,
dashboardInfo,
fullSizeChartId: dashboardState.fullSizeChartId,
embeddedMode: !dashboardInfo?.userId,
};
const boundActionCreators = useMemo(
() =>
bindActionCreators(
{
addDangerToast,
createComponent,
deleteComponent,
updateComponents,
handleComponentDrop,
setDirectPathToChild,
setFullSizeChartId,
setActiveTab,
logEvent,
},
dispatch,
),
[dispatch],
);
// rows and columns need more data about their child dimensions
// doing this allows us to not pass the entire component lookup to all Components
if (component) {
const componentType = component.type;
if (componentType === ROW_TYPE || componentType === COLUMN_TYPE) {
const { occupiedWidth, minimumWidth } = getDetailedComponentWidth({
id,
components: dashboardLayout,
});
const { occupiedColumnCount, minColumnWidth } = useMemo(() => {
if (component) {
const componentType = component.type;
if (componentType === ROW_TYPE || componentType === COLUMN_TYPE) {
const { occupiedWidth, minimumWidth } = getDetailedComponentWidth({
id: props.id,
components: dashboardLayout,
});
if (componentType === ROW_TYPE) props.occupiedColumnCount = occupiedWidth;
if (componentType === COLUMN_TYPE) props.minColumnWidth = minimumWidth;
if (componentType === ROW_TYPE) {
return { occupiedColumnCount: occupiedWidth };
}
if (componentType === COLUMN_TYPE) {
return { minColumnWidth: minimumWidth };
}
}
return {};
}
}
return {};
}, [component, dashboardLayout, props.id]);
return props;
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
addDangerToast,
createComponent,
deleteComponent,
updateComponents,
handleComponentDrop,
setDirectPathToChild,
setFullSizeChartId,
setActiveTab,
logEvent,
},
dispatch,
);
}
class DashboardComponent extends PureComponent {
render() {
const { component } = this.props;
const Component = component ? componentLookup[component.type] : null;
return Component ? <Component {...this.props} /> : null;
}
}
const Component = component ? componentLookup[component.type] : null;
return Component ? (
<Component
{...props}
{...boundActionCreators}
component={component}
getComponentById={getComponentById}
parentComponent={parentComponent}
editMode={editMode}
filters={filters}
dashboardId={dashboardId}
dashboardInfo={dashboardInfo}
fullSizeChartId={fullSizeChartId}
occupiedColumnCount={occupiedColumnCount}
minColumnWidth={minColumnWidth}
isComponentVisible={isComponentVisible}
embeddedMode={embeddedMode}
/>
) : null;
};
DashboardComponent.propTypes = propTypes;
DashboardComponent.defaultProps = defaultProps;
export default connect(mapStateToProps, mapDispatchToProps)(DashboardComponent);
export default memo(DashboardComponent);