chore(frontend): migrate SqlLab and explore JS/JSX files to TypeScript (#36760)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Evan Rusackas
2026-01-06 10:52:58 -08:00
committed by GitHub
parent aaa174f820
commit 9aff89c1b4
69 changed files with 3272 additions and 1482 deletions

View File

@@ -203,14 +203,14 @@ describe('AdhocMetric', () => {
aggregate: AGGREGATES.SUM,
});
expect(adhocMetric2.aggregate).toBe(AGGREGATES.SUM);
expect(adhocMetric2.column.column_name).toBe('my_column');
expect(adhocMetric2.column?.column_name).toBe('my_column');
const adhocMetric3 = adhocMetric.duplicateWith({
expressionType: EXPRESSION_TYPES.SIMPLE,
column: valueColumn,
});
expect(adhocMetric3.aggregate).toBe(AGGREGATES.AVG);
expect(adhocMetric3.column.column_name).toBe('value');
expect(adhocMetric3.column?.column_name).toBe('value');
});
test('should transform count_distinct SQL and do not change label if does not set metric label', () => {

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { AdhocMetric as CoreAdhocMetric } from '@superset-ui/core';
import {
sqlaAutoGeneratedMetricRegex,
AGGREGATES,
@@ -26,7 +27,33 @@ export const EXPRESSION_TYPES = {
SQL: 'SQL',
};
function inferSqlExpressionColumn(adhocMetric) {
interface ColumnType {
column_name: string;
verbose_name?: string;
// Allow additional properties from ColumnMeta and other column types
[key: string]: unknown;
}
interface AdhocMetricInput {
expressionType?: string;
column?: ColumnType | null;
aggregate?: string | null;
sqlExpression?: string | null;
datasourceWarning?: boolean;
hasCustomLabel?: boolean;
label?: string;
optionName?: string;
// Additional properties that may be passed in
metric_name?: string;
expression?: string;
error_text?: string;
uuid?: string;
[key: string]: unknown;
}
function inferSqlExpressionColumn(
adhocMetric: AdhocMetricInput,
): string | null {
if (
adhocMetric.sqlExpression &&
sqlaAutoGeneratedMetricRegex.test(adhocMetric.sqlExpression)
@@ -45,7 +72,9 @@ function inferSqlExpressionColumn(adhocMetric) {
return null;
}
function inferSqlExpressionAggregate(adhocMetric) {
function inferSqlExpressionAggregate(
adhocMetric: AdhocMetricInput,
): string | null {
if (
adhocMetric.sqlExpression &&
sqlaAutoGeneratedMetricRegex.test(adhocMetric.sqlExpression)
@@ -58,15 +87,51 @@ function inferSqlExpressionAggregate(adhocMetric) {
return null;
}
/**
* Adapter function to create an AdhocMetric instance from a core AdhocMetric type.
* This bridges the type gap between @superset-ui/core's AdhocMetric and the local class.
*/
export function fromCoreAdhocMetric(metric: CoreAdhocMetric): AdhocMetric {
return new AdhocMetric(metric as AdhocMetricInput);
}
/**
* Type guard to check if an object can be used to construct an AdhocMetric.
* Returns true for plain objects that have metric-like properties.
*/
export function isDictionaryForAdhocMetric(
value: unknown,
): value is AdhocMetricInput {
return (
typeof value === 'object' &&
value !== null &&
!(value instanceof AdhocMetric) &&
('expressionType' in value ||
'column' in value ||
'aggregate' in value ||
'sqlExpression' in value ||
'metric_name' in value)
);
}
export default class AdhocMetric {
constructor(adhocMetric) {
expressionType: string;
column?: ColumnType | null;
aggregate?: string | null;
sqlExpression?: string | null;
datasourceWarning: boolean;
hasCustomLabel: boolean;
label: string;
optionName: string;
constructor(adhocMetric: AdhocMetricInput) {
this.expressionType = adhocMetric.expressionType || EXPRESSION_TYPES.SIMPLE;
if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
// try to be clever in the case of transitioning from Sql expression back to simple expression
const inferredColumn = inferSqlExpressionColumn(adhocMetric);
this.column =
adhocMetric.column ||
(inferredColumn && { column_name: inferredColumn });
adhocMetric.column ??
(inferredColumn ? { column_name: inferredColumn } : null);
this.aggregate =
adhocMetric.aggregate || inferSqlExpressionAggregate(adhocMetric);
this.sqlExpression = null;
@@ -78,7 +143,7 @@ export default class AdhocMetric {
this.datasourceWarning = !!adhocMetric.datasourceWarning;
this.hasCustomLabel = !!(adhocMetric.hasCustomLabel && adhocMetric.label);
this.label = this.hasCustomLabel
? adhocMetric.label
? (adhocMetric.label ?? this.getDefaultLabel())
: this.getDefaultLabel();
this.optionName =
@@ -88,13 +153,16 @@ export default class AdhocMetric {
.substring(2, 15)}`;
}
getDefaultLabel() {
getDefaultLabel(): string {
return this.translateToSql({ useVerboseName: true });
}
translateToSql(
params = { useVerboseName: false, transformCountDistinct: false },
) {
params: { useVerboseName?: boolean; transformCountDistinct?: boolean } = {
useVerboseName: false,
transformCountDistinct: false,
},
): string {
if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
const aggregate = this.aggregate || '';
// eslint-disable-next-line camelcase
@@ -115,19 +183,19 @@ export default class AdhocMetric {
return aggregate + column;
}
if (this.expressionType === EXPRESSION_TYPES.SQL) {
return this.sqlExpression;
return this.sqlExpression ?? '';
}
return '';
}
duplicateWith(nextFields) {
duplicateWith(nextFields: Partial<AdhocMetricInput>): AdhocMetric {
return new AdhocMetric({
...this,
...nextFields,
});
}
equals(adhocMetric) {
equals(adhocMetric: AdhocMetric): boolean {
return (
adhocMetric.label === this.label &&
adhocMetric.expressionType === this.expressionType &&
@@ -138,7 +206,7 @@ export default class AdhocMetric {
);
}
isValid() {
isValid(): boolean {
if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
return !!(this.column && this.aggregate);
}
@@ -148,11 +216,11 @@ export default class AdhocMetric {
return false;
}
inferSqlExpressionAggregate() {
return inferSqlExpressionAggregate(this);
inferSqlExpressionAggregate(): string | null {
return inferSqlExpressionAggregate(this as unknown as AdhocMetricInput);
}
inferSqlExpressionColumn() {
return inferSqlExpressionColumn(this);
inferSqlExpressionColumn(): string | null {
return inferSqlExpressionColumn(this as unknown as AdhocMetricInput);
}
}

View File

@@ -49,6 +49,60 @@ import {
} from 'src/explore/components/optionRenderers';
import { getColumnKeywords } from 'src/explore/controlUtils/getColumnKeywords';
import SQLEditorWithValidation from 'src/components/SQLEditorWithValidation';
import type { RefObject } from 'react';
interface ColumnType {
column_name: string;
verbose_name?: string;
[key: string]: unknown;
}
interface SavedMetricType {
metric_name: string;
verbose_name?: string;
expression?: string;
[key: string]: unknown;
}
interface DatasourceInfo {
type?: DatasourceType | string;
id?: number | string;
extra?: string;
[key: string]: unknown;
}
interface ExtraConfig {
disallow_adhoc_metrics?: boolean;
[key: string]: unknown;
}
type Metric = AdhocMetric | SavedMetricType;
interface AdhocMetricEditPopoverProps {
onChange: (newMetric: Metric, oldMetric?: Metric) => void;
onClose: () => void;
onResize: () => void;
getCurrentTab?: (tab: string) => void;
getCurrentLabel?: (labels: {
savedMetricLabel?: string;
adhocMetricLabel?: string;
}) => void;
handleDatasetModal?: (open: boolean) => void;
adhocMetric: AdhocMetric;
columns?: ColumnType[];
savedMetricsOptions?: SavedMetricType[];
savedMetric?: SavedMetricType;
datasource?: DatasourceInfo;
isNewMetric?: boolean;
isLabelModified?: boolean;
}
interface AdhocMetricEditPopoverState {
adhocMetric: AdhocMetric;
savedMetric?: SavedMetricType;
width: number;
height: number;
}
const propTypes = {
onChange: PropTypes.func.isRequired,
@@ -85,11 +139,24 @@ const StyledSelect = styled(Select)`
export const SAVED_TAB_KEY = 'SAVED';
export default class AdhocMetricEditPopover extends PureComponent {
export default class AdhocMetricEditPopover extends PureComponent<
AdhocMetricEditPopoverProps,
AdhocMetricEditPopoverState
> {
// "Saved" is a default tab unless there are no saved metrics for dataset
defaultActiveTabKey = this.getDefaultTab();
constructor(props) {
aceEditorRef: RefObject<HTMLDivElement>;
dragStartX = 0;
dragStartY = 0;
dragStartWidth = 0;
dragStartHeight = 0;
constructor(props: AdhocMetricEditPopoverProps) {
super(props);
this.onSave = this.onSave.bind(this);
this.onResetStateAndClose = this.onResetStateAndClose.bind(this);
@@ -115,10 +182,13 @@ export default class AdhocMetricEditPopover extends PureComponent {
}
componentDidMount() {
this.props.getCurrentTab(this.defaultActiveTabKey);
this.props.getCurrentTab?.(this.defaultActiveTabKey);
}
componentDidUpdate(prevProps, prevState) {
componentDidUpdate(
_prevProps: AdhocMetricEditPopoverProps,
prevState: AdhocMetricEditPopoverState,
) {
if (
prevState.adhocMetric?.sqlExpression !==
this.state.adhocMetric?.sqlExpression ||
@@ -127,7 +197,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
this.state.adhocMetric?.column?.column_name ||
prevState.savedMetric?.metric_name !== this.state.savedMetric?.metric_name
) {
this.props.getCurrentLabel({
this.props.getCurrentLabel?.({
savedMetricLabel:
this.state.savedMetric?.verbose_name ||
this.state.savedMetric?.metric_name,
@@ -148,7 +218,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
return adhocMetric.expressionType;
}
if (
(isNewMetric || savedMetric.metric_name) &&
(isNewMetric || savedMetric?.metric_name) &&
Array.isArray(savedMetricsOptions) &&
savedMetricsOptions.length > 0
) {
@@ -167,8 +237,8 @@ export default class AdhocMetricEditPopover extends PureComponent {
this.props.onChange(
{
...metric,
},
oldMetric,
} as Metric,
oldMetric as Metric,
);
this.props.onClose();
}
@@ -183,8 +253,8 @@ export default class AdhocMetricEditPopover extends PureComponent {
);
}
onColumnChange(columnName) {
const column = this.props.columns.find(
onColumnChange(columnName: string): void {
const column = this.props.columns?.find(
column => column.column_name === columnName,
);
this.setState(prevState => ({
@@ -196,7 +266,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
}));
}
onAggregateChange(aggregate) {
onAggregateChange(aggregate: string | null): void {
// we construct this object explicitly to overwrite the value in the case aggregate is null
this.setState(prevState => ({
adhocMetric: prevState.adhocMetric.duplicateWith({
@@ -207,8 +277,8 @@ export default class AdhocMetricEditPopover extends PureComponent {
}));
}
onSavedMetricChange(savedMetricName) {
const savedMetric = this.props.savedMetricsOptions.find(
onSavedMetricChange(savedMetricName: string): void {
const savedMetric = this.props.savedMetricsOptions?.find(
metric => metric.metric_name === savedMetricName,
);
this.setState(prevState => ({
@@ -222,7 +292,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
}));
}
onSqlExpressionChange(sqlExpression) {
onSqlExpressionChange(sqlExpression: string): void {
this.setState(prevState => ({
adhocMetric: prevState.adhocMetric.duplicateWith({
sqlExpression,
@@ -232,7 +302,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
}));
}
onDragDown(e) {
onDragDown(e: React.MouseEvent): void {
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragStartWidth = this.state.width;
@@ -240,7 +310,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
document.addEventListener('mousemove', this.onMouseMove);
}
onMouseMove(e) {
onMouseMove(e: MouseEvent): void {
this.props.onResize();
this.setState({
width: Math.max(
@@ -254,32 +324,42 @@ export default class AdhocMetricEditPopover extends PureComponent {
});
}
onMouseUp() {
onMouseUp(): void {
document.removeEventListener('mousemove', this.onMouseMove);
}
onTabChange(tab) {
onTabChange(tab: string): void {
this.refreshAceEditor();
this.props.getCurrentTab(tab);
this.props.getCurrentTab?.(tab);
}
refreshAceEditor() {
refreshAceEditor(): void {
setTimeout(() => {
if (this.aceEditorRef.current) {
this.aceEditorRef.current.editor?.resize?.();
// Cast to access ace editor API
(
this.aceEditorRef.current as unknown as {
editor?: { resize?: () => void };
}
).editor?.resize?.();
}
}, 0);
}
renderColumnOption(option) {
renderColumnOption(option: ColumnType): React.ReactNode {
const column = { ...option };
if (column.metric_name && !column.verbose_name) {
column.verbose_name = column.metric_name;
if (
(column as unknown as { metric_name?: string }).metric_name &&
!column.verbose_name
) {
column.verbose_name = (
column as unknown as { metric_name: string }
).metric_name;
}
return <StyledColumnOption column={column} showType />;
}
renderMetricOption(savedMetric) {
renderMetricOption(savedMetric: SavedMetricType): React.ReactNode {
return <StyledMetricOption metric={savedMetric} showType />;
}
@@ -298,7 +378,12 @@ export default class AdhocMetricEditPopover extends PureComponent {
...popoverProps
} = this.props;
const { adhocMetric, savedMetric } = this.state;
const keywords = sqlKeywords.concat(getColumnKeywords(columns));
const columnsArray = columns ?? [];
const keywords = sqlKeywords.concat(
getColumnKeywords(
columnsArray as Parameters<typeof getColumnKeywords>[0],
),
);
const columnValue =
(adhocMetric.column && adhocMetric.column.column_name) ||
@@ -307,7 +392,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
// autofocus on column if there's no value in column; otherwise autofocus on aggregate
const columnSelectProps = {
ariaLabel: t('Select column'),
placeholder: t('%s column(s)', columns.length),
placeholder: t('%s column(s)', columnsArray.length),
value: columnValue,
onChange: this.onColumnChange,
allowClear: true,
@@ -317,8 +402,11 @@ export default class AdhocMetricEditPopover extends PureComponent {
const aggregateSelectProps = {
ariaLabel: t('Select aggregate options'),
placeholder: t('%s aggregates(s)', AGGREGATES_OPTIONS.length),
value: adhocMetric.aggregate || adhocMetric.inferSqlExpressionAggregate(),
onChange: this.onAggregateChange,
value:
adhocMetric.aggregate ??
adhocMetric.inferSqlExpressionAggregate() ??
undefined,
onChange: this.onAggregateChange as (value: unknown) => void,
allowClear: true,
autoFocus: !!columnValue,
};
@@ -343,10 +431,10 @@ export default class AdhocMetricEditPopover extends PureComponent {
) &&
savedMetric?.metric_name !== propsSavedMetric?.metric_name);
let extra = {};
if (datasource?.extra) {
let extra: ExtraConfig = {};
if (datasource?.extra && typeof datasource.extra === 'string') {
try {
extra = JSON.parse(datasource.extra);
extra = JSON.parse(datasource.extra) as ExtraConfig;
} catch {} // eslint-disable-line no-empty
}
@@ -383,7 +471,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
{...savedSelectProps}
/>
</FormItem>
) : datasource.type === DatasourceType.Table ? (
) : datasource?.type === DatasourceType.Table ? (
<EmptyState
image="empty.svg"
size="small"
@@ -403,7 +491,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
tabIndex={0}
role="button"
onClick={() => {
this.props.handleDatasetModal(true);
this.props.handleDatasetModal?.(true);
this.props.onClose();
}}
>
@@ -433,9 +521,9 @@ export default class AdhocMetricEditPopover extends PureComponent {
<>
<FormItem label={t('column')}>
<Select
options={columns.map(column => ({
options={columnsArray.map(column => ({
value: column.column_name,
key: column.id,
key: (column as { id?: unknown }).id,
label: this.renderColumnOption(column),
}))}
{...columnSelectProps}
@@ -527,5 +615,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
);
}
}
// @ts-expect-error - propTypes are defined for runtime validation but TypeScript handles type checking
AdhocMetricEditPopover.propTypes = propTypes;
// @ts-expect-error - defaultProps for backward compatibility with PropTypes
AdhocMetricEditPopover.defaultProps = defaultProps;

View File

@@ -37,41 +37,40 @@ const sumValueAdhocMetric = new AdhocMetric({
aggregate: AGGREGATES.SUM,
});
const datasource = {
type: 'table',
id: 1,
uid: '1__table',
columnFormats: {},
verboseMap: {},
};
const defaultProps = {
adhocMetric: sumValueAdhocMetric,
savedMetric: {},
savedMetricsOptions: [],
onMetricEdit: jest.fn(),
columns,
datasource,
datasource: {
type: 'table',
id: 1,
uid: '1__table',
columnFormats: {},
verboseMap: {},
},
onMoveLabel: jest.fn(),
onDropLabel: jest.fn(),
index: 0,
};
function setup(overrides) {
function setup(overrides: Record<string, unknown> = {}) {
const props = {
...defaultProps,
...overrides,
};
return render(<AdhocMetricOption {...props} />, { useDnd: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return render(<AdhocMetricOption {...(props as any)} />, { useDnd: true });
}
test('renders an overlay trigger wrapper for the label', () => {
setup();
setup({});
expect(screen.getByText('SUM(value)')).toBeInTheDocument();
});
test('overwrites the adhocMetric in state with onLabelChange', async () => {
setup();
setup({});
userEvent.click(screen.getByText('SUM(value)'));
userEvent.click(screen.getByTestId(/AdhocMetricEditTitle#trigger/i));
const labelInput = await screen.findByTestId(/AdhocMetricEditTitle#input/i);
@@ -86,7 +85,7 @@ test('overwrites the adhocMetric in state with onLabelChange', async () => {
});
test('returns to default labels when the custom label is cleared', async () => {
setup();
setup({});
userEvent.click(screen.getByText('SUM(value)'));
userEvent.click(screen.getByTestId(/AdhocMetricEditTitle#trigger/i));
const labelInput = await screen.findByTestId(/AdhocMetricEditTitle#input/i);

View File

@@ -18,12 +18,32 @@
*/
import { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { Metric } from '@superset-ui/core';
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
import { DndItemType } from 'src/explore/components/DndItemType';
import { Datasource } from 'src/explore/types';
import { ISaveableDatasource } from 'src/SqlLab/components/SaveDatasetModal';
import columnType from './columnType';
import AdhocMetric from './AdhocMetric';
import savedMetricType from './savedMetricType';
import AdhocMetricPopoverTrigger from './AdhocMetricPopoverTrigger';
import { savedMetricType as SavedMetricTypeDef } from './types';
interface AdhocMetricOptionProps {
adhocMetric: AdhocMetric;
onMetricEdit: (newMetric: Metric, oldMetric: Metric) => void;
onRemoveMetric?: (index: number) => void;
columns?: { column_name: string; type: string }[];
savedMetricsOptions?: SavedMetricTypeDef[];
savedMetric?: SavedMetricTypeDef | Record<string, never>;
datasource?: Datasource & ISaveableDatasource;
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
onDropLabel?: () => void;
index?: number;
type?: string;
multi?: boolean;
datasourceWarningMessage?: string;
}
const propTypes = {
adhocMetric: PropTypes.instanceOf(AdhocMetric),
@@ -41,15 +61,15 @@ const propTypes = {
datasourceWarningMessage: PropTypes.string,
};
class AdhocMetricOption extends PureComponent {
constructor(props) {
class AdhocMetricOption extends PureComponent<AdhocMetricOptionProps> {
constructor(props: AdhocMetricOptionProps) {
super(props);
this.onRemoveMetric = this.onRemoveMetric.bind(this);
}
onRemoveMetric(e) {
onRemoveMetric(e?: React.MouseEvent): void {
e?.stopPropagation();
this.props.onRemoveMetric(this.props.index);
this.props.onRemoveMetric?.(this.props.index ?? 0);
}
render() {
@@ -58,7 +78,7 @@ class AdhocMetricOption extends PureComponent {
onMetricEdit,
columns,
savedMetricsOptions,
savedMetric,
savedMetric = {} as SavedMetricTypeDef,
datasource,
onMoveLabel,
onDropLabel,
@@ -67,25 +87,26 @@ class AdhocMetricOption extends PureComponent {
multi,
datasourceWarningMessage,
} = this.props;
const withCaret = !savedMetric.error_text;
const withCaret = !(savedMetric as SavedMetricTypeDef).error_text;
return (
<AdhocMetricPopoverTrigger
adhocMetric={adhocMetric}
onMetricEdit={onMetricEdit}
columns={columns}
savedMetricsOptions={savedMetricsOptions}
columns={columns ?? []}
savedMetricsOptions={savedMetricsOptions ?? []}
savedMetric={savedMetric}
datasource={datasource}
datasource={datasource!}
>
<OptionControlLabel
savedMetric={savedMetric}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
savedMetric={savedMetric as any}
adhocMetric={adhocMetric}
label={adhocMetric.label}
onRemove={this.onRemoveMetric}
onRemove={() => this.onRemoveMetric()}
onMoveLabel={onMoveLabel}
onDropLabel={onDropLabel}
index={index}
index={index ?? 0}
type={type ?? DndItemType.AdhocMetricOption}
withCaret={withCaret}
isFunction
@@ -99,4 +120,5 @@ class AdhocMetricOption extends PureComponent {
export default AdhocMetricOption;
// @ts-expect-error - propTypes are defined for runtime validation but TypeScript handles type checking
AdhocMetricOption.propTypes = propTypes;

View File

@@ -37,7 +37,7 @@ export type AdhocMetricPopoverTriggerProps = {
onMetricEdit(newMetric: Metric, oldMetric: Metric): void;
columns: { column_name: string; type: string }[];
savedMetricsOptions: savedMetricType[];
savedMetric: savedMetricType;
savedMetric: savedMetricType | Record<string, never>;
datasource: Datasource & ISaveableDatasource;
children: ReactNode;
isControlledComponent?: boolean;
@@ -201,8 +201,8 @@ class AdhocMetricPopoverTrigger extends PureComponent<
const { visible, togglePopover, closePopover } = isControlledComponent
? {
visible: this.props.visible,
togglePopover: this.props.togglePopover,
closePopover: this.props.closePopover,
togglePopover: this.props.togglePopover ?? this.togglePopover,
closePopover: this.props.closePopover ?? this.closePopover,
}
: {
visible: this.state.popoverVisible,
@@ -216,12 +216,20 @@ class AdhocMetricPopoverTrigger extends PureComponent<
adhocMetric={adhocMetric}
columns={columns}
savedMetricsOptions={savedMetricsOptions}
savedMetric={savedMetric}
datasource={datasource}
savedMetric={savedMetric as savedMetricType}
datasource={
datasource as unknown as {
type?: string;
id?: number | string;
extra?: string;
}
}
handleDatasetModal={this.handleDatasetModal}
onResize={this.onPopoverResize}
onClose={closePopover}
onChange={this.onChange}
onChange={
this.onChange as (newMetric: unknown, oldMetric?: unknown) => void
}
getCurrentTab={this.getCurrentTab}
getCurrentLabel={this.getCurrentLabel}
isNewMetric={this.props.isNew}

View File

@@ -44,7 +44,11 @@ describe('FilterDefinitionOption', () => {
});
test('renders a StyledColumnOption given an adhoc metric', async () => {
render(<FilterDefinitionOption option={sumValueAdhocMetric} />);
render(
<FilterDefinitionOption
option={sumValueAdhocMetric as unknown as { label: string }}
/>,
);
await expect(screen.getByText('SUM(source)')).toBeVisible();
});

View File

@@ -22,6 +22,14 @@ import columnType from './columnType';
import adhocMetricType from './adhocMetricType';
import { StyledColumnOption } from '../../optionRenderers';
interface OptionType {
saved_metric_name?: string;
column_name?: string;
label?: string;
type?: string;
[key: string]: unknown;
}
const propTypes = {
option: PropTypes.oneOfType([
columnType,
@@ -30,7 +38,11 @@ const propTypes = {
]).isRequired,
};
export default function FilterDefinitionOption({ option }) {
export default function FilterDefinitionOption({
option,
}: {
option: OptionType;
}) {
if (option.saved_metric_name) {
return (
<StyledColumnOption
@@ -40,7 +52,12 @@ export default function FilterDefinitionOption({ option }) {
);
}
if (option.column_name) {
return <StyledColumnOption column={option} showType />;
return (
<StyledColumnOption
column={option as { column_name: string; type?: string }}
showType
/>
);
}
if (option.label) {
return (

View File

@@ -26,17 +26,21 @@ const sumValueAdhocMetric = new AdhocMetric({
aggregate: AGGREGATES.SUM,
});
const setup = propOverrides => {
const defaultProps = {
onMetricEdit: jest.fn(),
option: sumValueAdhocMetric as AdhocMetric,
index: 1,
columns: [],
savedMetrics: [],
savedMetricsOptions: [],
datasource: undefined,
onMoveLabel: jest.fn(),
onDropLabel: jest.fn(),
};
const setup = (propOverrides: Record<string, unknown> = {}) => {
const props = {
onMetricEdit: jest.fn(),
option: sumValueAdhocMetric,
index: 1,
columns: [],
savedMetrics: [],
savedMetricsOptions: [],
datasource: {},
onMoveLabel: jest.fn(),
onDropLabel: jest.fn(),
...defaultProps,
...propOverrides,
};
return render(<MetricDefinitionValue {...props} />, { useDnd: true });
@@ -50,6 +54,6 @@ test('renders a MetricOption given a saved metric', () => {
});
test('renders an AdhocMetricOption given an adhoc metric', () => {
setup();
setup({});
expect(screen.getByText('SUM(value)')).toBeInTheDocument();
});

View File

@@ -17,10 +17,30 @@
* under the License.
*/
import PropTypes from 'prop-types';
import { Metric } from '@superset-ui/core';
import { Datasource } from 'src/explore/types';
import { ISaveableDatasource } from 'src/SqlLab/components/SaveDatasetModal';
import columnType from './columnType';
import AdhocMetricOption from './AdhocMetricOption';
import AdhocMetric from './AdhocMetric';
import savedMetricType from './savedMetricType';
import { savedMetricType as SavedMetricTypeDef } from './types';
interface MetricDefinitionValueProps {
option: AdhocMetric | SavedMetricTypeDef | string;
index: number;
onMetricEdit?: (newMetric: Metric, oldMetric: Metric) => void;
onRemoveMetric?: (index: number) => void;
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
onDropLabel?: () => void;
columns?: { column_name: string; type: string }[];
savedMetrics?: SavedMetricTypeDef[];
savedMetricsOptions?: SavedMetricTypeDef[];
multi?: boolean;
datasource?: Datasource & ISaveableDatasource;
datasourceWarningMessage?: string;
type?: string;
}
const propTypes = {
option: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
@@ -51,14 +71,14 @@ export default function MetricDefinitionValue({
type,
multi,
datasourceWarningMessage,
}) {
const getSavedMetricByName = metricName =>
savedMetrics.find(metric => metric.metric_name === metricName);
}: MetricDefinitionValueProps) {
const getSavedMetricByName = (metricName: string) =>
savedMetrics?.find(metric => metric.metric_name === metricName);
let savedMetric;
if (typeof option === 'string') {
savedMetric = getSavedMetricByName(option);
} else if (option.metric_name) {
} else if ((option as SavedMetricTypeDef).metric_name) {
savedMetric = option;
}
@@ -82,7 +102,8 @@ export default function MetricDefinitionValue({
datasourceWarningMessage,
};
return <AdhocMetricOption {...metricOptionProps} />;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <AdhocMetricOption {...(metricOptionProps as any)} />;
}
return null;
}

View File

@@ -51,10 +51,11 @@ const defaultProps = {
{ metric_name: 'sum__value', expression: 'SUM(energy_usage.value)' },
{ metric_name: 'avg__value', expression: 'AVG(energy_usage.value)' },
],
datasource: undefined,
datasourceType: 'sqla',
};
function setup(overrides) {
function setup(overrides: Record<string, unknown> = {}) {
const onChange = jest.fn();
const props = {
onChange,
@@ -92,7 +93,7 @@ test('handles creating a new metric', async () => {
const { onChange } = setup();
userEvent.click(screen.getByText(/add metric/i));
await selectOption('sum__value', /select saved metrics/i);
await selectOption('sum__value', 'Select saved metrics');
userEvent.click(screen.getByRole('button', { name: /save/i }));
expect(onChange).toHaveBeenCalledWith(['sum__value']);
});
@@ -106,7 +107,7 @@ test('accepts an edited metric from an AdhocMetricEditPopover', async () => {
userEvent.click(metricLabel);
await screen.findByText('aggregate');
selectOption('AVG', /select aggregate options/i);
selectOption('AVG', 'Select aggregate options');
await screen.findByText('AVG(value)');
@@ -130,7 +131,7 @@ test('removes metrics if savedMetrics changes', async () => {
const savedTab = screen.getByRole('tab', { name: /saved/i });
userEvent.click(savedTab);
await selectOption('avg__value', /select saved metrics/i);
await selectOption('avg__value', 'Select saved metrics');
const simpleTab = screen.getByRole('tab', { name: /simple/i });
userEvent.click(simpleTab);
@@ -143,6 +144,9 @@ test('removes metrics if savedMetrics changes', async () => {
test('does not remove custom SQL metric if savedMetrics changes', async () => {
const { rerender } = render(
<MetricsControl
name="metrics"
onChange={jest.fn()}
multi
value={[
{
expressionType: EXPRESSION_TYPES.SQL,
@@ -160,6 +164,7 @@ test('does not remove custom SQL metric if savedMetrics changes', async () => {
{ metric_name: 'sum__value', expression: 'SUM(energy_usage.value)' },
{ metric_name: 'avg__value', expression: 'AVG(energy_usage.value)' },
]}
datasource={undefined}
/>,
{ useDnd: true },
);
@@ -169,6 +174,9 @@ test('does not remove custom SQL metric if savedMetrics changes', async () => {
// Simulate removing columns
rerender(
<MetricsControl
name="metrics"
onChange={jest.fn()}
multi
value={[
{
expressionType: EXPRESSION_TYPES.SQL,
@@ -179,6 +187,7 @@ test('does not remove custom SQL metric if savedMetrics changes', async () => {
]}
columns={[]}
savedMetrics={[]}
datasource={undefined}
/>,
);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { ensureIsArray, t, usePrevious } from '@superset-ui/core';
import { isEqual } from 'lodash';
@@ -57,13 +57,14 @@ const defaultProps = {
columns: [],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getOptionsForSavedMetrics(
savedMetrics,
currentMetricValues,
currentMetric,
savedMetrics: any,
currentMetricValues: any,
currentMetric: any,
) {
return (
savedMetrics?.filter(savedMetric =>
savedMetrics?.filter((savedMetric: { metric_name: string }) =>
Array.isArray(currentMetricValues)
? !currentMetricValues.includes(savedMetric.metric_name) ||
savedMetric.metric_name === currentMetric
@@ -72,13 +73,15 @@ function getOptionsForSavedMetrics(
);
}
function isDictionaryForAdhocMetric(value) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isDictionaryForAdhocMetric(value: any) {
return value && !(value instanceof AdhocMetric) && value.expressionType;
}
// adhoc metrics are stored as dictionaries in URL params. We convert them back into the
// AdhocMetric class for typechecking, consistency and instance method access.
function coerceAdhocMetrics(value) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function coerceAdhocMetrics(value: any) {
if (!value) {
return [];
}
@@ -88,7 +91,8 @@ function coerceAdhocMetrics(value) {
}
return [value];
}
return value.map(val => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return value.map((val: any) => {
if (isDictionaryForAdhocMetric(val)) {
return new AdhocMetric(val);
}
@@ -99,21 +103,42 @@ function coerceAdhocMetrics(value) {
const emptySavedMetric = { metric_name: '', expression: '' };
// TODO: use typeguards to distinguish saved metrics from adhoc metrics
const getMetricsMatchingCurrentDataset = (value, columns, savedMetrics) =>
ensureIsArray(value).filter(metric => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getMetricsMatchingCurrentDataset = (
value: any,
columns: any,
savedMetrics: any,
) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ensureIsArray(value).filter((metric: any) => {
if (typeof metric === 'string' || metric.metric_name) {
return savedMetrics?.some(
savedMetric =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(savedMetric: any) =>
savedMetric.metric_name === metric ||
savedMetric.metric_name === metric.metric_name,
);
}
return columns?.some(
column =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(column: any) =>
!metric.column || metric.column.column_name === column.column_name,
);
});
interface MetricsControlProps {
name: string;
onChange: (value: unknown) => void;
multi?: boolean;
value?: unknown;
columns?: unknown[];
savedMetrics?: unknown[];
datasource?: unknown;
clearable?: boolean;
isLoading?: boolean;
[key: string]: unknown;
}
const MetricsControl = ({
onChange,
multi,
@@ -122,13 +147,14 @@ const MetricsControl = ({
savedMetrics,
datasource,
...props
}) => {
}: MetricsControlProps) => {
const [value, setValue] = useState(coerceAdhocMetrics(propsValue));
const prevColumns = usePrevious(columns);
const prevSavedMetrics = usePrevious(savedMetrics);
const handleChange = useCallback(
opts => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(opts: any) => {
// if clear out options
if (opts === null) {
onChange(null);
@@ -137,21 +163,22 @@ const MetricsControl = ({
const transformedOpts = ensureIsArray(opts);
const optionValues = transformedOpts
.map(option => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((option: any) => {
// pre-defined metric
if (option.metric_name) {
return option.metric_name;
}
return option;
})
.filter(option => option);
.filter((option: unknown) => option);
onChange(multi ? optionValues : optionValues[0]);
},
[multi, onChange],
);
const onNewMetric = useCallback(
newMetric => {
(newMetric: unknown) => {
const newValue = [...value, newMetric];
setValue(newValue);
handleChange(newValue);
@@ -160,8 +187,10 @@ const MetricsControl = ({
);
const onMetricEdit = useCallback(
(changedMetric, oldMetric) => {
const newValue = value.map(val => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(changedMetric: any, oldMetric: any) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newValue = value.map((val: any) => {
if (
// compare saved metrics
val === oldMetric.metric_name ||
@@ -181,7 +210,7 @@ const MetricsControl = ({
);
const onRemoveMetric = useCallback(
index => {
(index: number) => {
if (!Array.isArray(value)) {
return;
}
@@ -194,7 +223,7 @@ const MetricsControl = ({
);
const moveLabel = useCallback(
(dragIndex, hoverIndex) => {
(dragIndex: number, hoverIndex: number) => {
const newValues = [...value];
[newValues[hoverIndex], newValues[dragIndex]] = [
newValues[dragIndex],
@@ -217,7 +246,7 @@ const MetricsControl = ({
const newAdhocMetric = useMemo(() => new AdhocMetric({}), [value]);
const addNewMetricPopoverTrigger = useCallback(
trigger => {
(trigger: React.ReactNode) => {
if (isAddNewMetricDisabled()) {
return trigger;
}
@@ -225,10 +254,12 @@ const MetricsControl = ({
<AdhocMetricPopoverTrigger
adhocMetric={newAdhocMetric}
onMetricEdit={onNewMetric}
columns={columns}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
columns={columns as any}
savedMetricsOptions={savedMetricOptions}
savedMetric={emptySavedMetric}
datasource={datasource}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
datasource={datasource as any}
isNew
>
{trigger}
@@ -274,16 +305,20 @@ const MetricsControl = ({
);
const valueRenderer = useCallback(
(option, index) => (
(option: unknown, index: number) => (
<MetricDefinitionValue
key={index}
index={index}
option={option}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
option={option as any}
onMetricEdit={onMetricEdit}
onRemoveMetric={onRemoveMetric}
columns={columns}
datasource={datasource}
savedMetrics={savedMetrics}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
columns={columns as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
datasource={datasource as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
savedMetrics={savedMetrics as any}
savedMetricsOptions={getOptionsForSavedMetrics(
savedMetrics,
value,

View File

@@ -20,6 +20,8 @@ export type savedMetricType = {
metric_name: string;
verbose_name?: string;
expression: string;
error_text?: string;
id?: number | string;
};
export interface AggregateOption {