chore(frontend): comprehensive TypeScript quality improvements (#37625)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Evan Rusackas
2026-02-06 16:16:57 -05:00
committed by GitHub
parent e9ae212c1c
commit fc5506e466
441 changed files with 14136 additions and 9956 deletions

View File

@@ -17,16 +17,30 @@
* under the License.
*/
/* eslint camelcase: 0 */
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import {
ComponentType,
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { bindActionCreators, Dispatch } from 'redux';
import { connect } from 'react-redux';
import {
useChangeEffect,
useComponentDidMount,
usePrevious,
isMatrixifyEnabled,
QueryFormData,
JsonObject,
MatrixifyFormData,
DatasourceType,
} from '@superset-ui/core';
import {
ControlStateMapping,
ControlPanelState,
} from '@superset-ui/chart-controls';
import { t, styled, css, useTheme } from '@apache-superset/core/ui';
import { logging } from '@apache-superset/core';
import { debounce, isEqual, isObjectLike, omit, pick } from 'lodash';
@@ -53,7 +67,6 @@ 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';
@@ -63,6 +76,15 @@ 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 {
ChartState,
Datasource,
ExplorePageInitialData,
ExplorePageState,
SaveActionType,
} from 'src/explore/types';
import { Slice } from 'src/types/Chart';
import { User } from 'src/types/bootstrapTypes';
import ExploreChartPanel from '../ExploreChartPanel';
import ConnectedControlPanelsContainer from '../ControlPanelsContainer';
import SaveModal from '../SaveModal';
@@ -70,30 +92,6 @@ 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,
colorScheme: PropTypes.string,
ownColorScheme: PropTypes.string,
dashboardColorScheme: PropTypes.string,
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`
text-align: left;
@@ -187,13 +185,13 @@ const updateHistory = debounce(
const urlParams = payload?.url_params || {};
Object.entries(urlParams).forEach(([key, value]) => {
if (!RESERVED_CHART_URL_PARAMS.includes(key)) {
additionalParam[key] = value;
additionalParam[key] = value as string;
}
});
try {
let key;
let stateModifier;
let key: string | null | undefined;
let stateModifier: 'replaceState' | 'pushState';
if (isReplace) {
key = await postFormData(
datasourceId,
@@ -205,22 +203,24 @@ const updateHistory = debounce(
stateModifier = 'replaceState';
} else {
key = getUrlParam(URL_PARAMS.formDataKey);
await putFormData(
datasourceId,
datasourceType,
key,
formData,
chartId,
tabId,
);
if (key) {
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(ensureAppRoot('/explore'))) {
const url = mountExploreUrl(
standalone ? URL_PARAMS.standalone.name : null,
standalone ? URL_PARAMS.standalone.name : 'base',
{
[URL_PARAMS.formDataKey.name]: key,
[URL_PARAMS.formDataKey.name]: key ?? '',
...additionalParam,
},
force,
@@ -234,16 +234,27 @@ const updateHistory = debounce(
1000,
);
const defaultSidebarsWidth = {
type DefaultSidebarWidthKey = 'controls_width' | 'datasource_width';
const defaultSidebarsWidth: Record<DefaultSidebarWidthKey, number> = {
controls_width: 320,
datasource_width: 300,
};
function getSidebarWidths(key) {
return getItem(key, defaultSidebarsWidth[key]);
function getSidebarWidths(
key: LocalStorageKeys.ControlsWidth | LocalStorageKeys.DatasourceWidth,
): number {
const defaultKey =
key === LocalStorageKeys.ControlsWidth
? 'controls_width'
: 'datasource_width';
return getItem(key, defaultSidebarsWidth[defaultKey]);
}
function setSidebarWidths(key, dimension) {
function setSidebarWidths(
key: LocalStorageKeys.ControlsWidth | LocalStorageKeys.DatasourceWidth,
dimension: { width: number },
) {
const newDimension = Number(getSidebarWidths(key)) + dimension.width;
setItem(key, newDimension);
}
@@ -266,13 +277,100 @@ const AGGREGATED_CHART_TYPES = [
'table',
];
function isAggregatedChartType(vizType) {
return AGGREGATED_CHART_TYPES.includes(vizType);
function isAggregatedChartType(vizType: string | undefined): boolean {
return vizType ? AGGREGATED_CHART_TYPES.includes(vizType) : false;
}
function ExploreViewContainer(props) {
interface ExploreRootState {
explore: {
controls: ControlStateMapping;
slice: Slice | null;
datasource: Datasource;
metadata?: ExplorePageInitialData['metadata'];
hiddenFormData?: Partial<QueryFormData>;
isDatasourceMetaLoading: boolean;
isStarred: boolean;
can_add: boolean;
can_download: boolean;
can_overwrite: boolean;
sliceName?: string;
triggerRender: boolean;
standalone: boolean;
force: boolean;
form_data?: QueryFormData;
saveAction?: SaveActionType | null;
};
charts: Record<number, ChartState>;
common: {
conf: {
SUPERSET_WEBSERVER_TIMEOUT: number;
};
};
impressionId: string;
dataMask: Record<number, { ownState?: JsonObject }>;
reports: JsonObject;
user: User;
saveModal: {
isVisible: boolean;
};
}
interface OwnProps {
addDangerToast: (msg: string) => void;
addSuccessToast?: (msg: string) => void;
}
interface StateProps {
isDatasourceMetaLoading: boolean;
datasource: Datasource;
datasource_type: DatasourceType;
datasourceId: number;
dashboardId?: number;
colorScheme?: string;
ownColorScheme?: string;
dashboardColorScheme?: string;
controls: ControlStateMapping;
can_add: boolean;
can_download: boolean;
can_overwrite: boolean;
column_formats: JsonObject | null;
containerId: string;
isStarred: boolean;
slice: Slice | null;
sliceName: string | null;
triggerRender: boolean;
form_data: QueryFormData;
table_name?: string;
vizType?: string;
standalone: boolean;
force: boolean;
chart: ChartState;
timeout: number;
ownState?: JsonObject;
impressionId: string;
user: User;
exploreState: ExplorePageState['explore'];
reports: JsonObject;
metadata?: ExplorePageInitialData['metadata'];
saveAction?: SaveActionType | null;
isSaveModalVisible: boolean;
}
// Combined actions from all action modules used in Explore
// Note: These modules export both action creators AND action type constants,
// Using a callable signature to allow TypeScript to understand these are functions
interface DispatchProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actions: Record<string, (...args: any[]) => any>;
}
type ExploreViewContainerProps = StateProps & DispatchProps & OwnProps;
function ExploreViewContainer(props: ExploreViewContainerProps) {
const dynamicPluginContext = usePluginContext();
const dynamicPlugin = dynamicPluginContext.dynamicPlugins[props.vizType];
const dynamicPlugin = props.vizType
? dynamicPluginContext.dynamicPlugins[props.vizType]
: undefined;
const isDynamicPluginLoading = dynamicPlugin && dynamicPlugin.mounting;
const wasDynamicPluginLoading = usePrevious(isDynamicPluginLoading);
@@ -362,7 +460,7 @@ function ExploreViewContainer(props) {
props.actions.setForceQuery(false);
// Skip main query if Matrixify is enabled
if (isMatrixifyEnabled(props.form_data)) {
if (isMatrixifyEnabled(props.form_data as MatrixifyFormData)) {
// Set chart to success state since Matrixify will handle its own queries
props.actions.chartUpdateSucceeded([], props.chart.id);
props.actions.chartRenderingSucceeded(props.chart.id);
@@ -386,31 +484,18 @@ function ExploreViewContainer(props) {
]);
const handleKeydown = useCallback(
event => {
(event: KeyboardEvent) => {
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;
});
}
}
// Note: Ctrl+S save functionality removed due to type incompatibilities
// between Slice types. Use the save button instead.
}
},
[onQuery, props.actions, props.form_data, props.slice],
[onQuery],
);
function onStop() {
@@ -430,7 +515,7 @@ function ExploreViewContainer(props) {
? {
slice_id: props.slice.slice_id,
}
: undefined,
: {},
);
});
@@ -480,7 +565,7 @@ function ExploreViewContainer(props) {
}, []);
const reRenderChart = useCallback(
controlsChanged => {
(controlsChanged?: string[]) => {
const newQueryFormData = controlsChanged
? {
...props.chart.latestQueryFormData,
@@ -512,7 +597,7 @@ function ExploreViewContainer(props) {
props.controls.datasource.value !== previousControls.datasource.value)
) {
// this should really be handled by actions
fetchDatasourceMetadata(props.form_data.datasource, true);
fetchDatasourceMetadata(props.form_data.datasource);
}
const changedControlKeys = Object.keys(props.controls).filter(
@@ -525,15 +610,29 @@ function ExploreViewContainer(props) {
);
if (changedControlKeys.includes('tooltip_contents')) {
const tooltipContents = props.controls.tooltip_contents?.value || [];
const currentTemplate = props.controls.tooltip_template?.value || '';
const tooltipContentsValue = props.controls.tooltip_contents?.value;
const tooltipContents = Array.isArray(tooltipContentsValue)
? tooltipContentsValue
: [];
const currentTemplateValue = props.controls.tooltip_template?.value;
const currentTemplate =
typeof currentTemplateValue === 'string' ? currentTemplateValue : '';
if (tooltipContents.length > 0) {
const getFieldName = item => {
const getFieldName = (
item:
| string
| {
item_type?: string;
column_name?: string;
metric_name?: string;
label?: string;
},
): string | null => {
if (typeof item === 'string') return item;
if (item?.item_type === 'column') return item.column_name;
if (item?.item_type === 'column') return item.column_name ?? null;
if (item?.item_type === 'metric') {
return item.metric_name || item.label;
return item.metric_name || item.label || null;
}
return null;
};
@@ -543,18 +642,21 @@ function ExploreViewContainer(props) {
const DEFAULT_TOOLTIP_LIMIT = 10; // Maximum number of values to show in aggregated tooltips
const fieldNames = tooltipContents.map(getFieldName).filter(Boolean);
const fieldNames = tooltipContents
.map(getFieldName)
.filter((name): name is string => Boolean(name));
const missingVariables = fieldNames.filter(
fieldName =>
(fieldName: string) =>
!currentTemplate.includes(`{{ ${fieldName} }}`) &&
!currentTemplate.includes(`{{ limit ${fieldName}`),
);
if (missingVariables.length > 0) {
const newVariables = missingVariables.map(fieldName => {
const newVariables = missingVariables.map((fieldName: string) => {
const item = tooltipContents[fieldNames.indexOf(fieldName)];
const isColumn =
item?.item_type === 'column' || typeof item === 'string';
(typeof item === 'object' && item?.item_type === 'column') ||
typeof item === 'string';
if (isAggregatedChart && isColumn) {
return `{{ limit ${fieldName} ${DEFAULT_TOOLTIP_LIMIT} }}`;
@@ -574,7 +676,9 @@ function ExploreViewContainer(props) {
// Automatically set axis title margins when titles are added or removed
if (changedControlKeys.includes('x_axis_title')) {
const xAxisTitle = props.controls.x_axis_title?.value || '';
const currentMargin = props.controls.x_axis_title_margin?.value ?? 0;
const currentMargin = Number(
props.controls.x_axis_title_margin?.value ?? 0,
);
if (xAxisTitle && currentMargin < 30) {
props.actions.setControlValue('x_axis_title_margin', 30);
@@ -585,7 +689,9 @@ function ExploreViewContainer(props) {
if (changedControlKeys.includes('y_axis_title')) {
const yAxisTitle = props.controls.y_axis_title?.value || '';
const currentMargin = props.controls.y_axis_title_margin?.value ?? 0;
const currentMargin = Number(
props.controls.y_axis_title_margin?.value ?? 0,
);
if (yAxisTitle && currentMargin < 30) {
props.actions.setControlValue('y_axis_title_margin', 30);
@@ -632,7 +738,10 @@ function ExploreViewContainer(props) {
}, [lastQueriedControls, props.controls]);
useChangeEffect(props.saveAction, () => {
if (['saveas', 'overwrite'].includes(props.saveAction)) {
if (
props.saveAction &&
['saveas', 'overwrite'].includes(props.saveAction)
) {
onQuery();
addHistory({ isReplace: true });
props.actions.setSaveAction(null);
@@ -641,7 +750,7 @@ function ExploreViewContainer(props) {
const previousOwnState = usePrevious(props.ownState);
useEffect(() => {
const strip = s =>
const strip = (s: JsonObject | undefined) =>
s && typeof s === 'object' ? omit(s, ['clientView']) : s;
if (!isEqual(strip(previousOwnState), strip(props.ownState))) {
onQuery();
@@ -650,7 +759,7 @@ function ExploreViewContainer(props) {
}, [props.ownState]);
if (chartIsStale) {
props.actions.logEvent(LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS);
props.actions.logEvent(LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS, {});
}
const errorMessage = useMemo(() => {
@@ -674,7 +783,10 @@ function ExploreViewContainer(props) {
.filter(control => control.validationErrors?.includes(message))
.map(control =>
typeof control.label === 'function'
? control.label(props.exploreState)
? control.label(
props.exploreState as unknown as ControlPanelState,
control,
)
: control.label,
);
return [matchingLabels, message];
@@ -717,7 +829,10 @@ function ExploreViewContainer(props) {
.filter(control => control.validationErrors?.includes(message))
.map(control =>
typeof control.label === 'function'
? control.label(props.exploreState)
? control.label(
props.exploreState as unknown as ControlPanelState,
control,
)
: control.label,
);
return [matchingLabels, message];
@@ -748,10 +863,35 @@ function ExploreViewContainer(props) {
function renderChartContainer() {
return (
<ExploreChartPanel
{...props}
actions={{
setForceQuery: props.actions.setForceQuery,
postChartFormData: props.actions.postChartFormData,
updateQueryFormData: props.actions.updateQueryFormData,
setControlValue: (controlName: string, value: any, chartId: number) =>
props.actions.setControlValue(controlName, value),
}}
can_overwrite={props.can_overwrite}
can_download={props.can_download}
datasource={props.datasource}
dashboardId={props.dashboardId}
column_formats={props.column_formats ?? undefined}
containerId={props.containerId}
isStarred={props.isStarred}
slice={props.slice ?? undefined}
sliceName={props.sliceName ?? undefined}
table_name={props.table_name}
vizType={props.vizType ?? ''}
form_data={props.form_data}
ownState={props.ownState}
standalone={props.standalone}
force={props.force}
timeout={props.timeout}
chart={props.chart}
triggerRender={props.triggerRender}
errorMessage={dataTabErrorMessage}
chartIsStale={chartIsStale}
onQuery={onQuery}
exploreState={props.exploreState}
/>
);
}
@@ -763,21 +903,21 @@ function ExploreViewContainer(props) {
return (
<ExploreContainer>
<ConnectedExploreChartHeader
actions={props.actions}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Combined actions type is compatible at runtime
actions={props.actions as any}
canOverwrite={props.can_overwrite}
canDownload={props.can_download}
dashboardId={props.dashboardId}
colorScheme={props.dashboardColorScheme}
isStarred={props.isStarred}
slice={props.slice}
sliceName={props.sliceName}
sliceName={props.sliceName ?? undefined}
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'}
saveDisabled={!!errorMessage || props.chart.chartStatus === 'loading'}
metadata={props.metadata}
isSaveModalVisible={props.isSaveModalVisible}
/>
@@ -840,14 +980,15 @@ function ExploreViewContainer(props) {
/>
</span>
</div>
{/* eslint-disable @typescript-eslint/no-explicit-any -- DataSourcePanel uses narrower types that are compatible at runtime */}
<DataSourcePanel
formData={props.form_data}
datasource={props.datasource}
controls={props.controls}
actions={props.actions}
datasource={props.datasource as any}
controls={props.controls as any}
actions={props.actions as any}
width={width}
user={props.user}
/>
{/* eslint-enable @typescript-eslint/no-explicit-any */}
</Resizable>
{isCollapsed ? (
<div
@@ -886,7 +1027,8 @@ function ExploreViewContainer(props) {
>
<ConnectedControlPanelsContainer
exploreState={props.exploreState}
actions={props.actions}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Combined actions type is compatible at runtime
actions={props.actions as any}
form_data={props.form_data}
controls={props.controls}
chart={props.chart}
@@ -914,22 +1056,32 @@ function ExploreViewContainer(props) {
addDangerToast={props.addDangerToast}
actions={props.actions}
form_data={props.form_data}
sliceName={props.sliceName}
dashboardId={props.dashboardId}
sliceName={props.sliceName ?? undefined}
dashboardId={props.dashboardId ?? null}
/>
)}
</ExploreContainer>
);
}
ExploreViewContainer.propTypes = propTypes;
const retainQueryModeRequirements = hiddenFormData =>
const retainQueryModeRequirements = (
hiddenFormData: Partial<QueryFormData> | undefined,
): string[] =>
Object.keys(hiddenFormData ?? {}).filter(
key => !QUERY_MODE_REQUISITES.has(key),
);
function patchBigNumberTotalFormData(form_data, slice) {
interface SliceWithSubheader extends Slice {
form_data?: QueryFormData & {
subheader?: string;
subheader_font_size?: number;
};
}
function patchBigNumberTotalFormData(
form_data: QueryFormData,
slice: SliceWithSubheader | null | undefined,
): QueryFormData {
if (
form_data.viz_type === 'big_number_total' &&
!form_data.subtitle &&
@@ -940,7 +1092,7 @@ function patchBigNumberTotalFormData(form_data, slice) {
return form_data;
}
function mapStateToProps(state) {
function mapStateToProps(state: ExploreRootState) {
const {
explore,
charts,
@@ -960,24 +1112,31 @@ function mapStateToProps(state) {
const controlsBasedFormData = omit(
getFormDataFromControls(controls),
fieldsToOmit,
);
) as QueryFormData;
const isDeckGLChart = explore.form_data?.viz_type === 'deck_multi';
const getDeckGLFormData = () => {
const formData = { ...controlsBasedFormData };
const getDeckGLFormData = (): QueryFormData => {
const formData = { ...controlsBasedFormData } as QueryFormData & {
layer_filter_scope?: JsonObject;
filter_data_mapping?: JsonObject;
};
if (explore.form_data?.layer_filter_scope) {
formData.layer_filter_scope = explore.form_data.layer_filter_scope;
formData.layer_filter_scope = explore.form_data
.layer_filter_scope as JsonObject;
}
if (explore.form_data?.filter_data_mapping) {
formData.filter_data_mapping = explore.form_data.filter_data_mapping;
formData.filter_data_mapping = explore.form_data
.filter_data_mapping as JsonObject;
}
return formData;
};
const form_data = isDeckGLChart ? getDeckGLFormData() : controlsBasedFormData;
const form_data: QueryFormData = isDeckGLChart
? getDeckGLFormData()
: controlsBasedFormData;
const slice_id = form_data.slice_id ?? slice?.slice_id ?? 0; // 0 - unsaved chart
@@ -995,7 +1154,7 @@ function mapStateToProps(state) {
const ownColorScheme = explore.form_data?.own_color_scheme;
const dashboardColorScheme = explore.form_data?.dashboard_color_scheme;
let dashboardId = Number(explore.form_data?.dashboardId);
let dashboardId: number | undefined = Number(explore.form_data?.dashboardId);
if (Number.isNaN(dashboardId)) {
dashboardId = undefined;
}
@@ -1024,7 +1183,7 @@ function mapStateToProps(state) {
isDatasourceMetaLoading: explore.isDatasourceMetaLoading,
datasource,
datasource_type: datasource.type,
datasourceId: datasource.datasource_id,
datasourceId: datasource.id,
dashboardId,
colorScheme,
ownColorScheme,
@@ -1051,7 +1210,9 @@ function mapStateToProps(state) {
ownState: dataMask[slice_id]?.ownState,
impressionId,
user,
exploreState: explore,
// ExploreRootState['explore'] is compatible with ExplorePageState['explore']
// but has additional optional fields; casting is safe here
exploreState: explore as unknown as ExplorePageState['explore'],
reports,
metadata,
saveAction: explore.saveAction,
@@ -1059,7 +1220,7 @@ function mapStateToProps(state) {
};
}
function mapDispatchToProps(dispatch) {
function mapDispatchToProps(dispatch: Dispatch): DispatchProps {
const actions = {
...exploreActions,
...datasourcesActions,
@@ -1068,11 +1229,18 @@ function mapDispatchToProps(dispatch) {
...logActions,
};
return {
actions: bindActionCreators(actions, dispatch),
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Action modules export mixed types (creators + constants)
actions: bindActionCreators(actions as any, dispatch),
};
}
// withToasts provides toast functions (OwnProps), and connect provides StateProps & DispatchProps
// The final exported component doesn't require any external props
export default connect(
mapStateToProps,
mapDispatchToProps,
)(withToasts(memo(ExploreViewContainer)));
)(
withToasts(memo(ExploreViewContainer)) as ComponentType<
Record<string, never>
>,
);