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

@@ -19,6 +19,7 @@
import {
render,
screen,
selectOption,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
@@ -210,39 +211,23 @@ test('fetches chart on mount if value present', async () => {
});
test('keeps apply disabled when missing required fields', async () => {
// With EVENT type and Table source, the component requires selecting a chart
// and filling in required fields. Without completing these, Apply should be disabled.
await waitForRender({
annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
sourceType: 'Table',
});
userEvent.click(
screen.getByRole('combobox', { name: 'Annotation layer value' }),
);
expect(await screen.findByText('Chart A')).toBeInTheDocument();
userEvent.click(screen.getByText('Chart A'));
// Apply button should be disabled initially since required fields are not filled
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
// Select Chart A from the annotation layer value dropdown
await selectOption('Chart A', 'Annotation layer value');
// Wait for the chart data to load
await screen.findByText(/title column/i);
userEvent.click(
screen.getByRole('combobox', { name: 'Annotation layer title column' }),
);
expect(await screen.findByText(/none/i)).toBeInTheDocument();
userEvent.click(screen.getByText('None'));
userEvent.click(screen.getByText('Style'));
// The checkbox for automatic color is in the Style tab
userEvent.click(screen.getByText('Use automatic color'));
userEvent.click(
screen.getByRole('combobox', { name: 'Annotation layer stroke' }),
);
expect(await screen.findByText('Dashed')).toBeInTheDocument();
userEvent.click(screen.getByText('Dashed'));
userEvent.click(screen.getByText('Opacity'));
userEvent.click(
screen.getByRole('combobox', { name: 'Annotation layer opacity' }),
);
expect(await screen.findByText(/0.5/i)).toBeInTheDocument();
userEvent.click(screen.getByText('0.5'));
const checkboxes = screen.getAllByRole('checkbox');
checkboxes.forEach(checkbox => userEvent.click(checkbox));
// Apply should still be disabled because name is not filled
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
});

View File

@@ -16,9 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import React, { PureComponent } from 'react';
import rison from 'rison';
import PropTypes from 'prop-types';
import {
Button,
AsyncSelect,
@@ -34,8 +33,13 @@ import {
isValidExpression,
getColumnLabel,
VizType,
type QueryFormColumn,
} from '@superset-ui/core';
import { styled, withTheme } from '@apache-superset/core/ui';
import {
styled,
withTheme,
type SupersetTheme,
} from '@apache-superset/core/ui';
import SelectControl from 'src/explore/components/controls/SelectControl';
import TextControl from 'src/explore/components/controls/TextControl';
import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
@@ -50,60 +54,81 @@ import {
ANNOTATION_SOURCE_TYPES_METADATA,
} from './AnnotationTypes';
interface SelectOption {
value: string | number;
label: string;
viz_type?: string;
[key: string]: unknown;
}
interface SliceData {
data: {
groupby?: string[];
all_columns?: string[];
include_time?: boolean;
[key: string]: unknown;
};
}
interface AnnotationOverrides {
time_range?: string | null;
time_grain_sqla?: string | null;
granularity?: string | null;
time_shift?: string;
[key: string]: unknown;
}
interface AnnotationLayerProps {
name?: string;
annotationType?: string;
sourceType?: string;
color?: string;
opacity?: string;
style?: string;
width?: number;
showMarkers?: boolean;
hideLine?: boolean;
value?: string | number | SelectOption;
overrides?: AnnotationOverrides;
show?: boolean;
showLabel?: boolean;
titleColumn?: string;
descriptionColumns?: string[];
timeColumn?: string;
intervalEndColumn?: string;
vizType?: string;
error?: string;
colorScheme?: string;
theme: SupersetTheme;
addAnnotationLayer?: (annotation: Record<string, unknown>) => void;
removeAnnotationLayer?: () => void;
close?: () => void;
}
interface AnnotationLayerState {
name: string;
annotationType: string;
sourceType: string | null;
value: string | number | SelectOption | null;
overrides: AnnotationOverrides;
show: boolean;
showLabel: boolean;
titleColumn: string;
descriptionColumns: string[];
timeColumn: string;
intervalEndColumn: string;
color: string;
opacity: string;
style: string;
width: number;
showMarkers: boolean;
hideLine: boolean;
isNew: boolean;
slice: SliceData | null;
}
const AUTOMATIC_COLOR = '';
const propTypes = {
name: PropTypes.string,
annotationType: PropTypes.string,
sourceType: PropTypes.string,
color: PropTypes.string,
opacity: PropTypes.string,
style: PropTypes.string,
width: PropTypes.number,
showMarkers: PropTypes.bool,
hideLine: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
overrides: PropTypes.object,
show: PropTypes.bool,
showLabel: PropTypes.bool,
titleColumn: PropTypes.string,
descriptionColumns: PropTypes.arrayOf(PropTypes.string),
timeColumn: PropTypes.string,
intervalEndColumn: PropTypes.string,
vizType: PropTypes.string,
error: PropTypes.string,
colorScheme: PropTypes.string,
addAnnotationLayer: PropTypes.func,
removeAnnotationLayer: PropTypes.func,
close: PropTypes.func,
};
const defaultProps = {
name: '',
annotationType: DEFAULT_ANNOTATION_TYPE,
sourceType: '',
color: AUTOMATIC_COLOR,
opacity: '',
style: 'solid',
width: 1,
showMarkers: false,
hideLine: false,
overrides: {},
colorScheme: 'd3Category10',
show: true,
showLabel: false,
titleColumn: '',
descriptionColumns: [],
timeColumn: '',
intervalEndColumn: '',
addAnnotationLayer: () => {},
removeAnnotationLayer: () => {},
close: () => {},
};
const NotFoundContentWrapper = styled.div`
&& > div:first-child {
padding-left: 0;
@@ -134,8 +159,34 @@ const NotFoundContent = () => (
</NotFoundContentWrapper>
);
class AnnotationLayer extends PureComponent {
constructor(props) {
class AnnotationLayer extends PureComponent<
AnnotationLayerProps,
AnnotationLayerState
> {
static defaultProps = {
name: '',
annotationType: DEFAULT_ANNOTATION_TYPE,
sourceType: '',
color: AUTOMATIC_COLOR,
opacity: '',
style: 'solid',
width: 1,
showMarkers: false,
hideLine: false,
overrides: {},
colorScheme: 'd3Category10',
show: true,
showLabel: false,
titleColumn: '',
descriptionColumns: [],
timeColumn: '',
intervalEndColumn: '',
addAnnotationLayer: () => {},
removeAnnotationLayer: () => {},
close: () => {},
};
constructor(props: AnnotationLayerProps) {
super(props);
const {
name,
@@ -159,42 +210,46 @@ class AnnotationLayer extends PureComponent {
} = props;
// Only allow override whole time_range
if ('since' in overrides || 'until' in overrides) {
overrides.time_range = null;
delete overrides.since;
delete overrides.until;
const processedOverrides: AnnotationOverrides = overrides
? { ...overrides }
: {};
if ('since' in processedOverrides || 'until' in processedOverrides) {
processedOverrides.time_range = null;
delete processedOverrides.since;
delete processedOverrides.until;
}
// Check if annotationType is supported by this chart
const metadata = getChartMetadataRegistry().get(vizType);
const metadata = vizType ? getChartMetadataRegistry().get(vizType) : null;
const supportedAnnotationTypes = metadata?.supportedAnnotationTypes || [];
const resolvedAnnotationType = annotationType || DEFAULT_ANNOTATION_TYPE;
const validAnnotationType = supportedAnnotationTypes.includes(
annotationType,
resolvedAnnotationType,
)
? annotationType
? resolvedAnnotationType
: supportedAnnotationTypes[0];
this.state = {
// base
name,
annotationType: validAnnotationType,
sourceType,
value,
overrides,
show,
showLabel,
name: name || '',
annotationType: validAnnotationType || DEFAULT_ANNOTATION_TYPE,
sourceType: sourceType || null,
value: value || null,
overrides: processedOverrides,
show: show ?? true,
showLabel: showLabel ?? false,
// slice
titleColumn,
descriptionColumns,
timeColumn,
intervalEndColumn,
titleColumn: titleColumn || '',
descriptionColumns: descriptionColumns || [],
timeColumn: timeColumn || '',
intervalEndColumn: intervalEndColumn || '',
// display
color: color || AUTOMATIC_COLOR,
opacity,
style,
width,
showMarkers,
hideLine,
opacity: opacity || '',
style: style || 'solid',
width: width ?? 1,
showMarkers: showMarkers ?? false,
hideLine: hideLine ?? false,
// refData
isNew: !name,
slice: null,
@@ -229,57 +284,71 @@ class AnnotationLayer extends PureComponent {
/* The value prop is the id of the chart/native. This function will set
value in state to an object with the id as value.value to be used by
AsyncSelect */
this.fetchAppliedAnnotation(value);
if (value !== null && typeof value !== 'object') {
this.fetchAppliedAnnotation(value);
}
}
}
componentDidUpdate(prevProps, prevState) {
componentDidUpdate(
_prevProps: AnnotationLayerProps,
prevState: AnnotationLayerState,
): void {
if (this.shouldFetchSliceData(prevState)) {
const { value } = this.state;
this.fetchSliceData(value.value);
if (value && typeof value === 'object' && 'value' in value) {
this.fetchSliceData(value.value);
}
}
}
getSupportedSourceTypes(annotationType) {
getSupportedSourceTypes(annotationType: string): SelectOption[] {
// Get vis types that can be source.
const sources = getChartMetadataRegistry()
.entries()
.filter(({ value: chartMetadata }) =>
chartMetadata.canBeAnnotationType(annotationType),
chartMetadata?.canBeAnnotationType(annotationType),
)
.map(({ key, value: chartMetadata }) => ({
value: key === VizType.Line ? 'line' : key,
label: chartMetadata.name,
label: chartMetadata?.name || key,
}));
// Prepend native source if applicable
if (ANNOTATION_TYPES_METADATA[annotationType]?.supportNativeSource) {
const annotationMeta =
ANNOTATION_TYPES_METADATA[
annotationType as keyof typeof ANNOTATION_TYPES_METADATA
];
if (annotationMeta && 'supportNativeSource' in annotationMeta) {
sources.unshift(ANNOTATION_SOURCE_TYPES_METADATA.NATIVE);
}
return sources;
}
shouldFetchAppliedAnnotation() {
shouldFetchAppliedAnnotation(): boolean {
const { value, sourceType } = this.state;
return value && requiresQuery(sourceType);
return !!value && requiresQuery(sourceType ?? undefined);
}
shouldFetchSliceData(prevState) {
shouldFetchSliceData(prevState: AnnotationLayerState): boolean {
const { value, sourceType } = this.state;
const isChart =
sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE &&
requiresQuery(sourceType);
requiresQuery(sourceType ?? undefined);
const valueIsNew = value && prevState.value !== value;
return valueIsNew && isChart;
return !!valueIsNew && isChart;
}
isValidFormulaAnnotation(expression, annotationType) {
isValidFormulaAnnotation(
expression: string | number | SelectOption | null,
annotationType: string,
): boolean {
if (annotationType === ANNOTATION_TYPES.FORMULA) {
return isValidExpression(expression);
return isValidExpression(expression as string);
}
return true;
}
isValidForm() {
isValidForm(): boolean {
const {
name,
annotationType,
@@ -302,11 +371,13 @@ class AnnotationLayer extends PureComponent {
errors.push(validateNonEmpty(intervalEndColumn));
}
}
errors.push(!this.isValidFormulaAnnotation(value, annotationType));
if (!this.isValidFormulaAnnotation(value, annotationType)) {
errors.push(t('Invalid formula expression'));
}
return !errors.filter(x => x).length;
}
handleAnnotationType(annotationType) {
handleAnnotationType(annotationType: string): void {
this.setState({
annotationType,
sourceType: null,
@@ -315,7 +386,7 @@ class AnnotationLayer extends PureComponent {
});
}
handleAnnotationSourceType(sourceType) {
handleAnnotationSourceType(sourceType: string): void {
const { sourceType: prevSourceType } = this.state;
if (prevSourceType !== sourceType) {
@@ -327,24 +398,28 @@ class AnnotationLayer extends PureComponent {
}
}
handleSelectValue(selectedValueObject) {
handleSelectValue(selectedValueObject: SelectOption): void {
this.setState({
value: selectedValueObject,
descriptionColumns: [],
intervalEndColumn: null,
timeColumn: null,
titleColumn: null,
intervalEndColumn: '',
timeColumn: '',
titleColumn: '',
overrides: { time_range: null },
});
}
handleTextValue(inputValue) {
handleTextValue(inputValue: string): void {
this.setState({
value: inputValue,
});
}
fetchNativeAnnotations = async (search, page, pageSize) => {
fetchNativeAnnotations = async (
search: string,
page: number,
pageSize: number,
): Promise<{ data: SelectOption[]; totalCount: number }> => {
const queryParams = rison.encode({
filters: [
{
@@ -364,7 +439,7 @@ class AnnotationLayer extends PureComponent {
const { result, count } = json;
const layersArray = result.map(layer => ({
const layersArray = result.map((layer: { id: number; name: string }) => ({
value: layer.id,
label: layer.name,
}));
@@ -375,7 +450,11 @@ class AnnotationLayer extends PureComponent {
};
};
fetchCharts = async (search, page, pageSize) => {
fetchCharts = async (
search: string,
page: number,
pageSize: number,
): Promise<{ data: SelectOption[]; totalCount: number }> => {
const { annotationType } = this.state;
const queryParams = rison.encode({
@@ -401,11 +480,11 @@ class AnnotationLayer extends PureComponent {
const registry = getChartMetadataRegistry();
const chartsArray = result
.filter(chart => {
.filter((chart: { id: number; slice_name: string; viz_type: string }) => {
const metadata = registry.get(chart.viz_type);
return metadata && metadata.canBeAnnotationType(annotationType);
})
.map(chart => ({
.map((chart: { id: number; slice_name: string; viz_type: string }) => ({
value: chart.id,
label: chart.slice_name,
viz_type: chart.viz_type,
@@ -417,7 +496,11 @@ class AnnotationLayer extends PureComponent {
};
};
fetchOptions = (search, page, pageSize) => {
fetchOptions = (
search: string,
page: number,
pageSize: number,
): Promise<{ data: SelectOption[]; totalCount: number }> => {
const { sourceType } = this.state;
if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) {
@@ -426,7 +509,7 @@ class AnnotationLayer extends PureComponent {
return this.fetchCharts(search, page, pageSize);
};
fetchSliceData = id => {
fetchSliceData = (id: string | number): void => {
const queryParams = rison.encode({
columns: ['query_context'],
});
@@ -439,7 +522,9 @@ class AnnotationLayer extends PureComponent {
const dataObject = {
data: {
...formData,
groupby: formData.groupby?.map(column => getColumnLabel(column)),
groupby: formData.groupby?.map((column: QueryFormColumn) =>
getColumnLabel(column),
),
},
};
this.setState({
@@ -448,7 +533,7 @@ class AnnotationLayer extends PureComponent {
});
};
fetchAppliedChart(id) {
fetchAppliedChart(id: string | number): void {
const { annotationType } = this.state;
const registry = getChartMetadataRegistry();
const queryParams = rison.encode({
@@ -474,7 +559,9 @@ class AnnotationLayer extends PureComponent {
slice: {
data: {
...formData,
groupby: formData.groupby?.map(column => getColumnLabel(column)),
groupby: formData.groupby?.map((column: QueryFormColumn) =>
getColumnLabel(column),
),
},
},
});
@@ -482,7 +569,7 @@ class AnnotationLayer extends PureComponent {
});
}
fetchAppliedNativeAnnotation(id) {
fetchAppliedNativeAnnotation(id: string | number): void {
SupersetClient.get({
endpoint: `/api/v1/annotation_layer/${id}`,
}).then(({ json }) => {
@@ -497,7 +584,7 @@ class AnnotationLayer extends PureComponent {
});
}
fetchAppliedAnnotation(id) {
fetchAppliedAnnotation(id: string | number): void {
const { sourceType } = this.state;
if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) {
@@ -506,12 +593,12 @@ class AnnotationLayer extends PureComponent {
return this.fetchAppliedChart(id);
}
deleteAnnotation() {
this.props.removeAnnotationLayer();
this.props.close();
deleteAnnotation(): void {
this.props.removeAnnotationLayer?.();
this.props.close?.();
}
applyAnnotation() {
applyAnnotation(): void {
const { value, sourceType } = this.state;
if (this.isValidForm()) {
const annotationFields = [
@@ -532,32 +619,42 @@ class AnnotationLayer extends PureComponent {
'timeColumn',
'intervalEndColumn',
];
const newAnnotation = {};
const newAnnotation: Record<string, unknown> = {};
annotationFields.forEach(field => {
if (this.state[field] !== null) {
newAnnotation[field] = this.state[field];
const stateValue = this.state[field as keyof AnnotationLayerState];
if (stateValue !== null) {
newAnnotation[field] = stateValue;
}
});
// Prepare newAnnotation.value for use in runAnnotationQuery()
const applicableValue = requiresQuery(sourceType) ? value.value : value;
const applicableValue =
requiresQuery(sourceType ?? undefined) &&
value &&
typeof value === 'object'
? (value as SelectOption).value
: value;
newAnnotation.value = applicableValue;
if (newAnnotation.color === AUTOMATIC_COLOR) {
newAnnotation.color = null;
}
this.props.addAnnotationLayer(newAnnotation);
this.props.addAnnotationLayer?.(newAnnotation);
this.setState({ isNew: false });
}
}
submitAnnotation() {
submitAnnotation(): void {
this.applyAnnotation();
this.props.close();
this.props.close?.();
}
renderChartHeader(label, description, value) {
renderChartHeader(
label: string,
description: string,
value: string | number | SelectOption | null,
): React.ReactNode {
return (
<ControlHeader
hovered
@@ -568,11 +665,11 @@ class AnnotationLayer extends PureComponent {
);
}
renderValueConfiguration() {
renderValueConfiguration(): React.ReactNode {
const { annotationType, sourceType, value } = this.state;
let label = '';
let description = '';
if (requiresQuery(sourceType)) {
if (requiresQuery(sourceType ?? undefined)) {
if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) {
label = t('Annotation layer');
description = t('Select the Annotation Layer you would like to use.');
@@ -592,7 +689,7 @@ class AnnotationLayer extends PureComponent {
in milliseconds since epoch. mathjs is used to evaluate the formulas.
Example: '2x+5'`);
}
if (requiresQuery(sourceType)) {
if (requiresQuery(sourceType ?? undefined)) {
return (
<AsyncSelect
/* key to force re-render on sourceType change */
@@ -608,6 +705,8 @@ class AnnotationLayer extends PureComponent {
);
}
if (annotationType === ANNOTATION_TYPES.FORMULA) {
// Extract primitive value for TextControl (formula is always a string)
const textValue = typeof value === 'object' ? null : value;
return (
<TextControl
name="annotation-layer-value"
@@ -616,7 +715,7 @@ class AnnotationLayer extends PureComponent {
description={description}
label={label}
placeholder=""
value={value}
value={textValue}
onChange={this.handleTextValue}
validationErrors={
!this.isValidFormulaAnnotation(value, annotationType)
@@ -629,7 +728,7 @@ class AnnotationLayer extends PureComponent {
return '';
}
renderSliceConfiguration() {
renderSliceConfiguration(): React.ReactNode {
const {
annotationType,
sourceType,
@@ -679,7 +778,9 @@ class AnnotationLayer extends PureComponent {
clearable={false}
options={timeColumnOptions}
value={timeColumn}
onChange={v => this.setState({ timeColumn: v })}
onChange={(
v: string | number | (string | number)[] | null | undefined,
) => this.setState({ timeColumn: String(v ?? '') })}
/>
)}
{annotationType === ANNOTATION_TYPES.INTERVAL && (
@@ -694,7 +795,14 @@ class AnnotationLayer extends PureComponent {
validationErrors={!intervalEndColumn ? ['Mandatory'] : []}
options={columns}
value={intervalEndColumn}
onChange={value => this.setState({ intervalEndColumn: value })}
onChange={(
value:
| string
| number
| (string | number)[]
| null
| undefined,
) => this.setState({ intervalEndColumn: String(value ?? '') })}
/>
)}
<SelectControl
@@ -705,7 +813,9 @@ class AnnotationLayer extends PureComponent {
description={t('Pick a title for you annotation.')}
options={[{ value: '', label: t('None') }].concat(columns)}
value={titleColumn}
onChange={value => this.setState({ titleColumn: value })}
onChange={(
value: string | number | (string | number)[] | null | undefined,
) => this.setState({ titleColumn: String(value ?? '') })}
/>
{annotationType !== ANNOTATION_TYPES.TIME_SERIES && (
<SelectControl
@@ -719,7 +829,17 @@ class AnnotationLayer extends PureComponent {
multi
options={columns}
value={descriptionColumns}
onChange={value => this.setState({ descriptionColumns: value })}
onChange={(
value:
| string
| number
| (string | number)[]
| null
| undefined,
) => {
const cols = Array.isArray(value) ? value.map(String) : [];
this.setState({ descriptionColumns: cols });
}}
/>
)}
<div style={{ marginTop: '1rem' }}>
@@ -784,7 +904,7 @@ class AnnotationLayer extends PureComponent {
return '';
}
renderDisplayConfiguration() {
renderDisplayConfiguration(): React.ReactNode {
const {
color,
opacity,
@@ -794,9 +914,10 @@ class AnnotationLayer extends PureComponent {
hideLine,
annotationType,
} = this.state;
const colorScheme = getCategoricalSchemeRegistry()
.get(this.props.colorScheme)
.colors.concat();
const colorScheme =
getCategoricalSchemeRegistry()
.get(this.props.colorScheme)
?.colors.concat() ?? [];
if (
color &&
color !== AUTOMATIC_COLOR &&
@@ -823,7 +944,9 @@ class AnnotationLayer extends PureComponent {
]}
value={style}
clearable={false}
onChange={v => this.setState({ style: v })}
onChange={(
v: string | number | (string | number)[] | null | undefined,
) => this.setState({ style: String(v ?? 'solid') })}
/>
<SelectControl
ariaLabel={t('Annotation layer opacity')}
@@ -837,7 +960,9 @@ class AnnotationLayer extends PureComponent {
{ value: 'opacityHigh', label: '0.8' },
]}
value={opacity}
onChange={value => this.setState({ opacity: value })}
onChange={(
value: string | number | (string | number)[] | null | undefined,
) => this.setState({ opacity: String(value ?? '') })}
/>
<div
style={{
@@ -905,14 +1030,19 @@ class AnnotationLayer extends PureComponent {
);
}
render() {
render(): React.ReactNode {
const { isNew, name, annotationType, sourceType, show, showLabel } =
this.state;
const isValid = this.isValidForm();
const metadata = getChartMetadataRegistry().get(this.props.vizType);
const metadata = this.props.vizType
? getChartMetadataRegistry().get(this.props.vizType)
: null;
const supportedAnnotationTypes = metadata
? metadata.supportedAnnotationTypes.map(
type => ANNOTATION_TYPES_METADATA[type],
type =>
ANNOTATION_TYPES_METADATA[
type as keyof typeof ANNOTATION_TYPES_METADATA
],
)
: [];
const supportedSourceTypes = this.getSupportedSourceTypes(annotationType);
@@ -989,7 +1119,7 @@ class AnnotationLayer extends PureComponent {
<Button
buttonSize="small"
buttonStyle="secondary"
onClick={() => this.props.close()}
onClick={() => this.props.close?.()}
>
{t('Cancel')}
</Button>
@@ -1026,7 +1156,4 @@ class AnnotationLayer extends PureComponent {
}
}
AnnotationLayer.propTypes = propTypes;
AnnotationLayer.defaultProps = defaultProps;
export default withTheme(AnnotationLayer);

View File

@@ -18,12 +18,25 @@
*/
import { t } from '@superset-ui/core';
function extractTypes(metadata) {
return Object.keys(metadata).reduce((prev, key) => {
const result = prev;
result[key] = key;
return result;
}, {});
interface Annotation {
sourceType?: string;
timeColumn?: string;
intervalEndColumn?: string;
titleColumn?: string;
descriptionColumns?: string[];
}
function extractTypes<T extends Record<string, { value: string }>>(
metadata: T,
): Record<keyof T, string> {
return Object.keys(metadata).reduce(
(prev, key) => {
const result = prev;
result[key as keyof T] = key;
return result;
},
{} as Record<keyof T, string>,
);
}
export const ANNOTATION_TYPES_METADATA = {
@@ -62,7 +75,9 @@ export const ANNOTATION_SOURCE_TYPES = extractTypes(
ANNOTATION_SOURCE_TYPES_METADATA,
);
export function requiresQuery(annotationSourceType) {
export function requiresQuery(
annotationSourceType: string | undefined,
): boolean {
return !!annotationSourceType;
}
@@ -71,11 +86,16 @@ const NATIVE_COLUMN_NAMES = {
intervalEndColumn: 'end_dttm',
titleColumn: 'short_descr',
descriptionColumns: ['long_descr'],
};
} as const;
export function applyNativeColumns(annotation) {
export function applyNativeColumns(annotation: Annotation): Annotation {
if (annotation.sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) {
return { ...annotation, ...NATIVE_COLUMN_NAMES };
return {
...annotation,
...NATIVE_COLUMN_NAMES,
// Spread to convert readonly array to mutable
descriptionColumns: [...NATIVE_COLUMN_NAMES.descriptionColumns],
};
}
return annotation;
}

View File

@@ -16,22 +16,22 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component } from 'react';
import PropTypes from 'prop-types';
import { Component, type ReactNode } from 'react';
import { styled, css } from '@apache-superset/core/ui';
import { Checkbox } from '@superset-ui/core/components';
import ControlHeader from '../ControlHeader';
const propTypes = {
value: PropTypes.bool,
label: PropTypes.string,
onChange: PropTypes.func,
};
const defaultProps = {
value: false,
onChange: () => {},
};
interface CheckboxControlProps {
value?: boolean;
label?: ReactNode;
name?: string;
description?: ReactNode;
hovered?: boolean;
onChange?: (value: boolean) => void;
validationErrors?: string[];
placeholder?: string;
debounceDelay?: number;
}
const CheckBoxControlWrapper = styled.div`
${({ theme }) => css`
@@ -47,28 +47,28 @@ const CheckBoxControlWrapper = styled.div`
`}
`;
export default class CheckboxControl extends Component {
onChange() {
this.props.onChange(!this.props.value);
export default class CheckboxControl extends Component<CheckboxControlProps> {
static defaultProps = {
value: false,
onChange: () => {},
};
onChange = (): void => {
this.props.onChange?.(!this.props.value);
};
renderCheckbox(): ReactNode {
return <Checkbox onChange={this.onChange} checked={!!this.props.value} />;
}
renderCheckbox() {
return (
<Checkbox
onChange={this.onChange.bind(this)}
checked={!!this.props.value}
/>
);
}
render() {
render(): ReactNode {
if (this.props.label) {
return (
<CheckBoxControlWrapper>
<ControlHeader
{...this.props}
leftNode={this.renderCheckbox()}
onClick={this.onChange.bind(this)}
onClick={this.onChange}
/>
</CheckBoxControlWrapper>
);
@@ -76,5 +76,3 @@ export default class CheckboxControl extends Component {
return this.renderCheckbox();
}
}
CheckboxControl.propTypes = propTypes;
CheckboxControl.defaultProps = defaultProps;

View File

@@ -16,12 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component } from 'react';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { IconTooltip, List } from '@superset-ui/core/components';
import { nanoid } from 'nanoid';
import { t } from '@superset-ui/core';
import { withTheme } from '@apache-superset/core/ui';
import { withTheme, type SupersetTheme } from '@apache-superset/core/ui';
import {
SortableContainer,
SortableHandle,
@@ -37,6 +37,27 @@ import ControlHeader from 'src/explore/components/ControlHeader';
import CustomListItem from 'src/explore/components/controls/CustomListItem';
import controlMap from '..';
interface CollectionItem {
key?: string;
[key: string]: unknown;
}
interface CollectionControlProps {
name: string;
label?: string | null;
description?: string | null;
placeholder?: string;
addTooltip?: string;
itemGenerator?: () => CollectionItem;
keyAccessor?: (item: CollectionItem) => string;
onChange?: (value: CollectionItem[]) => void;
value?: CollectionItem[];
isFloat?: boolean;
isInt?: boolean;
controlName: string;
theme: SupersetTheme;
}
const propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.string,
@@ -52,13 +73,13 @@ const propTypes = {
controlName: PropTypes.string.isRequired,
};
const defaultProps = {
const defaultProps: Partial<CollectionControlProps> = {
label: null,
description: null,
onChange: () => {},
placeholder: t('Empty collection'),
itemGenerator: () => ({ key: nanoid(11) }),
keyAccessor: o => o.key,
keyAccessor: (o: CollectionItem) => o.key ?? '',
value: [],
addTooltip: t('Add an item'),
};
@@ -73,63 +94,81 @@ const SortableDragger = SortableHandle(() => (
/>
));
class CollectionControl extends Component {
constructor(props) {
class CollectionControl extends Component<CollectionControlProps> {
static propTypes = propTypes;
static defaultProps = defaultProps;
constructor(props: CollectionControlProps) {
super(props);
this.onAdd = this.onAdd.bind(this);
}
onChange(i, value) {
const newValue = [...this.props.value];
newValue[i] = { ...this.props.value[i], ...value };
this.props.onChange(newValue);
onChange(i: number, value: CollectionItem) {
const currentValue = this.props.value ?? [];
const newValue = [...currentValue];
newValue[i] = { ...currentValue[i], ...value };
this.props.onChange?.(newValue);
}
onAdd() {
this.props.onChange(this.props.value.concat([this.props.itemGenerator()]));
const currentValue = this.props.value ?? [];
const newItem = this.props.itemGenerator?.();
// Cast needed: original JS allowed undefined items from itemGenerator
this.props.onChange?.(
currentValue.concat([newItem] as unknown as CollectionItem[]),
);
}
onSortEnd({ oldIndex, newIndex }) {
this.props.onChange(arrayMove(this.props.value, oldIndex, newIndex));
onSortEnd({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) {
const currentValue = this.props.value ?? [];
this.props.onChange?.(arrayMove(currentValue, oldIndex, newIndex));
}
removeItem(i) {
this.props.onChange(this.props.value.filter((o, ix) => i !== ix));
removeItem(i: number) {
const currentValue = this.props.value ?? [];
this.props.onChange?.(currentValue.filter((o, ix) => i !== ix));
}
renderList() {
if (this.props.value.length === 0) {
const currentValue = this.props.value ?? [];
if (currentValue.length === 0) {
return <div className="text-muted">{this.props.placeholder}</div>;
}
const Control = controlMap[this.props.controlName];
const Control = (controlMap as Record<string, React.ComponentType<any>>)[
this.props.controlName
];
const keyAccessor =
this.props.keyAccessor ?? ((o: CollectionItem) => o.key ?? '');
return (
<SortableList
useDragHandle
lockAxis="y"
onSortEnd={this.onSortEnd.bind(this)}
bordered
css={theme => ({
css={(theme: SupersetTheme) => ({
borderRadius: theme.borderRadius,
})}
>
{this.props.value.map((o, i) => {
{currentValue.map((o: CollectionItem, i: number) => {
// label relevant only for header, not here
const { label, ...commonProps } = this.props;
const { label, theme, ...commonProps } = this.props;
return (
<SortableListItem
selectable={false}
className="clearfix"
css={theme => ({
css={(theme: SupersetTheme) => ({
alignItems: 'center',
justifyContent: 'flex-start',
display: 'flex',
paddingInline: theme.sizeUnit * 6,
})}
key={this.props.keyAccessor(o)}
key={keyAccessor(o)}
index={i}
>
<SortableDragger />
<div
css={theme => ({
css={(theme: SupersetTheme) => ({
flex: 1,
marginLeft: theme.sizeUnit * 2,
marginRight: theme.sizeUnit * 2,
@@ -148,7 +187,7 @@ class CollectionControl extends Component {
tooltip={t('Remove item')}
mouseEnterDelay={0}
mouseLeaveDelay={0}
css={theme => ({
css={(theme: SupersetTheme) => ({
padding: 0,
minWidth: 'auto',
height: 'auto',
@@ -190,7 +229,4 @@ class CollectionControl extends Component {
}
}
CollectionControl.propTypes = propTypes;
CollectionControl.defaultProps = defaultProps;
export default withTheme(CollectionControl);

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import type React from 'react';
import { Route } from 'react-router-dom';
import fetchMock from 'fetch-mock';
import { DatasourceType, JsonObject, SupersetClient } from '@superset-ui/core';
@@ -28,7 +29,7 @@ import {
waitFor,
} from 'spec/helpers/testing-library';
import { fallbackExploreInitialData } from 'src/explore/fixtures';
import type { DatasetObject, ColumnObject } from 'src/features/datasets/types';
import type { ColumnObject } from 'src/features/datasets/types';
import DatasourceControl from '.';
const SupersetClientGet = jest.spyOn(SupersetClient, 'get');
@@ -46,20 +47,35 @@ afterEach(() => {
jest.clearAllMocks(); // Clears mock history but keeps spy in place
});
type TestDatasource = Omit<
Partial<DatasetObject>,
'columns' | 'main_dttm_col'
> & {
interface TestDatasource {
id?: number;
name: string;
database: { name: string };
datasource_name?: string;
database: {
id: number;
database_name: string;
name?: string;
backend?: string;
};
columns?: Partial<ColumnObject>[];
type?: DatasourceType;
main_dttm_col?: string | null;
};
owners?: Array<{
first_name: string;
last_name: string;
id: number;
username?: string;
}>;
sql?: string;
metrics?: Array<{ id: number; metric_name: string }>;
[key: string]: unknown;
}
const mockDatasource: TestDatasource = {
id: 25,
database: {
id: 1,
database_name: 'examples',
name: 'examples',
},
name: 'channels',
@@ -69,39 +85,50 @@ const mockDatasource: TestDatasource = {
owners: [{ first_name: 'john', last_name: 'doe', id: 1, username: 'jd' }],
sql: 'SELECT * FROM mock_datasource_sql',
};
const createProps = (overrides: JsonObject = {}) => ({
hovered: false,
type: 'DatasourceControl',
label: 'Datasource',
default: null,
description: null,
value: '25__table',
form_data: {},
datasource: mockDatasource,
validationErrors: [],
name: 'datasource',
actions: {
changeDatasource: jest.fn(),
setControlValue: jest.fn(),
},
isEditable: true,
user: {
createdOn: '2021-04-27T18:12:38.952304',
email: 'admin',
firstName: 'admin',
isActive: true,
lastName: 'admin',
permissions: {},
roles: { Admin: Array(173) },
userId: 1,
username: 'admin',
},
onChange: jest.fn(),
onDatasourceSave: jest.fn(),
...overrides,
});
async function openAndSaveChanges(datasource: TestDatasource) {
// Use type assertion for test props since the component is wrapped with withTheme
// The withTheme HOC makes the props type complex, so we cast through unknown to bypass type check
type DatasourceControlComponentProps = React.ComponentProps<
typeof DatasourceControl
>;
const createProps = (
overrides: JsonObject = {},
): DatasourceControlComponentProps =>
({
hovered: false,
type: 'DatasourceControl',
label: 'Datasource',
default: null,
description: null,
value: '25__table',
form_data: {},
datasource: mockDatasource,
validationErrors: [],
name: 'datasource',
actions: {
changeDatasource: jest.fn(),
setControlValue: jest.fn(),
},
isEditable: true,
user: {
createdOn: '2021-04-27T18:12:38.952304',
email: 'admin',
firstName: 'admin',
isActive: true,
lastName: 'admin',
permissions: {},
roles: { Admin: Array(173) },
userId: 1,
username: 'admin',
},
onChange: jest.fn(),
onDatasourceSave: jest.fn(),
...overrides,
}) as unknown as DatasourceControlComponentProps;
async function openAndSaveChanges(
datasource: TestDatasource | Record<string, unknown>,
) {
fetchMock.get(
'glob:*/api/v1/database/?q=*',
{ result: [] },
@@ -259,7 +286,6 @@ test('Click on Edit dataset', async () => {
test('Edit dataset should be disabled when user is not admin', async () => {
const props = createProps();
// @ts-expect-error
props.user.roles = {};
props.datasource.owners = [];
SupersetClientGet.mockImplementationOnce(
@@ -458,11 +484,11 @@ test('should not set the temporal column', async () => {
const overrideProps = {
...props,
form_data: {
granularity_sqla: null,
granularity_sqla: undefined,
},
datasource: {
...props.datasource,
main_dttm_col: null,
main_dttm_col: undefined,
columns: [
{
column_name: 'test-col',

View File

@@ -18,10 +18,20 @@
* under the License.
*/
import { PureComponent } from 'react';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { DatasourceType, SupersetClient, t } from '@superset-ui/core';
import { css, styled, withTheme } from '@apache-superset/core/ui';
import {
DatasourceType,
SupersetClient,
t,
Datasource,
} from '@superset-ui/core';
import {
css,
styled,
withTheme,
type SupersetTheme,
} from '@apache-superset/core/ui';
import { getTemporalColumns } from '@superset-ui/chart-controls';
import { getUrlParam } from 'src/utils/urlUtils';
import {
@@ -51,6 +61,68 @@ import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { safeStringify } from 'src/utils/safeStringify';
import { Link } from 'react-router-dom';
// Extended Datasource interface with all properties used in this component
interface ExtendedDatasource extends Datasource {
sql?: string;
select_star?: string;
owners?: Array<{
id: number;
first_name: string;
last_name: string;
value?: number;
}>;
extra?: string;
health_check_message?: string;
database?: {
id: number;
database_name: string;
backend?: string;
};
}
interface User {
userId?: number;
username?: string;
roles?: Record<string, unknown[]>;
}
interface DatasourceControlActions {
changeDatasource: (datasource: ExtendedDatasource) => void;
setControlValue: (name: string, value: unknown) => void;
}
interface FormData {
granularity_sqla?: string;
[key: string]: unknown;
}
interface DatasourceControlProps {
actions: DatasourceControlActions;
onChange?: () => void;
value?: string | null;
datasource: ExtendedDatasource;
form_data?: FormData;
isEditable?: boolean;
onDatasourceSave?: ((datasource: ExtendedDatasource) => void) | null;
theme: SupersetTheme;
user: User;
// ControlHeader-related props
hovered?: boolean;
type?: string;
label?: string;
default?: unknown;
description?: string | null;
validationErrors?: string[];
name?: string;
}
interface DatasourceControlState {
showEditDatasourceModal: boolean;
showChangeDatasourceModal: boolean;
showSaveDatasetModal: boolean;
showDatasource?: boolean;
}
const propTypes = {
actions: PropTypes.object.isRequired,
onChange: PropTypes.func,
@@ -59,6 +131,15 @@ const propTypes = {
form_data: PropTypes.object.isRequired,
isEditable: PropTypes.bool,
onDatasourceSave: PropTypes.func,
user: PropTypes.object.isRequired,
// ControlHeader-related props
hovered: PropTypes.bool,
type: PropTypes.string,
label: PropTypes.string,
default: PropTypes.any,
description: PropTypes.string,
validationErrors: PropTypes.array,
name: PropTypes.string,
};
const defaultProps = {
@@ -68,7 +149,7 @@ const defaultProps = {
isEditable: true,
};
const getDatasetType = datasource => {
const getDatasetType = (datasource: ExtendedDatasource): string => {
if (datasource.type === 'query') {
return 'query';
}
@@ -139,15 +220,18 @@ const SAVE_AS_DATASET = 'save_as_dataset';
const VISIBLE_TITLE_LENGTH = 25;
// Assign icon for each DatasourceType. If no icon assignment is found in the lookup, no icon will render
export const datasourceIconLookup = {
export const datasourceIconLookup: Record<string, React.ReactNode> = {
query: <Icons.ConsoleSqlOutlined className="datasource-svg" />,
physical_dataset: <Icons.TableOutlined className="datasource-svg" />,
virtual_dataset: <Icons.ConsoleSqlOutlined className="datasource-svg" />,
};
// Render title for datasource with tooltip only if text is longer than VISIBLE_TITLE_LENGTH
export const renderDatasourceTitle = (displayString, tooltip) =>
displayString?.length > VISIBLE_TITLE_LENGTH ? (
export const renderDatasourceTitle = (
displayString: string | undefined,
tooltip: string,
) =>
displayString?.length && displayString.length > VISIBLE_TITLE_LENGTH ? (
// Add a tooltip only for long names that will be visually truncated
<Tooltip title={tooltip}>
<span className="title-select">{displayString}</span>
@@ -159,12 +243,14 @@ export const renderDatasourceTitle = (displayString, tooltip) =>
);
// Different data source types use different attributes for the display title
export const getDatasourceTitle = datasource => {
if (datasource?.type === 'query') return datasource?.sql;
export const getDatasourceTitle = (
datasource: ExtendedDatasource | null | undefined,
): string => {
if (datasource?.type === 'query') return datasource?.sql || '';
return datasource?.name || '';
};
const preventRouterLinkWhileMetaClicked = evt => {
const preventRouterLinkWhileMetaClicked = (evt: React.MouseEvent) => {
if (evt.metaKey) {
evt.preventDefault();
} else {
@@ -172,8 +258,15 @@ const preventRouterLinkWhileMetaClicked = evt => {
}
};
class DatasourceControl extends PureComponent {
constructor(props) {
class DatasourceControl extends PureComponent<
DatasourceControlProps,
DatasourceControlState
> {
static propTypes = propTypes;
static defaultProps = defaultProps;
constructor(props: DatasourceControlProps) {
super(props);
this.state = {
showEditDatasourceModal: false,
@@ -182,10 +275,13 @@ class DatasourceControl extends PureComponent {
};
}
onDatasourceSave = datasource => {
this.props.actions.changeDatasource(datasource);
const { temporalColumns, defaultTemporalColumn } =
getTemporalColumns(datasource);
onDatasourceSave = (datasource: Datasource) => {
// Cast to ExtendedDatasource for the component's internal use
this.props.actions.changeDatasource(datasource as ExtendedDatasource);
// Cast datasource for getTemporalColumns which expects Dataset | QueryResponse
const { temporalColumns, defaultTemporalColumn } = getTemporalColumns(
datasource as Parameters<typeof getTemporalColumns>[0],
);
const { columns } = datasource;
// the current granularity_sqla might not be a temporal column anymore
const timeCol = this.props.form_data?.granularity_sqla;
@@ -238,7 +334,7 @@ class DatasourceControl extends PureComponent {
}));
};
handleMenuItemClick = ({ key }) => {
handleMenuItemClick = ({ key }: { key: string }) => {
switch (key) {
case CHANGE_DATASET:
this.toggleChangeDatasourceModal();
@@ -371,12 +467,17 @@ class DatasourceControl extends PureComponent {
modalBody={
<ViewQuery
sql={datasource?.sql || datasource?.select_star || ''}
datasource={`${datasource.id}__${datasource.type}`}
/>
}
modalFooter={
<ViewQueryModalFooter
changeDatasource={this.toggleSaveDatasetModal}
datasource={datasource}
datasource={{
id: String(datasource.id),
sql: datasource.sql || '',
type: datasource.type,
}}
/>
}
draggable={false}
@@ -406,7 +507,7 @@ class DatasourceControl extends PureComponent {
queryDatasourceMenuItems.push({
key: SAVE_AS_DATASET,
label: t('Save as dataset'),
label: <span>{t('Save as dataset')}</span>,
});
const queryDatasourceMenu = (
@@ -464,8 +565,8 @@ class DatasourceControl extends PureComponent {
{isMissingDatasource && isMissingParams && (
<div className="error-alert">
<ErrorAlert
level="warning"
errorType={t('Missing URL parameters')}
type="warning"
message={t('Missing URL parameters')}
description={t(
'The URL is missing the dataset_id or slice_id parameters.',
)}
@@ -486,7 +587,7 @@ class DatasourceControl extends PureComponent {
) : (
<ErrorAlert
type="warning"
errorType={t('Missing dataset')}
message={t('Missing dataset')}
descriptionPre={false}
descriptionDetailsCollapsed={false}
descriptionDetails={
@@ -498,7 +599,7 @@ class DatasourceControl extends PureComponent {
</p>
<p>
<Button
buttonStyle="warning"
buttonStyle="primary"
onClick={() =>
this.handleMenuItemClick({ key: CHANGE_DATASET })
}
@@ -547,7 +648,9 @@ class DatasourceControl extends PureComponent {
}
}
DatasourceControl.propTypes = propTypes;
DatasourceControl.defaultProps = defaultProps;
export default withTheme(DatasourceControl);
// withTheme injects the theme prop, so we need to cast the component type
export default withTheme(
DatasourceControl as React.ComponentType<
Omit<DatasourceControlProps, 'theme'>
>,
);

View File

@@ -333,7 +333,8 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
<MetricDefinitionValue
key={`metric-${idx}`}
index={idx}
option={item}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
option={item as any}
onMetricEdit={(changedMetric: Metric | AdhocMetric) => {
const newValues = [...coercedValue];
if (changedMetric instanceof AdhocMetric) {
@@ -344,10 +345,14 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
onChange(multi ? newValues : newValues[0]);
}}
onRemoveMetric={onClickClose}
columns={columns}
savedMetrics={savedMetrics}
savedMetricsOptions={savedMetrics}
datasource={datasource}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
columns={columns as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
savedMetrics={savedMetrics as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
savedMetricsOptions={savedMetrics as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
datasource={datasource as any}
onMoveLabel={onShiftOptions}
onDropLabel={() => {}}
type={`${DndItemType.AdhocMetricOption}_${name}_${label}`}

View File

@@ -16,12 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useState } from 'react';
import { ChangeEvent, useCallback, useState } from 'react';
import { t } from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/ui';
import { Input, Tooltip } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
interface DndColumnSelectPopoverTitleProps {
title: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
isEditDisabled: boolean;
hasCustomLabel: boolean;
}
const StyledInput = styled(Input)`
border-radius: ${({ theme }) => theme.borderRadius};
height: 26px;
@@ -34,7 +41,7 @@ export const DndColumnSelectPopoverTitle = ({
onChange,
isEditDisabled,
hasCustomLabel,
}) => {
}: DndColumnSelectPopoverTitleProps) => {
const theme = useTheme();
const [isHovered, setIsHovered] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);

View File

@@ -19,7 +19,11 @@
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import { ensureIsArray, QueryFormData } from '@superset-ui/core';
import {
ensureIsArray,
QueryFormData,
QueryFormMetric,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/api/core';
import { ColumnMeta } from '@superset-ui/chart-controls';
import {
@@ -166,7 +170,7 @@ test('renders options with adhoc metric', async () => {
setup({
formData: {
...baseFormData,
metrics: [adhocMetric],
metrics: [adhocMetric as unknown as QueryFormMetric],
},
}),
{
@@ -205,7 +209,7 @@ test('cannot drop a column that is not part of the simple column selection', ()
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric],
metrics: [adhocMetric as unknown as QueryFormMetric],
},
columns: [{ column_name: 'order_date' }],
})}
@@ -335,7 +339,7 @@ describe('when disallow_adhoc_metrics is set', () => {
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric],
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
@@ -383,7 +387,7 @@ describe('when disallow_adhoc_metrics is set', () => {
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric],
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,

View File

@@ -89,10 +89,15 @@ const coerceMetrics = (
col => col.column_name === metric.column.column_name,
);
if (column) {
return new AdhocMetric({ ...metric, column });
// Cast entire config object to handle type mismatch between @superset-ui/core and local types
return new AdhocMetric({
...(metric as unknown as Record<string, unknown>),
column,
} as Record<string, unknown>);
}
}
return new AdhocMetric(metric);
// Cast to unknown first to handle type mismatch between @superset-ui/core and local AdhocMetric
return new AdhocMetric(metric as unknown as Record<string, unknown>);
});
};
@@ -200,7 +205,11 @@ const DndMetricSelect = (props: any) => {
const onMetricEdit = useCallback(
(changedMetric: Metric | AdhocMetric, oldMetric: Metric | AdhocMetric) => {
if (oldMetric instanceof AdhocMetric && oldMetric.equals(changedMetric)) {
if (
oldMetric instanceof AdhocMetric &&
changedMetric instanceof AdhocMetric &&
oldMetric.equals(changedMetric)
) {
return;
}
const newValue = value.map(value => {
@@ -273,7 +282,8 @@ const DndMetricSelect = (props: any) => {
<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={props.columns}
@@ -343,9 +353,10 @@ const DndMetricSelect = (props: any) => {
droppedItem.type === DndItemType.Column
) {
const itemValue = droppedItem.value as ColumnMeta;
const config: Partial<AdhocMetric> = {
// Cast config to handle ColumnMeta/ColumnType mismatch
const config = {
column: itemValue,
};
} as Partial<AdhocMetric>;
if (itemValue.type_generic === GenericDataType.Numeric) {
config.aggregate = AGGREGATES.SUM;
} else if (

View File

@@ -16,10 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { AdhocFilter as CoreAdhocFilter } from '@superset-ui/core';
import {
CUSTOM_OPERATORS,
DISABLE_INPUT_OPERATORS,
OPERATOR_ENUM_TO_OPERATOR_TYPE,
Operators,
} from 'src/explore/constants';
import { translateToSql } from '../utils/translateToSQL';
import { Clauses, ExpressionTypes } from '../types';
@@ -28,15 +30,51 @@ const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
);
interface AdhocFilterInput {
expressionType?: string;
subject?: string | { column_name?: string; [key: string]: unknown } | null;
operator?: string | null;
operatorId?: string;
comparator?: unknown;
clause?: string | null;
sqlExpression?: string | null;
isExtra?: boolean;
isNew?: boolean;
datasourceWarning?: boolean;
deck_slices?: unknown;
layerFilterScope?: unknown;
filterOptionName?: string;
// Allow additional properties for flexibility
[key: string]: unknown;
}
export default class AdhocFilter {
constructor(adhocFilter) {
expressionType: string;
subject?: string | { column_name?: string; [key: string]: unknown } | null;
operator?: string | null;
operatorId?: string;
comparator?: unknown;
clause?: string | null;
sqlExpression?: string | null;
isExtra: boolean;
isNew: boolean;
datasourceWarning: boolean;
deck_slices?: unknown;
layerFilterScope?: unknown;
filterOptionName: string;
constructor(adhocFilter: AdhocFilterInput) {
this.expressionType = adhocFilter.expressionType || ExpressionTypes.Simple;
if (this.expressionType === ExpressionTypes.Simple) {
this.subject = adhocFilter.subject;
this.operator = adhocFilter.operator?.toUpperCase();
this.operatorId = adhocFilter.operatorId;
this.comparator = adhocFilter.comparator;
if (DISABLE_INPUT_OPERATORS.indexOf(adhocFilter.operatorId) >= 0) {
if (
adhocFilter.operatorId &&
DISABLE_INPUT_OPERATORS.indexOf(adhocFilter.operatorId as Operators) >=
0
) {
this.comparator = undefined;
}
this.clause = adhocFilter.clause || Clauses.Where;
@@ -45,7 +83,9 @@ export default class AdhocFilter {
this.sqlExpression =
typeof adhocFilter.sqlExpression === 'string'
? adhocFilter.sqlExpression
: translateToSql(adhocFilter, { useSimple: true });
: translateToSql(adhocFilter as unknown as CoreAdhocFilter, {
useSimple: true,
});
this.clause = adhocFilter.clause;
if (
adhocFilter.operator &&
@@ -73,16 +113,28 @@ export default class AdhocFilter {
.substring(2, 15)}`;
}
duplicateWith(nextFields) {
return new AdhocFilter({
...this,
// all duplicated fields are not new (i.e. will not open popup automatically)
isNew: false,
duplicateWith(nextFields: Partial<AdhocFilterInput>): AdhocFilter {
// Spread class properties as plain object for constructor input
const currentFields: AdhocFilterInput = {
expressionType: this.expressionType,
subject: this.subject,
operator: this.operator,
operatorId: this.operatorId,
comparator: this.comparator,
clause: this.clause,
sqlExpression: this.sqlExpression,
isExtra: this.isExtra,
isNew: false, // all duplicated fields are not new
datasourceWarning: this.datasourceWarning,
deck_slices: this.deck_slices,
layerFilterScope: this.layerFilterScope,
filterOptionName: this.filterOptionName,
...nextFields,
});
};
return new AdhocFilter(currentFields);
}
equals(adhocFilter) {
equals(adhocFilter: AdhocFilter): boolean {
return (
adhocFilter.clause === this.clause &&
adhocFilter.expressionType === this.expressionType &&
@@ -94,10 +146,11 @@ export default class AdhocFilter {
);
}
isValid() {
isValid(): boolean {
if (this.expressionType === ExpressionTypes.Simple) {
// operators where the comparator is not used
if (
this.operator &&
DISABLE_INPUT_OPERATORS.map(
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
).indexOf(this.operator) >= 0
@@ -121,16 +174,43 @@ export default class AdhocFilter {
);
}
getDefaultLabel() {
getDefaultLabel(): string {
const label = this.translateToSql();
return label.length < 43 ? label : `${label.substring(0, 40)}...`;
}
getTooltipTitle() {
getTooltipTitle(): string {
return this.translateToSql();
}
translateToSql() {
return translateToSql(this);
translateToSql(): string {
return translateToSql(this as unknown as CoreAdhocFilter);
}
}
/**
* Adapter function to create an AdhocFilter instance from a core AdhocFilter type.
* This bridges the type gap between @superset-ui/core's AdhocFilter and the local class.
*/
export function fromCoreAdhocFilter(filter: CoreAdhocFilter): AdhocFilter {
return new AdhocFilter(filter as AdhocFilterInput);
}
/**
* Type guard to check if an object can be used to construct an AdhocFilter.
* Returns true for plain objects that have filter-like properties.
*/
export function isDictionaryForAdhocFilter(
value: unknown,
): value is AdhocFilterInput {
return (
typeof value === 'object' &&
value !== null &&
!(value instanceof AdhocFilter) &&
('expressionType' in value ||
'subject' in value ||
'operator' in value ||
'sqlExpression' in value ||
'clause' in value)
);
}

View File

@@ -22,34 +22,29 @@ import AdhocFilterControl from '.';
import AdhocFilter from '../AdhocFilter';
import { Clauses, ExpressionTypes } from '../types';
interface Column {
column_name: string;
type: string;
}
interface Database {
id: number;
}
interface Datasource {
type: string;
database: Database;
schema: string;
datasource_name: string;
}
interface Props {
interface TestProps {
name: string;
label: string;
value: AdhocFilter[];
datasource: Datasource;
columns: Column[];
datasource: {
type: string;
database: { id: number };
schema: string;
datasource_name: string;
[key: string]: unknown;
};
columns: Array<{
column_name: string;
type?: string;
[key: string]: unknown;
}>;
onChange: jest.Mock;
sections: string[];
operators: string[];
[key: string]: unknown;
}
const createProps = (): Props => ({
const createProps = (): TestProps => ({
name: 'filter_control',
label: 'Filters',
value: [],
@@ -68,10 +63,16 @@ const createProps = (): Props => ({
operators: ['==', '>', '<'],
});
const renderComponent = (props: Partial<Props> = {}) =>
render(<AdhocFilterControl {...createProps()} {...props} />, {
useDnd: true,
});
const renderComponent = (props: Partial<TestProps> = {}) =>
render(
<AdhocFilterControl
{...(createProps() as Record<string, unknown>)}
{...props}
/>,
{
useDnd: true,
},
);
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('AdhocFilterControl', () => {

View File

@@ -16,15 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component } from 'react';
import { Component, ReactNode } from 'react';
import PropTypes from 'prop-types';
import { t, logging, SupersetClient, ensureIsArray } from '@superset-ui/core';
import { withTheme } from '@apache-superset/core/ui';
import { withTheme, type SupersetTheme } from '@apache-superset/core/ui';
import ControlHeader from 'src/explore/components/ControlHeader';
import adhocMetricType from 'src/explore/components/controls/MetricControl/adhocMetricType';
import savedMetricType from 'src/explore/components/controls/MetricControl/savedMetricType';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import AdhocMetric, {
isDictionaryForAdhocMetric,
} from 'src/explore/components/controls/MetricControl/AdhocMetric';
import {
Operators,
OPERATOR_ENUM_TO_OPERATOR_TYPE,
@@ -39,12 +41,70 @@ import { Icons } from '@superset-ui/core/components/Icons';
import { Modal } from '@superset-ui/core/components';
import AdhocFilterPopoverTrigger from 'src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger';
import AdhocFilterOption from 'src/explore/components/controls/FilterControl/AdhocFilterOption';
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
import AdhocFilter, {
isDictionaryForAdhocFilter,
} from 'src/explore/components/controls/FilterControl/AdhocFilter';
import adhocFilterType from 'src/explore/components/controls/FilterControl/adhocFilterType';
import columnType from 'src/explore/components/controls/FilterControl/columnType';
import { toQueryString } from 'src/utils/urlUtils';
import { Clauses, ExpressionTypes } from '../types';
interface ColumnMeta {
column_name: string;
verbose_name?: string;
[key: string]: unknown;
}
interface SavedMetric {
metric_name: string;
expression: string;
[key: string]: unknown;
}
interface Datasource {
id?: number;
type?: string;
database?: { id: number };
datasource_name?: string;
catalog?: string;
schema?: string;
is_sqllab_view?: boolean;
[key: string]: unknown;
}
interface AdhocFilterControlProps {
label?: ReactNode;
name?: string;
sections?: string[];
operators?: string[];
onChange?: (values: AdhocFilter[]) => void;
value?: AdhocFilter[];
datasource?: Datasource;
columns?: ColumnMeta[];
savedMetrics?: SavedMetric[];
selectedMetrics?: string | AdhocMetric | (string | AdhocMetric)[];
isLoading?: boolean;
canDelete?: (
filter: AdhocFilter,
allFilters: AdhocFilter[],
) => string | boolean | undefined;
theme?: SupersetTheme;
}
interface FilterOption {
column_name?: string;
saved_metric_name?: string;
label?: string;
filterOptionName?: string;
[key: string]: unknown;
}
interface AdhocFilterControlState {
values: AdhocFilter[];
options: FilterOption[];
partitionColumn: string | null;
}
const { warning } = Modal;
const selectedMetricType = PropTypes.oneOfType([
@@ -78,51 +138,55 @@ const defaultProps = {
selectedMetrics: [],
};
function isDictionaryForAdhocFilter(value) {
return value && !(value instanceof AdhocFilter) && value.expressionType;
}
function optionsForSelect(props) {
function optionsForSelect(props: AdhocFilterControlProps): FilterOption[] {
const options = [
...props.columns,
...(props.columns || []),
...ensureIsArray(props.selectedMetrics).map(
metric =>
metric &&
(typeof metric === 'string'
? { saved_metric_name: metric }
: new AdhocMetric(metric)),
: isDictionaryForAdhocMetric(metric)
? new AdhocMetric(metric)
: metric),
),
].filter(option => option);
return options
.reduce((results, option) => {
if (option.saved_metric_name) {
.reduce<FilterOption[]>((results, option) => {
if ((option as FilterOption).saved_metric_name) {
results.push({
...option,
filterOptionName: option.saved_metric_name,
...(option as FilterOption),
filterOptionName: (option as FilterOption).saved_metric_name,
});
} else if (option.column_name) {
} else if ((option as FilterOption).column_name) {
results.push({
...option,
filterOptionName: `_col_${option.column_name}`,
...(option as FilterOption),
filterOptionName: `_col_${(option as FilterOption).column_name}`,
});
} else if (option instanceof AdhocMetric) {
results.push({
...option,
filterOptionName: `_adhocmetric_${option.label}`,
});
} as FilterOption);
}
return results;
}, [])
.sort((a, b) =>
(a.saved_metric_name || a.column_name || a.label).localeCompare(
b.saved_metric_name || b.column_name || b.label,
.sort((a: FilterOption, b: FilterOption) =>
(a.saved_metric_name || a.column_name || a.label || '').localeCompare(
b.saved_metric_name || b.column_name || b.label || '',
),
);
}
class AdhocFilterControl extends Component {
constructor(props) {
class AdhocFilterControl extends Component<
AdhocFilterControlProps,
AdhocFilterControlState
> {
optionRenderer: (option: FilterOption) => JSX.Element;
valueRenderer: (adhocFilter: AdhocFilter, index: number) => JSX.Element;
constructor(props: AdhocFilterControlProps) {
super(props);
this.onRemoveFilter = this.onRemoveFilter.bind(this);
this.onNewFilter = this.onNewFilter.bind(this);
@@ -146,14 +210,14 @@ class AdhocFilterControl extends Component {
onFilterEdit={this.onFilterEdit}
options={this.state.options}
sections={this.props.sections}
operators={this.props.operators}
operators={this.props.operators as Operators[] | undefined}
datasource={this.props.datasource}
onRemoveFilter={e => {
e.stopPropagation();
this.onRemoveFilter(index);
}}
onMoveLabel={this.moveLabel}
onDropLabel={() => this.props.onChange(this.state.values)}
onDropLabel={() => this.props.onChange?.(this.state.values)}
partitionColumn={this.state.partitionColumn}
/>
);
@@ -206,7 +270,7 @@ class AdhocFilterControl extends Component {
}
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps: AdhocFilterControlProps): void {
if (this.props.columns !== prevProps.columns) {
this.setState({ options: optionsForSelect(this.props) });
}
@@ -219,17 +283,17 @@ class AdhocFilterControl extends Component {
}
}
removeFilter(index) {
removeFilter(index: number): void {
const valuesCopy = [...this.state.values];
valuesCopy.splice(index, 1);
this.setState(prevState => ({
...prevState,
values: valuesCopy,
}));
this.props.onChange(valuesCopy);
this.props.onChange?.(valuesCopy);
}
onRemoveFilter(index) {
onRemoveFilter(index: number): void {
const { canDelete } = this.props;
const { values } = this.state;
const result = canDelete?.(values[index], values);
@@ -240,7 +304,7 @@ class AdhocFilterControl extends Component {
this.removeFilter(index);
}
onNewFilter(newFilter) {
onNewFilter(newFilter: FilterOption | AdhocFilter): void {
const mappedOption = this.mapOption(newFilter);
if (mappedOption) {
this.setState(
@@ -249,14 +313,14 @@ class AdhocFilterControl extends Component {
values: [...prevState.values, mappedOption],
}),
() => {
this.props.onChange(this.state.values);
this.props.onChange?.(this.state.values);
},
);
}
}
onFilterEdit(changedFilter) {
this.props.onChange(
onFilterEdit(changedFilter: AdhocFilter): void {
this.props.onChange?.(
this.state.values.map(value => {
if (value.filterOptionName === changedFilter.filterOptionName) {
return changedFilter;
@@ -266,20 +330,21 @@ class AdhocFilterControl extends Component {
);
}
onChange(opts) {
onChange(opts: FilterOption[] | null): void {
const options = (opts || [])
.map(option => this.mapOption(option))
.filter(option => option);
this.props.onChange(options);
.filter((option): option is AdhocFilter => option !== null);
this.props.onChange?.(options);
}
getMetricExpression(savedMetricName) {
return this.props.savedMetrics.find(
getMetricExpression(savedMetricName: string): string {
const metric = this.props.savedMetrics?.find(
savedMetric => savedMetric.metric_name === savedMetricName,
).expression;
);
return metric?.expression ?? '';
}
moveLabel(dragIndex, hoverIndex) {
moveLabel(dragIndex: number, hoverIndex: number): void {
const { values } = this.state;
const newValues = [...values];
@@ -290,7 +355,7 @@ class AdhocFilterControl extends Component {
this.setState({ values: newValues });
}
mapOption(option) {
mapOption(option: FilterOption | AdhocFilter): AdhocFilter | null {
// already a AdhocFilter, skip
if (option instanceof AdhocFilter) {
return option;
@@ -331,16 +396,16 @@ class AdhocFilterControl extends Component {
return null;
}
addNewFilterPopoverTrigger(trigger) {
addNewFilterPopoverTrigger(trigger: ReactNode): JSX.Element {
return (
<AdhocFilterPopoverTrigger
operators={this.props.operators}
operators={this.props.operators as Operators[] | undefined}
sections={this.props.sections}
adhocFilter={new AdhocFilter({})}
datasource={this.props.datasource}
datasource={(this.props.datasource as Record<string, unknown>) || {}}
options={this.state.options}
onFilterEdit={this.onNewFilter}
partitionColumn={this.state.partitionColumn}
partitionColumn={this.state.partitionColumn ?? undefined}
>
{trigger}
</AdhocFilterPopoverTrigger>
@@ -373,7 +438,10 @@ class AdhocFilterControl extends Component {
}
}
// Static properties are defined in the class using static keyword
// @ts-expect-error - propTypes are defined for runtime validation but TypeScript handles type checking
AdhocFilterControl.propTypes = propTypes;
// @ts-expect-error - defaultProps for backward compatibility with PropTypes
AdhocFilterControl.defaultProps = defaultProps;
export default withTheme(AdhocFilterControl);

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type React from 'react';
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { AGGREGATES } from 'src/explore/constants';
@@ -39,7 +40,8 @@ const sqlAdhocFilter = new AdhocFilter({
});
const faultyAdhocFilter = new AdhocFilter({
expressionType: null,
// Use undefined for faulty expressionType to trigger error state
expressionType: undefined,
subject: null,
operator: '>',
comparator: '10',
@@ -69,10 +71,20 @@ const defaultProps = {
datasource: {},
};
const renderPopover = (props = {}) =>
render(<AdhocFilterEditPopover {...defaultProps} {...props} />, {
useRedux: true, // Add Redux provider for context
});
// Cast props to handle AdhocMetric type in options array
type AdhocFilterEditPopoverComponentProps = React.ComponentProps<
typeof AdhocFilterEditPopover
>;
const renderPopover = (props: Partial<typeof defaultProps> = {}) =>
render(
<AdhocFilterEditPopover
{...(defaultProps as unknown as AdhocFilterEditPopoverComponentProps)}
{...(props as unknown as Partial<AdhocFilterEditPopoverComponentProps>)}
/>,
{
useRedux: true, // Add Redux provider for context
},
);
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('AdhocFilterEditPopover', () => {
@@ -123,7 +135,7 @@ describe('AdhocFilterEditPopover', () => {
fireEvent.change(sqlInput, { target: { value: 'COUNT(*) > 0' } });
// Wait for validation to complete
await screen.findByRole('button', { name: /save/i, disabled: false });
await screen.findByRole('button', { name: /save/i });
// Click save button
const saveButton = screen.getByRole('button', { name: /save/i });

View File

@@ -16,8 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { createRef, Component } from 'react';
import type React from 'react';
import { createRef, Component, type RefObject } from 'react';
import PropTypes from 'prop-types';
import type { SupersetTheme } from '@apache-superset/core/ui';
import { Button, Icons, Select } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components';
import { t, SupersetClient } from '@superset-ui/core';
@@ -29,14 +31,54 @@ import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilt
import AdhocFilterEditPopoverSimpleTabContent from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent';
import AdhocFilterEditPopoverSqlTabContent from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSqlTabContent';
import columnType from 'src/explore/components/controls/FilterControl/columnType';
import type { Dataset } from '@superset-ui/chart-controls';
import type { ColumnType } from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent';
import {
POPOVER_INITIAL_HEIGHT,
POPOVER_INITIAL_WIDTH,
Operators,
} from 'src/explore/constants';
import rison from 'rison';
import { isObject } from 'lodash';
import { ExpressionTypes } from '../types';
interface LayerOption {
id: number | null;
value: number;
label: string;
}
interface FilterOption {
column_name?: string;
saved_metric_name?: string;
[key: string]: unknown;
}
interface AdhocFilterEditPopoverProps {
adhocFilter: AdhocFilter;
onChange: (filter: AdhocFilter) => void;
onClose: () => void;
onResize: () => void;
options: FilterOption[];
datasource?: Record<string, unknown>;
partitionColumn?: string;
theme?: SupersetTheme;
sections?: string[];
operators?: string[];
requireSave?: boolean;
}
interface AdhocFilterEditPopoverState {
adhocFilter: AdhocFilter;
width: number;
height: number;
activeKey: string;
isSimpleTabValid: boolean;
selectedLayers: LayerOption[];
layerOptions: LayerOption[];
hasLayerFilterScopeChanged: boolean;
}
const propTypes = {
adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired,
onChange: PropTypes.func.isRequired,
@@ -94,8 +136,21 @@ const LayerSelectContainer = styled.div`
margin-bottom: ${({ theme }) => theme.sizeUnit * 12}px;
`;
export default class AdhocFilterEditPopover extends Component {
constructor(props) {
export default class AdhocFilterEditPopover extends Component<
AdhocFilterEditPopoverProps,
AdhocFilterEditPopoverState
> {
popoverContentRef: RefObject<HTMLDivElement>;
dragStartX = 0;
dragStartY = 0;
dragStartWidth = 0;
dragStartHeight = 0;
constructor(props: AdhocFilterEditPopoverProps) {
super(props);
this.onSave = this.onSave.bind(this);
this.onDragDown = this.onDragDown.bind(this);
@@ -126,13 +181,15 @@ export default class AdhocFilterEditPopover extends Component {
document.addEventListener('mouseup', this.onMouseUp);
// Load layer options if deck_slices exist
if (
this.props.adhocFilter?.deck_slices &&
this.props.adhocFilter.deck_slices.length > 0
) {
const deckSlices = this.props.adhocFilter?.deck_slices as
| number[]
| undefined;
if (deckSlices && deckSlices.length > 0) {
this.loadLayerOptions(0, 100).then(result => {
this.setState({ layerOptions: result.data });
const layerFilterScope = this.props.adhocFilter?.layerFilterScope;
const layerFilterScope = this.props.adhocFilter?.layerFilterScope as
| number[]
| undefined;
if (layerFilterScope) {
const selectedLayers = layerFilterScope.map(item => {
const layerOption = result.data.find(
@@ -140,7 +197,9 @@ export default class AdhocFilterEditPopover extends Component {
);
return layerOption;
});
this.setState({ selectedLayers });
this.setState({
selectedLayers: selectedLayers.filter(Boolean) as LayerOption[],
});
}
});
}
@@ -151,18 +210,19 @@ export default class AdhocFilterEditPopover extends Component {
document.removeEventListener('mousemove', this.onMouseMove);
}
onAdhocFilterChange(adhocFilter) {
onAdhocFilterChange(adhocFilter: AdhocFilter): void {
this.setState({ adhocFilter });
}
setSimpleTabIsValid(isValid) {
setSimpleTabIsValid(isValid: boolean): void {
this.setState({ isSimpleTabValid: isValid });
}
onSave() {
const hasDeckSlices =
this.state.adhocFilter.deck_slices &&
this.state.adhocFilter.deck_slices.length > 0;
const deckSlices = this.state.adhocFilter.deck_slices as
| number[]
| undefined;
const hasDeckSlices = deckSlices && deckSlices.length > 0;
if (!hasDeckSlices) {
this.props.onChange(this.state.adhocFilter);
@@ -176,16 +236,15 @@ export default class AdhocFilterEditPopover extends Component {
}
return item;
});
const correctedAdhocFilter = {
...this.state.adhocFilter,
const correctedAdhocFilter = this.state.adhocFilter.duplicateWith({
layerFilterScope: selectedLayers,
};
});
this.setState({ hasLayerFilterScopeChanged: false });
this.props.onChange(correctedAdhocFilter);
this.props.onClose();
}
onDragDown(e) {
onDragDown(e: React.MouseEvent): void {
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragStartWidth = this.state.width;
@@ -193,7 +252,7 @@ export default class AdhocFilterEditPopover extends Component {
document.addEventListener('mousemove', this.onMouseMove);
}
onMouseMove(e) {
onMouseMove(e: MouseEvent): void {
this.props.onResize();
this.setState({
width: Math.max(
@@ -211,17 +270,17 @@ export default class AdhocFilterEditPopover extends Component {
document.removeEventListener('mousemove', this.onMouseMove);
}
onTabChange(activeKey) {
onTabChange(activeKey: string) {
this.setState({
activeKey,
});
}
adjustHeight(heightDifference) {
adjustHeight(heightDifference: number) {
this.setState(state => ({ height: state.height + heightDifference }));
}
loadLayerOptions(page, pageSize) {
loadLayerOptions(page: number, pageSize: number) {
const query = rison.encode({
columns: ['id', 'slice_name', 'viz_type'],
filters: [{ col: 'viz_type', opr: 'sw', value: 'deck' }],
@@ -247,7 +306,8 @@ export default class AdhocFilterEditPopover extends Component {
};
}
const deckSlices = this.props.adhocFilter?.deck_slices || [];
const deckSlices = (this.props.adhocFilter?.deck_slices ||
[]) as number[];
const list = [
{
@@ -256,7 +316,7 @@ export default class AdhocFilterEditPopover extends Component {
label: 'All',
},
...response.json.result
.map(item => {
.map((item: { id: number; slice_name: string }) => {
const sliceIndex = deckSlices.indexOf(item.id);
return {
id: item.id,
@@ -265,8 +325,18 @@ export default class AdhocFilterEditPopover extends Component {
sliceIndex,
};
})
.filter(item => item.sliceIndex !== -1)
.map(({ sliceIndex, ...item }) => item),
.filter((item: { sliceIndex: number }) => item.sliceIndex !== -1)
.map(
({
sliceIndex,
...item
}: {
sliceIndex: number;
id: number;
value: number;
label: string;
}) => item,
),
];
return {
@@ -276,24 +346,29 @@ export default class AdhocFilterEditPopover extends Component {
});
}
onLayerChange(selectedValue) {
let updatedSelectedLayers = selectedValue;
onLayerChange(selectedValue: LayerOption[] | number[] | null) {
let updatedSelectedLayers: LayerOption[] =
(selectedValue as LayerOption[]) || [];
if (!selectedValue || selectedValue.length === 0) {
updatedSelectedLayers = [{ id: null, value: -1, label: 'All' }];
} else if (
selectedValue.length > 1 &&
selectedValue.some(item => item.value === -1 || item === -1)
selectedValue.some(
(item: LayerOption | number) =>
(typeof item === 'object' && item.value === -1) || item === -1,
)
) {
const lastItem = selectedValue[selectedValue.length - 1];
if (
selectedValue[selectedValue.length - 1].value === -1 ||
selectedValue[selectedValue.length - 1] === -1
(typeof lastItem === 'object' && lastItem.value === -1) ||
lastItem === -1
) {
updatedSelectedLayers = [{ id: null, value: -1, label: 'All' }];
} else {
updatedSelectedLayers = selectedValue
.filter(item => item.value !== -1)
.filter(item => item !== -1);
updatedSelectedLayers = (selectedValue as LayerOption[]).filter(
(item: LayerOption) => item.value !== -1,
);
}
}
@@ -324,8 +399,8 @@ export default class AdhocFilterEditPopover extends Component {
!adhocFilter.equals(propsAdhocFilter) ||
hasLayerFilterScopeChanged;
const hasDeckSlices =
adhocFilter.deck_slices && adhocFilter.deck_slices.length > 0;
const renderDeckSlices = adhocFilter.deck_slices as number[] | undefined;
const hasDeckSlices = renderDeckSlices && renderDeckSlices.length > 0;
return (
<FilterPopoverContentContainer
@@ -349,11 +424,11 @@ export default class AdhocFilterEditPopover extends Component {
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSimpleTabContent
operators={operators}
operators={operators as Operators[] | undefined}
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
options={options}
datasource={datasource}
options={options as ColumnType[]}
datasource={datasource as unknown as Dataset}
onHeightChange={this.adjustHeight}
partitionColumn={partitionColumn}
popoverRef={this.popoverContentRef.current}
@@ -372,7 +447,6 @@ export default class AdhocFilterEditPopover extends Component {
onChange={this.onAdhocFilterChange}
options={this.props.options}
height={this.state.height}
activeKey={this.state.activeKey}
datasource={datasource}
/>
</ErrorBoundary>
@@ -384,7 +458,9 @@ export default class AdhocFilterEditPopover extends Component {
<LayerSelectContainer>
<Select
options={this.state.layerOptions}
onChange={this.onLayerChange}
onChange={
this.onLayerChange as unknown as (value: unknown) => void
}
value={selectedLayers}
mode="multiple"
/>
@@ -427,4 +503,5 @@ export default class AdhocFilterEditPopover extends Component {
}
}
// @ts-expect-error - propTypes are defined for runtime validation but TypeScript handles type checking
AdhocFilterEditPopover.propTypes = propTypes;

View File

@@ -41,6 +41,7 @@ import fetchMock from 'fetch-mock';
import { TestDataset, Dataset } from '@superset-ui/chart-controls';
import AdhocFilterEditPopoverSimpleTabContent, {
useSimpleTabFilterProps,
Props,
} from '.';
import { Clauses, ExpressionTypes } from '../types';
@@ -56,10 +57,10 @@ const simpleAdhocFilter = new AdhocFilter({
const advancedTypeTestAdhocFilterTest = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'advancedDataType',
operatorId: null,
operator: null,
comparator: null,
clause: null,
operatorId: undefined,
operator: undefined,
comparator: undefined,
clause: undefined,
});
const simpleMultiAdhocFilter = new AdhocFilter({
@@ -93,7 +94,7 @@ const options = [
sumValueAdhocMetric,
];
const getAdvancedDataTypeTestProps = (overrides?: Record<string, any>) => {
const getAdvancedDataTypeTestProps = (overrides?: Record<string, unknown>) => {
const onChange = sinon.spy();
const validHandler = sinon.spy();
const props = {
@@ -113,7 +114,7 @@ const getAdvancedDataTypeTestProps = (overrides?: Record<string, any>) => {
return props;
};
function setup(overrides?: Record<string, any>) {
function setup(overrides?: Record<string, unknown>) {
const onChange = sinon.spy();
const validHandler = sinon.spy();
const spy = jest.spyOn(redux, 'useSelector');
@@ -132,7 +133,9 @@ function setup(overrides?: Record<string, any>) {
...overrides,
validHandler,
};
render(<AdhocFilterEditPopoverSimpleTabContent {...props} />);
render(
<AdhocFilterEditPopoverSimpleTabContent {...(props as unknown as Props)} />,
);
return props;
}
@@ -193,10 +196,10 @@ test('shows boolean only operators when subject is boolean', () => {
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
operatorId: null,
operator: null,
comparator: null,
clause: null,
operatorId: undefined,
operator: undefined,
comparator: undefined,
clause: undefined,
}),
datasource: {
columns: [
@@ -208,7 +211,9 @@ test('shows boolean only operators when subject is boolean', () => {
],
},
});
const { isOperatorRelevant } = useSimpleTabFilterProps(props);
const { isOperatorRelevant } = useSimpleTabFilterProps(
props as unknown as Props,
);
[
Operators.IsTrue,
Operators.IsFalse,
@@ -222,10 +227,10 @@ test('shows boolean only operators when subject is number', () => {
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
operatorId: null,
operator: null,
comparator: null,
clause: null,
operatorId: undefined,
operator: undefined,
comparator: undefined,
clause: undefined,
}),
datasource: {
columns: [
@@ -237,7 +242,9 @@ test('shows boolean only operators when subject is number', () => {
],
},
});
const { isOperatorRelevant } = useSimpleTabFilterProps(props);
const { isOperatorRelevant } = useSimpleTabFilterProps(
props as unknown as Props,
);
[
Operators.IsTrue,
Operators.IsFalse,
@@ -248,7 +255,9 @@ test('shows boolean only operators when subject is number', () => {
test('will convert from individual comparator to array if the operator changes to multi', () => {
const props = setup();
const { onOperatorChange } = useSimpleTabFilterProps(props);
const { onOperatorChange } = useSimpleTabFilterProps(
props as unknown as Props,
);
onOperatorChange(Operators.In);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0].comparator).toEqual(['10']);
@@ -259,7 +268,9 @@ test('will convert from array to individual comparators if the operator changes
const props = setup({
adhocFilter: simpleMultiAdhocFilter,
});
const { onOperatorChange } = useSimpleTabFilterProps(props);
const { onOperatorChange } = useSimpleTabFilterProps(
props as unknown as Props,
);
onOperatorChange(Operators.LessThan);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0]).toEqual(
@@ -273,7 +284,9 @@ test('will convert from array to individual comparators if the operator changes
test('passes the new adhocFilter to onChange after onComparatorChange', () => {
const props = setup();
const { onComparatorChange } = useSimpleTabFilterProps(props);
const { onComparatorChange } = useSimpleTabFilterProps(
props as unknown as Props,
);
onComparatorChange('20');
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0]).toEqual(
@@ -283,7 +296,9 @@ test('passes the new adhocFilter to onChange after onComparatorChange', () => {
test('will filter operators for table datasources', () => {
const props = setup({ datasource: { type: 'table' as const } });
const { isOperatorRelevant } = useSimpleTabFilterProps(props);
const { isOperatorRelevant } = useSimpleTabFilterProps(
props as unknown as Props,
);
expect(isOperatorRelevant(Operators.Like, 'value')).toBe(true);
});
@@ -297,7 +312,9 @@ test('will show LATEST PARTITION operator', () => {
adhocFilter: simpleCustomFilter,
partitionColumn: 'ds',
});
const { isOperatorRelevant } = useSimpleTabFilterProps(props);
const { isOperatorRelevant } = useSimpleTabFilterProps(
props as unknown as Props,
);
expect(isOperatorRelevant(Operators.LatestPartition, 'ds')).toBe(true);
expect(isOperatorRelevant(Operators.LatestPartition, 'value')).toBe(false);
});
@@ -316,7 +333,9 @@ test('will generate custom sqlExpression for LATEST PARTITION operator', () => {
adhocFilter: testAdhocFilter,
partitionColumn: 'ds',
});
const { onOperatorChange } = useSimpleTabFilterProps(props);
const { onOperatorChange } = useSimpleTabFilterProps(
props as unknown as Props,
);
onOperatorChange(Operators.LatestPartition);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0]).toEqual(
@@ -342,7 +361,9 @@ test('will not display boolean operators when column type is string', () => {
},
adhocFilter: simpleAdhocFilter,
});
const { isOperatorRelevant } = useSimpleTabFilterProps(props);
const { isOperatorRelevant } = useSimpleTabFilterProps(
props as unknown as Props,
);
const booleanOnlyOperators = [Operators.IsTrue, Operators.IsFalse];
booleanOnlyOperators.forEach(operator => {
expect(isOperatorRelevant(operator, 'value')).toBe(false);
@@ -364,7 +385,9 @@ test('will display boolean operators when column is an expression', () => {
},
adhocFilter: simpleAdhocFilter,
});
const { isOperatorRelevant } = useSimpleTabFilterProps(props);
const { isOperatorRelevant } = useSimpleTabFilterProps(
props as unknown as Props,
);
const booleanOnlyOperators = [Operators.IsTrue, Operators.IsFalse];
booleanOnlyOperators.forEach(operator => {
expect(isOperatorRelevant(operator, 'value')).toBe(true);
@@ -373,7 +396,9 @@ test('will display boolean operators when column is an expression', () => {
test('sets comparator to undefined when operator is IS_TRUE', () => {
const props = setup();
const { onOperatorChange } = useSimpleTabFilterProps(props);
const { onOperatorChange } = useSimpleTabFilterProps(
props as unknown as Props,
);
onOperatorChange(Operators.IsTrue);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0].operatorId).toBe(Operators.IsTrue);
@@ -383,7 +408,9 @@ test('sets comparator to undefined when operator is IS_TRUE', () => {
test('sets comparator to undefined when operator is IS_FALSE', () => {
const props = setup();
const { onOperatorChange } = useSimpleTabFilterProps(props);
const { onOperatorChange } = useSimpleTabFilterProps(
props as unknown as Props,
);
onOperatorChange(Operators.IsFalse);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0].operatorId).toBe(Operators.IsFalse);
@@ -393,7 +420,9 @@ test('sets comparator to undefined when operator is IS_FALSE', () => {
test('sets comparator to undefined when operator is IS_NULL or IS_NOT_NULL', () => {
const props = setup();
const { onOperatorChange } = useSimpleTabFilterProps(props);
const { onOperatorChange } = useSimpleTabFilterProps(
props as unknown as Props,
);
[Operators.IsNull, Operators.IsNotNull].forEach(op => {
onOperatorChange(op);
expect(props.onChange.called).toBe(true);
@@ -409,9 +438,14 @@ test('should not call API when column has no advanced data type', async () => {
const props = getAdvancedDataTypeTestProps();
await act(async () => {
render(<AdhocFilterEditPopoverSimpleTabContent {...props} />, {
store,
});
render(
<AdhocFilterEditPopoverSimpleTabContent
{...(props as unknown as Props)}
/>,
{
store,
},
);
});
const filterValueField = screen.getByPlaceholderText(
@@ -443,9 +477,14 @@ test('should call API when column has advanced data type', async () => {
});
await act(async () => {
render(<AdhocFilterEditPopoverSimpleTabContent {...props} />, {
store,
});
render(
<AdhocFilterEditPopoverSimpleTabContent
{...(props as unknown as Props)}
/>,
{
store,
},
);
});
const filterValueField = screen.getByPlaceholderText(
@@ -478,9 +517,14 @@ test('save button should be disabled if error message from API is returned', asy
});
await act(async () => {
render(<AdhocFilterEditPopoverSimpleTabContent {...props} />, {
store,
});
render(
<AdhocFilterEditPopoverSimpleTabContent
{...(props as unknown as Props)}
/>,
{
store,
},
);
});
const filterValueField = screen.getByPlaceholderText(
@@ -515,9 +559,14 @@ test('advanced data type operator list should update after API response', async
});
await act(async () => {
render(<AdhocFilterEditPopoverSimpleTabContent {...props} />, {
store,
});
render(
<AdhocFilterEditPopoverSimpleTabContent
{...(props as unknown as Props)}
/>,
{
store,
},
);
});
const filterValueField = screen.getByPlaceholderText(
@@ -581,7 +630,9 @@ test('dropdown should remain open when clicked after filter is configured', asyn
validHandler,
};
render(<AdhocFilterEditPopoverSimpleTabContent {...props} />);
render(
<AdhocFilterEditPopoverSimpleTabContent {...(props as unknown as Props)} />,
);
const operatorDropdown = screen.getByRole('combobox', {
name: 'Select operator',

View File

@@ -18,7 +18,13 @@
*/
import { FC, ChangeEvent, useEffect, useState, useRef } from 'react';
import { Input, InputRef, Select, Tooltip } from '@superset-ui/core/components';
import {
Input,
InputRef,
Select,
Tooltip,
type SelectValue,
} from '@superset-ui/core/components';
import {
isFeatureEnabled,
FeatureFlag,
@@ -87,9 +93,11 @@ export interface Props {
onChange: (filter: AdhocFilter) => void;
options: ColumnType[];
datasource: Dataset;
partitionColumn: string;
partitionColumn?: string;
operators?: Operators[];
validHandler: (isValid: boolean) => void;
onHeightChange?: (heightDifference: number) => void;
popoverRef?: HTMLDivElement | null;
}
export interface AdvancedDataTypesState {
@@ -151,7 +159,9 @@ export const useSimpleTabFilterProps = (props: Props) => {
}
let { operator, operatorId, comparator } = props.adhocFilter;
operator =
operator && operatorId && isOperatorRelevant(operatorId, subject)
operator &&
operatorId &&
isOperatorRelevant(operatorId as Operators, subject)
? OPERATOR_ENUM_TO_OPERATOR_TYPE[
operatorId as keyof typeof OPERATOR_ENUM_TO_OPERATOR_TYPE
].operation
@@ -290,7 +300,17 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
};
const renderSubjectOptionLabel = (option: ColumnType) => (
<FilterDefinitionOption option={option} />
<FilterDefinitionOption
option={
option as unknown as {
column_name?: string;
saved_metric_name?: string;
label?: string;
type?: string;
[key: string]: unknown;
}
}
/>
);
const getOptionsRemaining = () => {
@@ -314,9 +334,16 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
let columns = props.options;
const { subject, operator, operatorId } = props.adhocFilter;
const subjectValue =
typeof subject === 'string'
? subject
: subject && 'column_name' in subject
? subject.column_name
: undefined;
const subjectSelectProps = {
ariaLabel: t('Select subject'),
value: subject ?? undefined,
value: subjectValue,
onChange: handleSubjectChange,
notFoundContent: t(
'No such column found. To filter on a metric, try the Custom SQL tab.',
@@ -333,11 +360,12 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
option => 'column_name' in option && option.column_name,
);
const subjectString = typeof subject === 'string' ? subject : '';
const operatorSelectProps = {
placeholder: t(
'%s operator(s)',
(props.operators ?? OPERATORS_OPTIONS).filter(op =>
isOperatorRelevantWrapper(op, subject),
isOperatorRelevantWrapper(op, subjectString),
).length,
),
value: operatorId,
@@ -353,25 +381,35 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
allowClear: true,
allowNewOptions: true,
ariaLabel: t('Comparator option'),
mode: MULTI_OPERATORS.has(operatorId)
? ('multiple' as const)
: ('single' as const),
mode:
operatorId && MULTI_OPERATORS.has(operatorId as Operators)
? ('multiple' as const)
: ('single' as const),
loading: loadingComparatorSuggestions,
value: comparator,
value: comparator as SelectValue,
onChange: onComparatorChange,
notFoundContent: t('Type a value here'),
disabled: DISABLE_INPUT_OPERATORS.includes(operatorId),
disabled:
operatorId !== undefined &&
DISABLE_INPUT_OPERATORS.includes(operatorId as Operators),
placeholder: createSuggestionsPlaceholder(),
};
const labelText =
comparator && comparator.length > 0 && createSuggestionsPlaceholder();
const comparatorHasValue =
comparator &&
(Array.isArray(comparator)
? comparator.length > 0
: String(comparator).length > 0);
const labelText = comparatorHasValue ? createSuggestionsPlaceholder() : '';
const datePicker = useDatePickerInAdhocFilter({
columnName: props.adhocFilter.subject,
columnName:
typeof props.adhocFilter.subject === 'string'
? props.adhocFilter.subject
: undefined,
timeRange:
props.adhocFilter.operator === Operators.TemporalRange
? props.adhocFilter.comparator
? (props.adhocFilter.comparator as string | undefined)
: undefined,
datasource: props.datasource,
onChange: onDatePickerChange,
@@ -441,8 +479,14 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
useEffect(() => {
if (isFeatureEnabled(FeatureFlag.EnableAdvancedDataTypes)) {
const comparatorValue =
comparator === undefined
? ''
: typeof comparator === 'string'
? comparator
: String(comparator);
fetchAdvancedDataTypeValueCallback(
comparator === undefined ? '' : comparator,
comparatorValue,
advancedDataTypesState,
subjectAdvancedDataType,
);
@@ -501,7 +545,7 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
<>
<Select
options={(props.operators ?? OPERATORS_OPTIONS)
.filter(op => isOperatorRelevantWrapper(op, subject))
.filter(op => isOperatorRelevantWrapper(op, subjectString))
.map((option, index) => ({
value: option,
label: OPERATOR_ENUM_TO_OPERATOR_TYPE[option].display,
@@ -510,7 +554,8 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
}))}
{...operatorSelectProps}
/>
{MULTI_OPERATORS.has(operatorId) || suggestions.length > 0 ? (
{(operatorId && MULTI_OPERATORS.has(operatorId as Operators)) ||
suggestions.length > 0 ? (
<Tooltip
title={
advancedDataTypesState.errorMessage ||
@@ -543,9 +588,12 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
name="filter-value"
ref={comparatorInputRef}
onChange={onInputComparatorChange}
value={comparator}
value={typeof comparator === 'string' ? comparator : undefined}
placeholder={t('Filter value (case sensitive)')}
disabled={DISABLE_INPUT_OPERATORS.includes(operatorId)}
disabled={
operatorId !== undefined &&
DISABLE_INPUT_OPERATORS.includes(operatorId as Operators)
}
/>
</Tooltip>
)}

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type React from 'react';
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
import { DndItemType } from 'src/explore/components/DndItemType';
import AdhocFilterPopoverTrigger from 'src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger';
@@ -26,14 +27,14 @@ import { useGetTimeRangeLabel } from '../utils';
export interface AdhocFilterOptionProps {
adhocFilter: AdhocFilter;
onFilterEdit: () => void;
onRemoveFilter: () => void;
onFilterEdit: (editedFilter: AdhocFilter) => void;
onRemoveFilter: (e: React.MouseEvent) => void;
options: OptionSortType[];
sections: string[];
operators: Operators[];
datasource: Record<string, any>;
partitionColumn: string;
onMoveLabel: () => void;
sections?: string[];
operators?: Operators[];
datasource?: Record<string, unknown>;
partitionColumn?: string | null;
onMoveLabel: (dragIndex: number, hoverIndex: number) => void;
onDropLabel: () => void;
index: number;
}
@@ -59,14 +60,18 @@ export default function AdhocFilterOption({
operators={operators}
adhocFilter={adhocFilter}
options={options}
datasource={datasource}
datasource={(datasource as Record<string, unknown>) || {}}
onFilterEdit={onFilterEdit}
partitionColumn={partitionColumn}
partitionColumn={partitionColumn ?? undefined}
>
<OptionControlLabel
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
onRemove={onRemoveFilter}
onRemove={() =>
onRemoveFilter({
stopPropagation: () => {},
} as React.MouseEvent<Element, MouseEvent>)
}
onMoveLabel={onMoveLabel}
onDropLabel={onDropLabel}
index={index}

View File

@@ -93,7 +93,7 @@ class AdhocFilterPopoverTrigger extends PureComponent<
datasource={this.props.datasource}
partitionColumn={this.props.partitionColumn}
onResize={this.onPopoverResize}
onClose={closePopover}
onClose={closePopover ?? (() => {})}
sections={this.props.sections}
operators={this.props.operators}
onChange={this.props.onFilterEdit}

View File

@@ -24,7 +24,7 @@ import DateFilterControl from 'src/explore/components/controls/DateFilterControl
import ControlHeader from 'src/explore/components/ControlHeader';
interface DatePickerInFilterProps {
columnName: string;
columnName?: string;
timeRange?: string;
datasource: Dataset;
onChange: (columnName: string, timeRange: string) => void;
@@ -36,7 +36,7 @@ export const useDatePickerInAdhocFilter = ({
datasource,
onChange,
}: DatePickerInFilterProps): ReactElement | undefined => {
const onTimeRangeChange = (val: string) => onChange(columnName, val);
const onTimeRangeChange = (val: string) => onChange(columnName ?? '', val);
const extensionsRegistry = getExtensionsRegistry();
@@ -45,7 +45,7 @@ export const useDatePickerInAdhocFilter = ({
);
const DateFilterComponent = DateFilterControlExtension ?? DateFilterControl;
return isTemporalColumn(columnName, datasource) ? (
return columnName && isTemporalColumn(columnName, datasource) ? (
<>
<ControlHeader label={t('Time Range')} />
<DateFilterComponent

View File

@@ -53,21 +53,22 @@ export const useGetTimeRangeLabel = (adhocFilter: AdhocFilter): Results => {
adhocFilter.comparator !== NO_TIME_RANGE &&
actualTimeRange.title !== adhocFilter.comparator
) {
fetchTimeRange(adhocFilter.comparator, adhocFilter.subject).then(
({ value, error }) => {
if (error) {
setActualTimeRange({
actualTimeRange: `${adhocFilter.subject} (${adhocFilter.comparator})`,
title: error,
});
} else {
setActualTimeRange({
actualTimeRange: value ?? '',
title: adhocFilter.comparator,
});
}
},
);
fetchTimeRange(
adhocFilter.comparator as string,
adhocFilter.subject as string,
).then(({ value, error }) => {
if (error) {
setActualTimeRange({
actualTimeRange: `${adhocFilter.subject} (${adhocFilter.comparator})`,
title: error,
});
} else {
setActualTimeRange({
actualTimeRange: value ?? '',
title: adhocFilter.comparator as string | undefined,
});
}
});
}
}, [adhocFilter]);

View File

@@ -28,7 +28,42 @@ import PopoverSection from '@superset-ui/core/components/PopoverSection';
const controlTypes = {
fixed: 'fix',
metric: 'metric',
};
} as const;
interface ControlValue {
type?: 'fix' | 'metric';
value?:
| string
| number
| { label?: string; expressionType?: string; sqlExpression?: string };
}
interface MetricValue {
label?: string;
expressionType?: string;
sqlExpression?: string;
[key: string]: unknown;
}
interface DatasourceType {
columns?: { column_name: string }[];
metrics?: { metric_name: string; expression: string }[];
[key: string]: unknown;
}
interface FixedOrMetricControlProps {
onChange?: (value: ControlValue) => void;
value?: ControlValue;
isFloat?: boolean;
datasource: DatasourceType;
default?: ControlValue;
}
interface FixedOrMetricControlState {
type: 'fix' | 'metric';
fixedValue: string | number;
metricValue: MetricValue | null;
}
const propTypes = {
onChange: PropTypes.func,
@@ -46,50 +81,60 @@ const defaultProps = {
default: { type: controlTypes.fixed, value: 5 },
};
export default class FixedOrMetricControl extends Component {
constructor(props) {
export default class FixedOrMetricControl extends Component<
FixedOrMetricControlProps,
FixedOrMetricControlState
> {
constructor(props: FixedOrMetricControlProps) {
super(props);
this.onChange = this.onChange.bind(this);
this.setType = this.setType.bind(this);
this.setFixedValue = this.setFixedValue.bind(this);
this.setMetric = this.setMetric.bind(this);
const type =
(props.value ? props.value.type : props.default.type) ||
controlTypes.fixed;
const value =
(props.value ? props.value.value : props.default.value) || '100';
const type = (props.value?.type ??
props.default?.type ??
controlTypes.fixed) as 'fix' | 'metric';
const rawValue = props.value?.value ?? props.default?.value ?? '100';
const fixedValue =
type === controlTypes.fixed && typeof rawValue !== 'object'
? rawValue
: '';
const metricValue =
type === controlTypes.metric && typeof rawValue === 'object'
? (rawValue as MetricValue)
: null;
this.state = {
type,
fixedValue: type === controlTypes.fixed ? value : '',
metricValue: type === controlTypes.metric ? value : null,
fixedValue,
metricValue,
};
}
onChange() {
this.props.onChange({
onChange(): void {
this.props.onChange?.({
type: this.state.type,
value:
this.state.type === controlTypes.fixed
? this.state.fixedValue
: this.state.metricValue,
: (this.state.metricValue ?? undefined),
});
}
setType(type) {
setType(type: 'fix' | 'metric'): void {
this.setState({ type }, this.onChange);
}
setFixedValue(fixedValue) {
setFixedValue(fixedValue: string | number): void {
this.setState({ fixedValue }, this.onChange);
}
setMetric(metricValue) {
setMetric(metricValue: MetricValue | null): void {
this.setState({ metricValue }, this.onChange);
}
render() {
const value = this.props.value || this.props.default;
const type = value.type || controlTypes.fixed;
const value = this.props.value ?? this.props.default;
const type = value?.type ?? controlTypes.fixed;
const columns = this.props.datasource
? this.props.datasource.columns
: null;
@@ -136,6 +181,7 @@ export default class FixedOrMetricControl extends Component {
onChange={this.setFixedValue}
onFocus={() => {
this.setType(controlTypes.fixed);
return {};
}}
value={this.state.fixedValue}
/>
@@ -149,8 +195,8 @@ export default class FixedOrMetricControl extends Component {
>
<MetricsControl
name="metric"
columns={columns}
savedMetrics={metrics}
columns={columns ?? undefined}
savedMetrics={metrics ?? undefined}
multi={false}
onFocus={() => {
this.setType(controlTypes.metric);
@@ -170,5 +216,7 @@ export default class FixedOrMetricControl extends Component {
}
}
// @ts-expect-error - propTypes are defined for runtime validation but TypeScript handles type checking
FixedOrMetricControl.propTypes = propTypes;
// @ts-expect-error - defaultProps for backward compatibility with PropTypes
FixedOrMetricControl.defaultProps = defaultProps;

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 {

View File

@@ -259,8 +259,8 @@ export const OptionControlLabel = ({
savedMetric?: savedMetricType;
adhocMetric?: AdhocMetric;
onRemove: () => void;
onMoveLabel: (dragIndex: number, hoverIndex: number) => void;
onDropLabel: () => void;
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
onDropLabel?: () => void;
withCaret?: boolean;
isFunction?: boolean;
isDraggable?: boolean;

View File

@@ -31,7 +31,13 @@ import SelectControl, {
getSortComparator,
} from 'src/explore/components/controls/SelectControl';
const defaultProps = {
const defaultProps: {
choices: [string | number, string][];
name: string;
label: string;
valueKey: string;
onChange: jest.Mock;
} = {
choices: [
['1 year ago', '1 year ago'],
['1 week ago', '1 week ago'],
@@ -306,14 +312,19 @@ describe('SelectControl', () => {
test('returns false for empty items', () => {
expect(areAllValuesNumbers([])).toBe(false);
// @ts-expect-error testing invalid input
expect(areAllValuesNumbers(null)).toBe(false);
// @ts-expect-error testing invalid input
expect(areAllValuesNumbers(undefined)).toBe(false);
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('getSortComparator', () => {
const mockExplicitComparator = (a, b) => a.label.localeCompare(b.label);
const mockExplicitComparator = (
a: { label: string },
b: { label: string },
) => a.label.localeCompare(b.label);
test('returns explicit comparator when provided', () => {
const choices = [
@@ -322,7 +333,7 @@ describe('SelectControl', () => {
];
const result = getSortComparator(
choices,
null,
undefined,
'value',
mockExplicitComparator,
);
@@ -334,7 +345,7 @@ describe('SelectControl', () => {
[1, 'One'],
[2, 'Two'],
];
const result = getSortComparator(choices, null, 'value', null);
const result = getSortComparator(choices, undefined, 'value', undefined);
expect(typeof result).toBe('function');
expect(result).not.toBe(mockExplicitComparator);
});
@@ -344,7 +355,7 @@ describe('SelectControl', () => {
{ value: 1, label: 'One' },
{ value: 2, label: 'Two' },
];
const result = getSortComparator(null, options, 'value', null);
const result = getSortComparator(undefined, options, 'value', undefined);
expect(typeof result).toBe('function');
expect(result).not.toBe(mockExplicitComparator);
});
@@ -358,7 +369,7 @@ describe('SelectControl', () => {
{ value: 3, label: 'Three' },
{ value: 4, label: 'Four' },
];
const result = getSortComparator(choices, options, 'value', null);
const result = getSortComparator(choices, options, 'value', undefined);
expect(typeof result).toBe('function');
});
@@ -367,7 +378,7 @@ describe('SelectControl', () => {
['one', 'One'],
['two', 'Two'],
];
const result = getSortComparator(choices, null, 'value', null);
const result = getSortComparator(choices, undefined, 'value', undefined);
expect(result).toBeUndefined();
});
@@ -376,12 +387,17 @@ describe('SelectControl', () => {
{ value: 'one', label: 'One' },
{ value: 'two', label: 'Two' },
];
const result = getSortComparator(null, options, 'value', null);
const result = getSortComparator(undefined, options, 'value', undefined);
expect(result).toBeUndefined();
});
test('returns undefined when no choices or options provided', () => {
const result = getSortComparator(null, null, 'value', null);
const result = getSortComparator(
undefined,
undefined,
'value',
undefined,
);
expect(result).toBeUndefined();
});
});

View File

@@ -16,13 +16,61 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { PureComponent, type ReactNode } from 'react';
import PropTypes from 'prop-types';
import { isEqualArray, t } from '@superset-ui/core';
import { css } from '@apache-superset/core/ui';
import { Select } from '@superset-ui/core/components';
import ControlHeader from 'src/explore/components/ControlHeader';
type SelectValue = string | number | (string | number)[] | null | undefined;
interface SelectOption {
value: string | number;
label: string;
[key: string]: unknown;
}
export interface SelectControlProps {
ariaLabel?: string;
autoFocus?: boolean;
choices?: [string | number, string][];
clearable?: boolean;
description?: string | ReactNode;
disabled?: boolean;
freeForm?: boolean;
isLoading?: boolean;
mode?: string;
multi?: boolean;
isMulti?: boolean;
name: string;
onChange?: (value: SelectValue, options?: unknown[]) => void;
onFocus?: () => void;
onSelect?: (value: SelectValue) => void;
onDeselect?: (value: SelectValue) => void;
value?: SelectValue;
default?: SelectValue;
showHeader?: boolean;
optionRenderer?: (option: unknown) => ReactNode;
valueKey?: string;
options?: { value: string | number; label: string; [key: string]: unknown }[];
placeholder?: string;
filterOption?: (input: unknown, option: unknown) => boolean;
tokenSeparators?: string[];
notFoundContent?: ReactNode;
label?: string;
renderTrigger?: boolean;
validationErrors?: string[];
rightNode?: ReactNode;
leftNode?: ReactNode;
onClick?: () => void;
hovered?: boolean;
tooltipOnClick?: () => void;
warning?: string;
danger?: string;
sortComparator?: (a: SelectOption, b: SelectOption) => number;
}
const propTypes = {
ariaLabel: PropTypes.string,
autoFocus: PropTypes.bool,
@@ -88,9 +136,17 @@ const defaultProps = {
valueKey: 'value',
};
const numberComparator = (a, b) => a.value - b.value;
interface SelectControlState {
options: SelectOption[];
}
export const areAllValuesNumbers = (items, valueKey = 'value') => {
const numberComparator = (a: SelectOption, b: SelectOption): number =>
(a.value as number) - (b.value as number);
export const areAllValuesNumbers = (
items: unknown[],
valueKey = 'value',
): boolean => {
if (!items || items.length === 0) {
return false;
}
@@ -100,18 +156,22 @@ export const areAllValuesNumbers = (items, valueKey = 'value') => {
return typeof value === 'number';
}
if (typeof item === 'object' && item !== null) {
return typeof item[valueKey] === 'number';
return typeof (item as Record<string, unknown>)[valueKey] === 'number';
}
return typeof item === 'number';
});
};
type SortComparator =
| ((a: SelectOption, b: SelectOption) => number)
| undefined;
export const getSortComparator = (
choices,
options,
valueKey,
explicitComparator,
) => {
choices: unknown[] | undefined,
options: unknown[] | undefined,
valueKey: string | undefined,
explicitComparator: SortComparator,
): SortComparator => {
if (explicitComparator) {
return explicitComparator;
}
@@ -126,14 +186,16 @@ export const getSortComparator = (
return undefined;
};
export const innerGetOptions = props => {
const { choices, optionRenderer, valueKey } = props;
let options = [];
export const innerGetOptions = (props: SelectControlProps): SelectOption[] => {
const { choices, optionRenderer, valueKey = 'value' } = props;
let options: SelectOption[] = [];
if (props.options) {
options = props.options.map(o => ({
...o,
value: o[valueKey],
label: optionRenderer ? optionRenderer(o) : o.label || o[valueKey],
value: o[valueKey] as string | number,
label: optionRenderer
? (optionRenderer(o) as string)
: ((o.label || o[valueKey]) as string),
}));
} else if (choices) {
// Accepts different formats of input
@@ -142,24 +204,25 @@ export const innerGetOptions = props => {
const [value, label] = c.length > 1 ? c : [c[0], c[0]];
return {
value,
label,
label: String(label),
};
}
if (Object.is(c)) {
return {
...c,
value: c[valueKey],
label: c.label || c[valueKey],
};
}
return { value: c, label: c };
// This branch handles object-like choices, but choices are typed as tuples
return { value: c as unknown as string | number, label: String(c) };
});
}
return options;
};
export default class SelectControl extends PureComponent {
constructor(props) {
export default class SelectControl extends PureComponent<
SelectControlProps,
SelectControlState
> {
static propTypes = propTypes;
static defaultProps = defaultProps;
constructor(props: SelectControlProps) {
super(props);
this.state = {
options: this.getOptions(props),
@@ -168,7 +231,7 @@ export default class SelectControl extends PureComponent {
this.handleFilterOptions = this.handleFilterOptions.bind(this);
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps: SelectControlProps) {
if (
!isEqualArray(this.props.choices, prevProps.choices) ||
!isEqualArray(this.props.options, prevProps.options)
@@ -180,30 +243,39 @@ export default class SelectControl extends PureComponent {
// Beware: This is acting like an on-click instead of an on-change
// (firing every time user chooses vs firing only if a new option is chosen).
onChange(val) {
onChange(val: SelectValue | SelectOption | SelectOption[]) {
// will eventually call `exploreReducer`: SET_FIELD_VALUE
const { valueKey } = this.props;
let onChangeVal = val;
const { valueKey = 'value' } = this.props;
let onChangeVal: SelectValue = val as SelectValue;
if (Array.isArray(val)) {
const values = val.map(v =>
v?.[valueKey] !== undefined ? v[valueKey] : v,
typeof v === 'object' &&
v !== null &&
(v as SelectOption)[valueKey] !== undefined
? (v as SelectOption)[valueKey]
: v,
);
onChangeVal = values;
onChangeVal = values as (string | number)[];
}
if (typeof val === 'object' && val?.[valueKey] !== undefined) {
onChangeVal = val[valueKey];
if (
typeof val === 'object' &&
val !== null &&
!Array.isArray(val) &&
(val as SelectOption)[valueKey] !== undefined
) {
onChangeVal = (val as SelectOption)[valueKey] as string | number;
}
this.props.onChange(onChangeVal, []);
this.props.onChange?.(onChangeVal, []);
}
getOptions(props) {
getOptions(props: SelectControlProps) {
return innerGetOptions(props);
}
handleFilterOptions(text, option) {
handleFilterOptions(text: string, option: SelectOption) {
const { filterOption } = this.props;
return filterOption({ data: option }, text);
return filterOption?.({ data: option }, text) ?? true;
}
render() {
@@ -316,11 +388,9 @@ export default class SelectControl extends PureComponent {
}
`}
>
<Select {...selectProps} />
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Select {...(selectProps as any)} />
</div>
);
}
}
SelectControl.propTypes = propTypes;
SelectControl.defaultProps = defaultProps;

View File

@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component } from 'react';
import PropTypes from 'prop-types';
import { Component, type ReactNode } from 'react';
import {
Row,
Col,
@@ -35,27 +34,54 @@ const spatialTypes = {
latlong: 'latlong',
delimited: 'delimited',
geohash: 'geohash',
};
} as const;
const propTypes = {
onChange: PropTypes.func,
value: PropTypes.object,
animation: PropTypes.bool,
choices: PropTypes.array,
};
type SpatialType = (typeof spatialTypes)[keyof typeof spatialTypes];
const defaultProps = {
onChange: () => {},
animation: true,
choices: [],
};
interface SpatialValue {
type: SpatialType;
latCol?: string;
lonCol?: string;
lonlatCol?: string;
delimiter?: string;
reverseCheckbox?: boolean;
geohashCol?: string;
}
export default class SpatialControl extends Component {
constructor(props) {
interface SpatialControlProps {
onChange?: (value: SpatialValue, errors: string[]) => void;
value?: SpatialValue;
animation?: boolean;
choices?: [string, string][];
}
interface SpatialControlState {
type: SpatialType;
delimiter: string;
latCol: string | undefined;
lonCol: string | undefined;
lonlatCol: string | undefined;
reverseCheckbox: boolean;
geohashCol: string | undefined;
value: SpatialValue | null;
errors: string[];
}
export default class SpatialControl extends Component<
SpatialControlProps,
SpatialControlState
> {
static defaultProps = {
onChange: () => {},
animation: true,
choices: [],
};
constructor(props: SpatialControlProps) {
super(props);
const v = props.value || {};
let defaultCol;
if (props.choices.length > 0) {
const v = props.value || ({} as SpatialValue);
let defaultCol: string | undefined;
if (props.choices && props.choices.length > 0) {
defaultCol = props.choices[0][0];
}
this.state = {
@@ -69,19 +95,16 @@ export default class SpatialControl extends Component {
value: null,
errors: [],
};
this.toggleCheckbox = this.toggleCheckbox.bind(this);
this.onChange = this.onChange.bind(this);
this.renderReverseCheckbox = this.renderReverseCheckbox.bind(this);
}
componentDidMount() {
componentDidMount(): void {
this.onChange();
}
onChange() {
onChange = (): void => {
const { type } = this.state;
const value = { type };
const errors = [];
const value: SpatialValue = { type };
const errors: string[] = [];
const errMsg = t('Invalid lat/long configuration.');
if (type === spatialTypes.latlong) {
value.latCol = this.state.latCol;
@@ -104,21 +127,21 @@ export default class SpatialControl extends Component {
}
}
this.setState({ value, errors });
this.props.onChange(value, errors);
}
this.props.onChange?.(value, errors);
};
setType(type) {
setType = (type: SpatialType): void => {
this.setState({ type }, this.onChange);
}
};
toggleCheckbox() {
toggleCheckbox = (): void => {
this.setState(
prevState => ({ reverseCheckbox: !prevState.reverseCheckbox }),
this.onChange,
);
}
};
renderLabelContent() {
renderLabelContent(): string | null {
if (this.state.errors.length > 0) {
return 'N/A';
}
@@ -134,25 +157,28 @@ export default class SpatialControl extends Component {
return null;
}
renderSelect(name, type) {
renderSelect(name: keyof SpatialControlState, type: SpatialType): ReactNode {
return (
<SelectControl
ariaLabel={name}
name={name}
choices={this.props.choices}
value={this.state[name]}
value={this.state[name] as string}
clearable={false}
onFocus={() => {
this.setType(type);
}}
onChange={value => {
this.setState({ [name]: value }, this.onChange);
onChange={(value: string) => {
this.setState(
{ [name]: value } as unknown as SpatialControlState,
this.onChange,
);
}}
/>
);
}
renderReverseCheckbox() {
renderReverseCheckbox(): ReactNode {
return (
<span>
{t('Reverse lat/long ')}
@@ -164,13 +190,13 @@ export default class SpatialControl extends Component {
);
}
renderPopoverContent() {
renderPopoverContent(): ReactNode {
return (
<div style={{ width: '300px' }}>
<PopoverSection
title={t('Longitude & Latitude columns')}
isSelected={this.state.type === spatialTypes.latlong}
onSelect={this.setType.bind(this, spatialTypes.latlong)}
onSelect={() => this.setType(spatialTypes.latlong)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
@@ -190,7 +216,7 @@ export default class SpatialControl extends Component {
'Python library for more details',
)}
isSelected={this.state.type === spatialTypes.delimited}
onSelect={this.setType.bind(this, spatialTypes.delimited)}
onSelect={() => this.setType(spatialTypes.delimited)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
@@ -205,7 +231,7 @@ export default class SpatialControl extends Component {
<PopoverSection
title={t('Geohash')}
isSelected={this.state.type === spatialTypes.geohash}
onSelect={this.setType.bind(this, spatialTypes.geohash)}
onSelect={() => this.setType(spatialTypes.geohash)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
@@ -221,13 +247,13 @@ export default class SpatialControl extends Component {
);
}
render() {
render(): ReactNode {
return (
<div>
<ControlHeader {...this.props} />
<Popover
content={this.renderPopoverContent()}
placement="topLeft" // so that popover doesn't move when label changes
placement="topLeft"
trigger="click"
>
<Label className="pointer">{this.renderLabelContent()}</Label>
@@ -236,6 +262,3 @@ export default class SpatialControl extends Component {
);
}
}
SpatialControl.propTypes = propTypes;
SpatialControl.defaultProps = defaultProps;

View File

@@ -33,6 +33,54 @@ import 'ace-builds/src-min-noconflict/mode-handlebars';
import ControlHeader from 'src/explore/components/ControlHeader';
interface HotkeyConfig {
name: string;
key: string;
func: () => void;
}
interface ThemeType {
colorBorder: string;
colorBgMask: string;
sizeUnit: number;
}
interface TextAreaControlProps {
name?: string;
onChange?: (value: string) => void;
initialValue?: string;
height?: number;
minLines?: number;
maxLines?: number;
offerEditInModal?: boolean;
language?:
| 'json'
| 'html'
| 'sql'
| 'markdown'
| 'javascript'
| 'handlebars'
| null;
aboveEditorSection?: React.ReactNode;
readOnly?: boolean;
resize?:
| 'block'
| 'both'
| 'horizontal'
| 'inline'
| 'none'
| 'vertical'
| null;
textAreaStyles?: React.CSSProperties;
tooltipOptions?: Record<string, unknown>;
hotkeys?: HotkeyConfig[];
debounceDelay?: number | null;
theme?: ThemeType;
'aria-required'?: boolean;
value?: string;
[key: string]: unknown;
}
const propTypes = {
name: PropTypes.string,
onChange: PropTypes.func,
@@ -82,18 +130,27 @@ const defaultProps = {
debounceDelay: null,
};
class TextAreaControl extends Component {
constructor(props) {
class TextAreaControl extends Component<TextAreaControlProps> {
static propTypes = propTypes;
static defaultProps = defaultProps;
debouncedOnChange:
| ReturnType<typeof debounce<(value: string) => void>>
| undefined;
constructor(props: TextAreaControlProps) {
super(props);
if (props.debounceDelay) {
if (props.debounceDelay && props.onChange) {
this.debouncedOnChange = debounce(props.onChange, props.debounceDelay);
}
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps: TextAreaControlProps) {
if (
this.props.onChange !== prevProps.onChange &&
this.props.debounceDelay
this.props.debounceDelay &&
this.props.onChange
) {
if (this.debouncedOnChange) {
this.debouncedOnChange.cancel();
@@ -105,12 +162,12 @@ class TextAreaControl extends Component {
}
}
handleChange(value) {
handleChange(value: string | { target: { value: string } }) {
const finalValue = typeof value === 'object' ? value.target.value : value;
if (this.debouncedOnChange) {
this.debouncedOnChange(finalValue);
} else {
this.props.onChange(finalValue);
this.props.onChange?.(finalValue);
}
}
@@ -123,8 +180,10 @@ class TextAreaControl extends Component {
renderEditor(inModal = false) {
const minLines = inModal ? 40 : this.props.minLines || 12;
if (this.props.language) {
const style = {
border: `1px solid ${this.props.theme.colorBorder}`,
const style: React.CSSProperties = {
border: this.props.theme?.colorBorder
? `1px solid ${this.props.theme.colorBorder}`
: undefined,
minHeight: `${minLines}em`,
width: 'auto',
...this.props.textAreaStyles,
@@ -133,10 +192,18 @@ class TextAreaControl extends Component {
style.resize = this.props.resize;
}
if (this.props.readOnly) {
style.backgroundColor = this.props.theme.colorBgMask;
style.backgroundColor = this.props.theme?.colorBgMask;
}
const onEditorLoad = editor => {
this.props.hotkeys.forEach(keyConfig => {
const onEditorLoad = (editor: {
commands: {
addCommand: (cmd: {
name: string;
bindKey: { win: string; mac: string };
exec: () => void;
}) => void;
};
}) => {
this.props.hotkeys?.forEach(keyConfig => {
editor.commands.addCommand({
name: keyConfig.name,
bindKey: { win: keyConfig.key, mac: keyConfig.key },
@@ -203,16 +270,17 @@ class TextAreaControl extends Component {
{this.renderEditor()}
{this.props.offerEditInModal && (
<ModalTrigger
modalTitle={controlHeader}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
modalTitle={controlHeader as any}
triggerNode={
<Button
buttonSize="small"
style={{ marginTop: this.props.theme.sizeUnit }}
style={{ marginTop: this.props.theme?.sizeUnit ?? 4 }}
>
{t('Edit %s in modal', this.props.language)}
</Button>
}
modalBody={this.renderModalBody(true)}
modalBody={this.renderModalBody()}
responsive
/>
)}
@@ -221,7 +289,5 @@ class TextAreaControl extends Component {
}
}
TextAreaControl.propTypes = propTypes;
TextAreaControl.defaultProps = defaultProps;
export default withTheme(TextAreaControl);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default withTheme(TextAreaControl as any);

View File

@@ -25,7 +25,9 @@ import { Constants, Input } from '@superset-ui/core/components';
type InputValueType = string | number;
export interface TextControlProps<T extends InputValueType = InputValueType> {
name?: string;
label?: string;
description?: string;
disabled?: boolean;
isFloat?: boolean;
isInt?: boolean;
@@ -36,6 +38,8 @@ export interface TextControlProps<T extends InputValueType = InputValueType> {
controlId?: string;
renderTrigger?: boolean;
validationErrors?: string[];
hovered?: boolean;
showHeader?: boolean;
}
export interface TextControlState {

View File

@@ -34,6 +34,42 @@ import BoundsControl from '../BoundsControl';
import CheckboxControl from '../CheckboxControl';
import ControlPopover from '../ControlPopover/ControlPopover';
interface TimeSeriesColumnControlProps {
label?: string;
tooltip?: string;
colType?: string;
width?: string;
height?: string;
timeLag?: string | number;
timeRatio?: string;
comparisonType?: string;
showYAxis?: boolean;
yAxisBounds?: (number | null)[];
bounds?: (number | null)[];
d3format?: string;
dateFormat?: string;
sparkType?: string;
onChange?: (state: TimeSeriesColumnControlState) => void;
}
interface TimeSeriesColumnControlState {
label: string;
tooltip: string;
colType: string;
width: string;
height: string;
timeLag: string | number;
timeRatio: string;
comparisonType: string;
showYAxis: boolean;
yAxisBounds: (number | null)[];
bounds: (number | null)[];
d3format: string;
dateFormat: string;
sparkType: string;
popoverVisible: boolean;
}
const propTypes = {
label: PropTypes.string,
tooltip: PropTypes.string,
@@ -111,8 +147,15 @@ const ButtonBar = styled.div`
justify-content: center;
`;
export default class TimeSeriesColumnControl extends Component {
constructor(props) {
export default class TimeSeriesColumnControl extends Component<
TimeSeriesColumnControlProps,
TimeSeriesColumnControlState
> {
static propTypes = propTypes;
static defaultProps = defaultProps;
constructor(props: TimeSeriesColumnControlProps) {
super(props);
this.onSave = this.onSave.bind(this);
@@ -124,22 +167,22 @@ export default class TimeSeriesColumnControl extends Component {
this.state = this.initialState();
}
initialState() {
initialState(): TimeSeriesColumnControlState {
return {
label: this.props.label,
tooltip: this.props.tooltip,
colType: this.props.colType,
width: this.props.width,
height: this.props.height,
timeLag: this.props.timeLag || 0,
timeRatio: this.props.timeRatio,
comparisonType: this.props.comparisonType,
showYAxis: this.props.showYAxis,
yAxisBounds: this.props.yAxisBounds,
bounds: this.props.bounds,
d3format: this.props.d3format,
dateFormat: this.props.dateFormat,
sparkType: this.props.sparkType,
label: this.props.label ?? t('Time series columns'),
tooltip: this.props.tooltip ?? '',
colType: this.props.colType ?? '',
width: this.props.width ?? '',
height: this.props.height ?? '',
timeLag: this.props.timeLag ?? 0,
timeRatio: this.props.timeRatio ?? '',
comparisonType: this.props.comparisonType ?? '',
showYAxis: this.props.showYAxis ?? false,
yAxisBounds: this.props.yAxisBounds ?? [null, null],
bounds: this.props.bounds ?? [null, null],
d3format: this.props.d3format ?? '',
dateFormat: this.props.dateFormat ?? '',
sparkType: this.props.sparkType ?? 'line',
popoverVisible: false,
};
}
@@ -150,7 +193,7 @@ export default class TimeSeriesColumnControl extends Component {
}
onSave() {
this.props.onChange(this.state);
this.props.onChange?.(this.state);
this.setState({ popoverVisible: false });
}
@@ -158,23 +201,23 @@ export default class TimeSeriesColumnControl extends Component {
this.resetState();
}
onSelectChange(attr, opt) {
this.setState({ [attr]: opt });
onSelectChange(attr: string, opt: string) {
this.setState(prevState => ({ ...prevState, [attr]: opt }));
}
onTextInputChange(attr, event) {
this.setState({ [attr]: event.target.value });
onTextInputChange(attr: string, event: React.ChangeEvent<HTMLInputElement>) {
this.setState(prevState => ({ ...prevState, [attr]: event.target.value }));
}
onCheckboxChange(attr, value) {
this.setState({ [attr]: value });
onCheckboxChange(attr: string, value: boolean) {
this.setState(prevState => ({ ...prevState, [attr]: value }));
}
onBoundsChange(bounds) {
onBoundsChange(bounds: (number | null)[]) {
this.setState({ bounds });
}
onPopoverVisibleChange(popoverVisible) {
onPopoverVisibleChange(popoverVisible: boolean) {
if (popoverVisible) {
this.setState({ popoverVisible });
} else {
@@ -182,15 +225,20 @@ export default class TimeSeriesColumnControl extends Component {
}
}
onYAxisBoundsChange(yAxisBounds) {
onYAxisBoundsChange(yAxisBounds: (number | null)[]) {
this.setState({ yAxisBounds });
}
textSummary() {
return `${this.props.label}`;
return `${this.props.label ?? ''}`;
}
formRow(label, tooltip, ttLabel, control) {
formRow(
label: string,
tooltip: string,
ttLabel: string,
control: React.ReactNode,
) {
return (
<StyledRow>
<StyledCol xs={24} md={11}>
@@ -412,6 +460,3 @@ export default class TimeSeriesColumnControl extends Component {
);
}
}
TimeSeriesColumnControl.propTypes = propTypes;
TimeSeriesColumnControl.defaultProps = defaultProps;

View File

@@ -16,16 +16,23 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component } from 'react';
import { Component, type ReactNode } from 'react';
import { t } from '@superset-ui/core';
import PropTypes from 'prop-types';
import { Popover, FormLabel, Label } from '@superset-ui/core/components';
import { decimal2sexagesimal } from 'geolib';
import TextControl from './TextControl';
import ControlHeader from '../ControlHeader';
export const DEFAULT_VIEWPORT = {
export interface Viewport {
longitude: number;
latitude: number;
zoom: number;
bearing: number;
pitch: number;
}
export const DEFAULT_VIEWPORT: Viewport = {
longitude: 6.85236157047845,
latitude: 31.222656842808707,
zoom: 1,
@@ -33,54 +40,49 @@ export const DEFAULT_VIEWPORT = {
pitch: 0,
};
const PARAMS = ['longitude', 'latitude', 'zoom', 'bearing', 'pitch'];
const PARAMS: (keyof Viewport)[] = [
'longitude',
'latitude',
'zoom',
'bearing',
'pitch',
];
const propTypes = {
onChange: PropTypes.func,
value: PropTypes.shape({
longitude: PropTypes.number,
latitude: PropTypes.number,
zoom: PropTypes.number,
bearing: PropTypes.number,
pitch: PropTypes.number,
}),
default: PropTypes.object,
name: PropTypes.string.isRequired,
};
interface ViewportControlProps {
onChange?: (value: Viewport) => void;
value?: Viewport;
default?: Record<string, unknown>;
name: string;
}
const defaultProps = {
onChange: () => {},
default: { type: 'fix', value: 5 },
value: DEFAULT_VIEWPORT,
};
export default class ViewportControl extends Component<ViewportControlProps> {
static defaultProps = {
onChange: () => {},
default: { type: 'fix', value: 5 },
value: DEFAULT_VIEWPORT,
};
export default class ViewportControl extends Component {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
}
onChange(ctrl, value) {
this.props.onChange({
...this.props.value,
onChange = (ctrl: keyof Viewport, value: number): void => {
this.props.onChange?.({
...this.props.value!,
[ctrl]: value,
});
}
};
renderTextControl(ctrl) {
renderTextControl(ctrl: keyof Viewport): ReactNode {
return (
<div key={ctrl}>
<FormLabel>{ctrl}</FormLabel>
<TextControl
value={this.props.value[ctrl]}
onChange={this.onChange.bind(this, ctrl)}
value={this.props.value?.[ctrl]}
onChange={(value: number) => this.onChange(ctrl, value)}
isFloat
/>
</div>
);
}
renderPopover() {
renderPopover(): ReactNode {
return (
<div id={`filter-popover-${this.props.name}`}>
{PARAMS.map(ctrl => this.renderTextControl(ctrl))}
@@ -88,8 +90,8 @@ export default class ViewportControl extends Component {
);
}
renderLabel() {
if (this.props.value.longitude && this.props.value.latitude) {
renderLabel(): string {
if (this.props.value?.longitude && this.props.value?.latitude) {
return `${decimal2sexagesimal(
this.props.value.longitude,
)} | ${decimal2sexagesimal(this.props.value.latitude)}`;
@@ -97,12 +99,11 @@ export default class ViewportControl extends Component {
return 'N/A';
}
render() {
render(): ReactNode {
return (
<div>
<ControlHeader {...this.props} />
<Popover
container={document.body}
trigger="click"
placement="right"
content={this.renderPopover()}
@@ -114,6 +115,3 @@ export default class ViewportControl extends Component {
);
}
}
ViewportControl.propTypes = propTypes;
ViewportControl.defaultProps = defaultProps;

View File

@@ -19,11 +19,15 @@
import { useEffect, useState } from 'react';
import SelectControl from './SelectControl';
export default function XAxisSortControl(props: {
interface XAxisSortControlProps {
onChange: (val: string | undefined) => void;
value: string | null;
shouldReset: boolean;
}) {
name?: string;
[key: string]: unknown;
}
export default function XAxisSortControl(props: XAxisSortControlProps) {
const [value, setValue] = useState(props.value);
useEffect(() => {
if (props.shouldReset) {
@@ -32,5 +36,11 @@ export default function XAxisSortControl(props: {
}
}, [props.shouldReset, props.value]);
return <SelectControl {...props} value={value} />;
return (
<SelectControl
{...props}
name={props.name ?? 'x_axis_sort'}
value={value}
/>
);
}