superset-frontend/src/explore/components/ExploreViewContainer/index.jsx
/**
* 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 camelcase: 0 */
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import {
styled,
t,
css,
useTheme,
logging,
useChangeEffect,
useComponentDidMount,
usePrevious,
} from '@superset-ui/core';
import { debounce, omit, pick } from 'lodash';
import { Resizable } from 're-resizable';
import { usePluginContext } from 'src/components/DynamicPlugins';
import { Global } from '@emotion/react';
import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons';
import {
getItem,
setItem,
LocalStorageKeys,
} from 'src/utils/localStorageHelpers';
import { RESERVED_CHART_URL_PARAMS, URL_PARAMS } from 'src/constants';
import { areObjectsEqual } from 'src/reduxUtils';
import * as logActions from 'src/logger/actions';
import {
LOG_ACTIONS_MOUNT_EXPLORER,
LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS,
} from 'src/logger/LogUtils';
import { getUrlParam } from 'src/utils/urlUtils';
import cx from 'classnames';
import * as chartActions from 'src/components/Chart/chartAction';
import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources';
import { chartPropShape } from 'src/dashboard/util/propShapes';
import { mergeExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
import { postFormData, putFormData } from 'src/explore/exploreUtils/formData';
import { datasourcesActions } from 'src/explore/actions/datasourcesActions';
import { mountExploreUrl } from 'src/explore/exploreUtils';
import { getFormDataFromControls } from 'src/explore/controlUtils';
import * as exploreActions from 'src/explore/actions/exploreActions';
import * as saveModalActions from 'src/explore/actions/saveModalActions';
import { useTabId } from 'src/hooks/useTabId';
import withToasts from 'src/components/MessageToasts/withToasts';
import ExploreChartPanel from '../ExploreChartPanel';
import ConnectedControlPanelsContainer from '../ControlPanelsContainer';
import SaveModal from '../SaveModal';
import DataSourcePanel from '../DatasourcePanel';
import ConnectedExploreChartHeader from '../ExploreChartHeader';
import ExploreContainer from '../ExploreContainer';
const propTypes = {
...ExploreChartPanel.propTypes,
actions: PropTypes.object.isRequired,
datasource_type: PropTypes.string.isRequired,
dashboardId: PropTypes.number,
isDatasourceMetaLoading: PropTypes.bool.isRequired,
chart: chartPropShape.isRequired,
slice: PropTypes.object,
sliceName: PropTypes.string,
controls: PropTypes.object.isRequired,
forcedHeight: PropTypes.string,
form_data: PropTypes.object.isRequired,
standalone: PropTypes.bool.isRequired,
force: PropTypes.bool,
timeout: PropTypes.number,
impressionId: PropTypes.string,
vizType: PropTypes.string,
saveAction: PropTypes.string,
isSaveModalVisible: PropTypes.bool,
};
const ExplorePanelContainer = styled.div`
${({ theme }) => css`
background: ${theme.colors.grayscale.light5};
text-align: left;
position: relative;
width: 100%;
max-height: 100%;
min-height: 0;
display: flex;
flex: 1;
flex-wrap: nowrap;
border-top: 1px solid ${theme.colors.grayscale.light2};
.explore-column {
display: flex;
flex-direction: column;
padding: ${theme.gridUnit * 2}px 0;
max-height: 100%;
}
.data-source-selection {
background-color: ${theme.colors.grayscale.light5};
padding: ${theme.gridUnit * 2}px 0;
border-right: 1px solid ${theme.colors.grayscale.light2};
}
.main-explore-content {
flex: 1;
min-width: ${theme.gridUnit * 128}px;
border-left: 1px solid ${theme.colors.grayscale.light2};
padding: 0 ${theme.gridUnit * 4}px;
.panel {
margin-bottom: 0;
}
}
.controls-column {
align-self: flex-start;
padding: 0;
}
.title-container {
position: relative;
display: flex;
flex-direction: row;
padding: 0 ${theme.gridUnit * 2}px 0 ${theme.gridUnit * 4}px;
justify-content: space-between;
.horizontal-text {
font-size: ${theme.typography.sizes.m}px;
}
}
.no-show {
display: none;
}
.vertical-text {
writing-mode: vertical-rl;
text-orientation: mixed;
}
.sidebar {
height: 100%;
background-color: ${theme.colors.grayscale.light4};
padding: ${theme.gridUnit * 2}px;
width: ${theme.gridUnit * 8}px;
}
.collapse-icon > svg {
color: ${theme.colors.primary.base};
}
`};
`;
const updateHistory = debounce(
async (
formData,
datasourceId,
datasourceType,
isReplace,
standalone,
force,
title,
tabId,
) => {
const payload = { ...formData };
const chartId = formData.slice_id;
const params = new URLSearchParams(window.location.search);
const additionalParam = Object.fromEntries(params);
if (chartId) {
additionalParam[URL_PARAMS.sliceId.name] = chartId;
} else {
additionalParam[URL_PARAMS.datasourceId.name] = datasourceId;
additionalParam[URL_PARAMS.datasourceType.name] = datasourceType;
}
const urlParams = payload?.url_params || {};
Object.entries(urlParams).forEach(([key, value]) => {
if (!RESERVED_CHART_URL_PARAMS.includes(key)) {
additionalParam[key] = value;
}
});
try {
let key;
let stateModifier;
if (isReplace) {
key = await postFormData(
datasourceId,
datasourceType,
formData,
chartId,
tabId,
);
stateModifier = 'replaceState';
} else {
key = getUrlParam(URL_PARAMS.formDataKey);
await putFormData(
datasourceId,
datasourceType,
key,
formData,
chartId,
tabId,
);
stateModifier = 'pushState';
}
// avoid race condition in case user changes route before explore updates the url
if (window.location.pathname.startsWith('/explore')) {
const url = mountExploreUrl(
standalone ? URL_PARAMS.standalone.name : null,
{
[URL_PARAMS.formDataKey.name]: key,
...additionalParam,
},
force,
);
window.history[stateModifier](payload, title, url);
}
} catch (e) {
logging.warn('Failed at altering browser history', e);
}
},
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];
const isDynamicPluginLoading = dynamicPlugin && dynamicPlugin.mounting;
const wasDynamicPluginLoading = usePrevious(isDynamicPluginLoading);
/** the state of controls in the previous render */
const previousControls = usePrevious(props.controls);
/** the state of controls last time a query was triggered */
const [lastQueriedControls, setLastQueriedControls] = useState(
props.controls,
);
const [isCollapsed, setIsCollapsed] = useState(false);
const [width, setWidth] = useState(
getSidebarWidths(LocalStorageKeys.DatasourceWidth),
);
const tabId = useTabId();
const theme = useTheme();
const addHistory = useCallback(
async ({ isReplace = false, title } = {}) => {
const formData = props.dashboardId
? {
...props.form_data,
dashboardId: props.dashboardId,
}
: props.form_data;
const { id: datasourceId, type: datasourceType } = props.datasource;
updateHistory(
formData,
datasourceId,
datasourceType,
isReplace,
props.standalone,
props.force,
title,
tabId,
);
},
[
props.dashboardId,
props.form_data,
props.datasource.id,
props.datasource.type,
props.standalone,
props.force,
tabId,
],
);
const handlePopstate = useCallback(() => {
const formData = window.history.state;
if (formData && Object.keys(formData).length) {
props.actions.setExploreControls(formData);
props.actions.postChartFormData(
formData,
props.force,
props.timeout,
props.chart.id,
);
}
}, [props.actions, props.chart.id, props.timeout]);
const onQuery = useCallback(() => {
props.actions.setForceQuery(false);
props.actions.triggerQuery(true, props.chart.id);
addHistory();
setLastQueriedControls(props.controls);
}, [props.controls, addHistory, props.actions, props.chart.id]);
const handleKeydown = useCallback(
event => {
const controlOrCommand = event.ctrlKey || event.metaKey;
if (controlOrCommand) {
const isEnter = event.key === 'Enter' || event.keyCode === 13;
const isS = event.key === 's' || event.keyCode === 83;
if (isEnter) {
onQuery();
} else if (isS) {
if (props.slice) {
props.actions
.saveSlice(props.form_data, {
action: 'overwrite',
slice_id: props.slice.slice_id,
slice_name: props.slice.slice_name,
add_to_dash: 'noSave',
goto_dash: false,
})
.then(({ data }) => {
window.location = data.slice.slice_url;
});
}
}
}
},
[onQuery, props.actions, props.form_data, props.slice],
);
function onStop() {
if (props.chart && props.chart.queryController) {
props.chart.queryController.abort();
}
}
function toggleCollapse() {
setIsCollapsed(!isCollapsed);
}
useComponentDidMount(() => {
props.actions.logEvent(LOG_ACTIONS_MOUNT_EXPLORER);
});
useChangeEffect(tabId, (previous, current) => {
if (current) {
addHistory({ isReplace: true });
}
});
const previousHandlePopstate = usePrevious(handlePopstate);
useEffect(() => {
if (previousHandlePopstate) {
window.removeEventListener('popstate', previousHandlePopstate);
}
window.addEventListener('popstate', handlePopstate);
return () => {
window.removeEventListener('popstate', handlePopstate);
};
}, [handlePopstate, previousHandlePopstate]);
const previousHandleKeyDown = usePrevious(handleKeydown);
useEffect(() => {
if (previousHandleKeyDown) {
window.removeEventListener('keydown', previousHandleKeyDown);
}
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [handleKeydown, previousHandleKeyDown]);
useEffect(() => {
if (wasDynamicPluginLoading && !isDynamicPluginLoading) {
// reload the controls now that we actually have the control config
props.actions.dynamicPluginControlsReady();
}
}, [isDynamicPluginLoading]);
useEffect(() => {
const hasError = Object.values(props.controls).some(
control =>
control.validationErrors && control.validationErrors.length > 0,
);
if (!hasError) {
props.actions.triggerQuery(true, props.chart.id);
}
}, []);
const reRenderChart = useCallback(
controlsChanged => {
const newQueryFormData = controlsChanged
? {
...props.chart.latestQueryFormData,
...getFormDataFromControls(pick(props.controls, controlsChanged)),
}
: getFormDataFromControls(props.controls);
props.actions.updateQueryFormData(newQueryFormData, props.chart.id);
props.actions.renderTriggered(new Date().getTime(), props.chart.id);
addHistory();
},
[
addHistory,
props.actions,
props.chart.id,
props.chart.latestQueryFormData,
props.controls,
],
);
// effect to run when controls change
useEffect(() => {
if (
previousControls &&
props.chart.latestQueryFormData.viz_type === props.controls.viz_type.value
) {
if (
props.controls.datasource &&
(previousControls.datasource == null ||
props.controls.datasource.value !== previousControls.datasource.value)
) {
// this should really be handled by actions
fetchDatasourceMetadata(props.form_data.datasource, true);
}
const changedControlKeys = Object.keys(props.controls).filter(
key =>
typeof previousControls[key] !== 'undefined' &&
!areObjectsEqual(
props.controls[key].value,
previousControls[key].value,
),
);
// this should also be handled by the actions that are actually changing the controls
const displayControlsChanged = changedControlKeys.filter(
key => props.controls[key].renderTrigger,
);
if (displayControlsChanged.length > 0) {
reRenderChart(displayControlsChanged);
}
}
}, [props.controls, props.ownState]);
const chartIsStale = useMemo(() => {
if (lastQueriedControls) {
const changedControlKeys = Object.keys(props.controls).filter(
key =>
typeof lastQueriedControls[key] !== 'undefined' &&
!areObjectsEqual(
props.controls[key].value,
lastQueriedControls[key].value,
{ ignoreFields: ['datasourceWarning'] },
),
);
return changedControlKeys.some(
key =>
!props.controls[key].renderTrigger &&
!props.controls[key].dontRefreshOnChange,
);
}
return false;
}, [lastQueriedControls, props.controls]);
useChangeEffect(props.saveAction, () => {
if (['saveas', 'overwrite'].includes(props.saveAction)) {
onQuery();
addHistory({ isReplace: true });
props.actions.setSaveAction(null);
}
});
useEffect(() => {
if (props.ownState !== undefined) {
onQuery();
reRenderChart();
}
}, [props.ownState]);
if (chartIsStale) {
props.actions.logEvent(LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS);
}
const errorMessage = useMemo(() => {
const controlsWithErrors = Object.values(props.controls).filter(
control =>
control.validationErrors && control.validationErrors.length > 0,
);
if (controlsWithErrors.length === 0) {
return null;
}
const errorMessages = controlsWithErrors.map(
control => control.validationErrors,
);
const uniqueErrorMessages = [...new Set(errorMessages.flat())];
const errors = uniqueErrorMessages
.map(message => {
const matchingLabels = controlsWithErrors
.filter(control => control.validationErrors?.includes(message))
.map(control => control.label);
return [matchingLabels, message];
})
.map(([labels, message]) => (
<div key={message}>
{labels.length > 1 ? t('Controls labeled ') : t('Control labeled ')}
<strong>{` ${labels.join(', ')}`}</strong>
<span>: {message}</span>
</div>
));
let errorMessage;
if (errors.length > 0) {
errorMessage = <div style={{ textAlign: 'left' }}>{errors}</div>;
}
return errorMessage;
}, [props.controls]);
function renderChartContainer() {
return (
<ExploreChartPanel
{...props}
errorMessage={errorMessage}
chartIsStale={chartIsStale}
onQuery={onQuery}
/>
);
}
if (props.standalone) {
return renderChartContainer();
}
return (
<ExploreContainer>
<ConnectedExploreChartHeader
actions={props.actions}
canOverwrite={props.can_overwrite}
canDownload={props.can_download}
dashboardId={props.dashboardId}
isStarred={props.isStarred}
slice={props.slice}
sliceName={props.sliceName}
table_name={props.table_name}
formData={props.form_data}
chart={props.chart}
ownState={props.ownState}
user={props.user}
reports={props.reports}
saveDisabled={errorMessage || props.chart.chartStatus === 'loading'}
metadata={props.metadata}
/>
<ExplorePanelContainer id="explore-container">
<Global
styles={css`
.navbar {
margin-bottom: 0;
}
body {
height: 100vh;
max-height: 100vh;
overflow: hidden;
}
#app-menu,
#app {
flex: 1 1 auto;
}
#app {
flex-basis: 100%;
overflow: hidden;
height: 100%;
}
#app-menu {
flex-shrink: 0;
}
`}
/>
<Resizable
onResizeStop={(evt, direction, ref, d) => {
setWidth(ref.getBoundingClientRect().width);
setSidebarWidths(LocalStorageKeys.DatasourceWidth, d);
}}
defaultSize={{
width: getSidebarWidths(LocalStorageKeys.DatasourceWidth),
height: '100%',
}}
minWidth={defaultSidebarsWidth[LocalStorageKeys.DatasourceWidth]}
maxWidth="33%"
enable={{ right: true }}
className={
isCollapsed ? 'no-show' : 'explore-column data-source-selection'
}
>
<div className="title-container">
<span className="horizontal-text">{t('Chart Source')}</span>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={toggleCollapse}
>
<Icons.Expand
className="collapse-icon"
iconColor={theme.colors.primary.base}
iconSize="l"
/>
</span>
</div>
<DataSourcePanel
formData={props.form_data}
datasource={props.datasource}
controls={props.controls}
actions={props.actions}
width={width}
user={props.user}
/>
</Resizable>
{isCollapsed ? (
<div
className="sidebar"
onClick={toggleCollapse}
data-test="open-datasource-tab"
role="button"
tabIndex={0}
>
<span role="button" tabIndex={0} className="action-button">
<Tooltip title={t('Open Datasource tab')}>
<Icons.Collapse
className="collapse-icon"
iconColor={theme.colors.primary.base}
iconSize="l"
/>
</Tooltip>
</span>
</div>
) : null}
<Resizable
onResizeStop={(evt, direction, ref, d) =>
setSidebarWidths(LocalStorageKeys.ControlsWidth, d)
}
defaultSize={{
width: getSidebarWidths(LocalStorageKeys.ControlsWidth),
height: '100%',
}}
minWidth={defaultSidebarsWidth[LocalStorageKeys.ControlsWidth]}
maxWidth="33%"
enable={{ right: true }}
className="col-sm-3 explore-column controls-column"
>
<ConnectedControlPanelsContainer
exploreState={props.exploreState}
actions={props.actions}
form_data={props.form_data}
controls={props.controls}
chart={props.chart}
datasource_type={props.datasource_type}
isDatasourceMetaLoading={props.isDatasourceMetaLoading}
onQuery={onQuery}
onStop={onStop}
canStopQuery={props.can_add || props.can_overwrite}
errorMessage={errorMessage}
chartIsStale={chartIsStale}
/>
</Resizable>
<div
className={cx(
'main-explore-content',
isCollapsed ? 'col-sm-9' : 'col-sm-7',
)}
>
{renderChartContainer()}
</div>
</ExplorePanelContainer>
{props.isSaveModalVisible && (
<SaveModal
addDangerToast={props.addDangerToast}
actions={props.actions}
form_data={props.form_data}
sliceName={props.sliceName}
dashboardId={props.dashboardId}
/>
)}
</ExploreContainer>
);
}
ExploreViewContainer.propTypes = propTypes;
function mapStateToProps(state) {
const {
explore,
charts,
common,
impressionId,
dataMask,
reports,
user,
saveModal,
} = state;
const { controls, slice, datasource, metadata, hiddenFormData } = explore;
const form_data = omit(
getFormDataFromControls(controls),
Object.keys(hiddenFormData ?? {}),
);
const slice_id = form_data.slice_id ?? slice?.slice_id ?? 0; // 0 - unsaved chart
form_data.extra_form_data = mergeExtraFormData(
{ ...form_data.extra_form_data },
{
...dataMask[slice_id]?.ownState,
},
);
const chart = charts[slice_id];
let dashboardId = Number(explore.form_data?.dashboardId);
if (Number.isNaN(dashboardId)) {
dashboardId = undefined;
}
return {
isDatasourceMetaLoading: explore.isDatasourceMetaLoading,
datasource,
datasource_type: datasource.type,
datasourceId: datasource.datasource_id,
dashboardId,
controls: explore.controls,
can_add: !!explore.can_add,
can_download: !!explore.can_download,
can_overwrite: !!explore.can_overwrite,
column_formats: datasource?.column_formats ?? null,
containerId: slice
? `slice-container-${slice.slice_id}`
: 'slice-container',
isStarred: explore.isStarred,
slice,
sliceName: explore.sliceName ?? slice?.slice_name ?? null,
triggerRender: explore.triggerRender,
form_data,
table_name: datasource.table_name,
vizType: form_data.viz_type,
standalone: !!explore.standalone,
force: !!explore.force,
chart,
timeout: common.conf.SUPERSET_WEBSERVER_TIMEOUT,
ownState: dataMask[slice_id]?.ownState,
impressionId,
user,
exploreState: explore,
reports,
metadata,
saveAction: explore.saveAction,
isSaveModalVisible: saveModal.isVisible,
};
}
function mapDispatchToProps(dispatch) {
const actions = {
...exploreActions,
...datasourcesActions,
...saveModalActions,
...chartActions,
...logActions,
};
return {
actions: bindActionCreators(actions, dispatch),
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(withToasts(React.memo(ExploreViewContainer)));