mirror of
https://github.com/apache/superset.git
synced 2024-09-18 11:39:49 -04:00
refactor: improve code smell for postprocessing (#1368)
* refactor: improve code smell for postprocessing * jest UT * best practice for jest
This commit is contained in:
parent
eed58d6658
commit
0f4a06d15e
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitationsxw
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
PostProcessingBoxplot,
|
||||
getMetricLabel,
|
||||
ensureIsArray,
|
||||
QueryFormColumn,
|
||||
} from '@superset-ui/core';
|
||||
import { PostProcessingFactory } from './types';
|
||||
|
||||
type BoxPlotQueryObjectWhiskerType = PostProcessingBoxplot['options']['whisker_type'];
|
||||
const PERCENTILE_REGEX = /(\d+)\/(\d+) percentiles/;
|
||||
|
||||
export const boxplotOperator: PostProcessingFactory<PostProcessingBoxplot | undefined> = (
|
||||
formData,
|
||||
queryObject,
|
||||
) => {
|
||||
const { whiskerOptions } = formData;
|
||||
|
||||
if (whiskerOptions) {
|
||||
let whiskerType: BoxPlotQueryObjectWhiskerType;
|
||||
let percentiles: [number, number] | undefined;
|
||||
const percentileMatch = PERCENTILE_REGEX.exec(whiskerOptions as string);
|
||||
|
||||
if (whiskerOptions === 'Tukey' || !whiskerOptions) {
|
||||
whiskerType = 'tukey';
|
||||
} else if (whiskerOptions === 'Min/max (no outliers)') {
|
||||
whiskerType = 'min/max';
|
||||
} else if (percentileMatch) {
|
||||
whiskerType = 'percentile';
|
||||
percentiles = [parseInt(percentileMatch[1], 10), parseInt(percentileMatch[2], 10)];
|
||||
} else {
|
||||
throw new Error(`Unsupported whisker type: ${whiskerOptions}`);
|
||||
}
|
||||
|
||||
return {
|
||||
operation: 'boxplot',
|
||||
options: {
|
||||
whisker_type: whiskerType,
|
||||
percentiles,
|
||||
groupby: ensureIsArray(queryObject.groupby as QueryFormColumn[]),
|
||||
metrics: ensureIsArray(queryObject.metrics).map(getMetricLabel),
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
};
|
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitationsxw
|
||||
* under the License.
|
||||
*/
|
||||
import { PostProcessingContribution } from '@superset-ui/core';
|
||||
import { PostProcessingFactory } from './types';
|
||||
|
||||
export const contributionOperator: PostProcessingFactory<PostProcessingContribution | undefined> = (
|
||||
formData,
|
||||
queryObject,
|
||||
) => {
|
||||
if (formData.contributionMode) {
|
||||
return {
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
orientation: formData.contributionMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
};
|
@ -23,4 +23,7 @@ export { timeComparePivotOperator } from './timeComparePivotOperator';
|
||||
export { sortOperator } from './sortOperator';
|
||||
export { pivotOperator } from './pivotOperator';
|
||||
export { resampleOperator } from './resampleOperator';
|
||||
export { contributionOperator } from './contributionOperator';
|
||||
export { prophetOperator } from './prophetOperator';
|
||||
export { boxplotOperator } from './boxplotOperator';
|
||||
export * from './utils';
|
||||
|
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitationsxw
|
||||
* under the License.
|
||||
*/
|
||||
import { PostProcessingProphet } from '@superset-ui/core';
|
||||
import { PostProcessingFactory } from './types';
|
||||
|
||||
export const prophetOperator: PostProcessingFactory<PostProcessingProphet | undefined> = (
|
||||
formData,
|
||||
queryObject,
|
||||
) => {
|
||||
if (formData.forecastEnabled) {
|
||||
return {
|
||||
operation: 'prophet',
|
||||
options: {
|
||||
time_grain: formData.time_grain_sqla,
|
||||
periods: parseInt(formData.forecastPeriods, 10),
|
||||
confidence_interval: parseFloat(formData.forecastInterval),
|
||||
yearly_seasonality: formData.forecastSeasonalityYearly,
|
||||
weekly_seasonality: formData.forecastSeasonalityWeekly,
|
||||
daily_seasonality: formData.forecastSeasonalityDaily,
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
};
|
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { QueryObject, SqlaFormData } from '@superset-ui/core';
|
||||
import { boxplotOperator } from '../../../src';
|
||||
|
||||
const formData: SqlaFormData = {
|
||||
metrics: ['count(*)', { label: 'sum(val)', expressionType: 'SQL', sqlExpression: 'sum(val)' }],
|
||||
time_range: '2015 : 2016',
|
||||
time_grain_sqla: 'P1Y',
|
||||
datasource: 'foo',
|
||||
viz_type: 'table',
|
||||
};
|
||||
const queryObject: QueryObject = {
|
||||
metrics: ['count(*)', { label: 'sum(val)', expressionType: 'SQL', sqlExpression: 'sum(val)' }],
|
||||
time_range: '2015 : 2016',
|
||||
granularity: 'P1Y',
|
||||
};
|
||||
|
||||
test('should skip boxplotOperator', () => {
|
||||
expect(boxplotOperator(formData, queryObject)).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('should do tukey boxplot', () => {
|
||||
expect(
|
||||
boxplotOperator(
|
||||
{
|
||||
...formData,
|
||||
whiskerOptions: 'Tukey',
|
||||
},
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'boxplot',
|
||||
options: {
|
||||
whisker_type: 'tukey',
|
||||
percentiles: undefined,
|
||||
groupby: [],
|
||||
metrics: ['count(*)', 'sum(val)'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should do min/max boxplot', () => {
|
||||
expect(
|
||||
boxplotOperator(
|
||||
{
|
||||
...formData,
|
||||
whiskerOptions: 'Min/max (no outliers)',
|
||||
},
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'boxplot',
|
||||
options: {
|
||||
whisker_type: 'min/max',
|
||||
percentiles: undefined,
|
||||
groupby: [],
|
||||
metrics: ['count(*)', 'sum(val)'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should do percentile boxplot', () => {
|
||||
expect(
|
||||
boxplotOperator(
|
||||
{
|
||||
...formData,
|
||||
whiskerOptions: '1/4 percentiles',
|
||||
},
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'boxplot',
|
||||
options: {
|
||||
whisker_type: 'percentile',
|
||||
percentiles: [1, 4],
|
||||
groupby: [],
|
||||
metrics: ['count(*)', 'sum(val)'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw an error', () => {
|
||||
expect(() =>
|
||||
boxplotOperator(
|
||||
{
|
||||
...formData,
|
||||
whiskerOptions: 'foobar',
|
||||
},
|
||||
queryObject,
|
||||
),
|
||||
).toThrow('Unsupported whisker type: foobar');
|
||||
});
|
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { QueryObject, SqlaFormData } from '@superset-ui/core';
|
||||
import { contributionOperator } from '../../../src';
|
||||
|
||||
const formData: SqlaFormData = {
|
||||
metrics: ['count(*)', { label: 'sum(val)', expressionType: 'SQL', sqlExpression: 'sum(val)' }],
|
||||
time_range: '2015 : 2016',
|
||||
granularity: 'month',
|
||||
datasource: 'foo',
|
||||
viz_type: 'table',
|
||||
};
|
||||
const queryObject: QueryObject = {
|
||||
metrics: ['count(*)', { label: 'sum(val)', expressionType: 'SQL', sqlExpression: 'sum(val)' }],
|
||||
time_range: '2015 : 2016',
|
||||
granularity: 'month',
|
||||
};
|
||||
|
||||
test('should skip contributionOperator', () => {
|
||||
expect(contributionOperator(formData, queryObject)).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('should do contributionOperator', () => {
|
||||
expect(contributionOperator({ ...formData, contributionMode: 'row' }, queryObject)).toEqual({
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
orientation: 'row',
|
||||
},
|
||||
});
|
||||
});
|
@ -47,82 +47,80 @@ const queryObject: QueryObject = {
|
||||
],
|
||||
};
|
||||
|
||||
describe('pivotOperator', () => {
|
||||
it('skip pivot', () => {
|
||||
expect(pivotOperator(formData, queryObject)).toEqual(undefined);
|
||||
expect(pivotOperator(formData, { ...queryObject, is_timeseries: false })).toEqual(undefined);
|
||||
expect(pivotOperator(formData, { ...queryObject, is_timeseries: true, metrics: [] })).toEqual(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
test('skip pivot', () => {
|
||||
expect(pivotOperator(formData, queryObject)).toEqual(undefined);
|
||||
expect(pivotOperator(formData, { ...queryObject, is_timeseries: false })).toEqual(undefined);
|
||||
expect(pivotOperator(formData, { ...queryObject, is_timeseries: true, metrics: [] })).toEqual(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('pivot by __timestamp without groupby', () => {
|
||||
expect(pivotOperator(formData, { ...queryObject, is_timeseries: true })).toEqual({
|
||||
operation: 'pivot',
|
||||
options: {
|
||||
index: ['__timestamp'],
|
||||
columns: [],
|
||||
aggregates: {
|
||||
'count(*)': { operator: 'mean' },
|
||||
'sum(val)': { operator: 'mean' },
|
||||
},
|
||||
drop_missing_columns: false,
|
||||
test('pivot by __timestamp without groupby', () => {
|
||||
expect(pivotOperator(formData, { ...queryObject, is_timeseries: true })).toEqual({
|
||||
operation: 'pivot',
|
||||
options: {
|
||||
index: ['__timestamp'],
|
||||
columns: [],
|
||||
aggregates: {
|
||||
'count(*)': { operator: 'mean' },
|
||||
'sum(val)': { operator: 'mean' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('pivot by __timestamp with groupby', () => {
|
||||
expect(
|
||||
pivotOperator(formData, { ...queryObject, columns: ['foo', 'bar'], is_timeseries: true }),
|
||||
).toEqual({
|
||||
operation: 'pivot',
|
||||
options: {
|
||||
index: ['__timestamp'],
|
||||
columns: ['foo', 'bar'],
|
||||
aggregates: {
|
||||
'count(*)': { operator: 'mean' },
|
||||
'sum(val)': { operator: 'mean' },
|
||||
},
|
||||
drop_missing_columns: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('timecompare in formdata', () => {
|
||||
expect(
|
||||
pivotOperator(
|
||||
{
|
||||
...formData,
|
||||
comparison_type: 'values',
|
||||
time_compare: ['1 year ago', '1 year later'],
|
||||
},
|
||||
{
|
||||
...queryObject,
|
||||
columns: ['foo', 'bar'],
|
||||
is_timeseries: true,
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'pivot',
|
||||
options: {
|
||||
aggregates: {
|
||||
'count(*)': { operator: 'mean' },
|
||||
'count(*)__1 year ago': { operator: 'mean' },
|
||||
'count(*)__1 year later': { operator: 'mean' },
|
||||
'sum(val)': {
|
||||
operator: 'mean',
|
||||
},
|
||||
'sum(val)__1 year ago': {
|
||||
operator: 'mean',
|
||||
},
|
||||
'sum(val)__1 year later': {
|
||||
operator: 'mean',
|
||||
},
|
||||
},
|
||||
drop_missing_columns: false,
|
||||
columns: ['foo', 'bar'],
|
||||
index: ['__timestamp'],
|
||||
},
|
||||
});
|
||||
drop_missing_columns: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('pivot by __timestamp with groupby', () => {
|
||||
expect(
|
||||
pivotOperator(formData, { ...queryObject, columns: ['foo', 'bar'], is_timeseries: true }),
|
||||
).toEqual({
|
||||
operation: 'pivot',
|
||||
options: {
|
||||
index: ['__timestamp'],
|
||||
columns: ['foo', 'bar'],
|
||||
aggregates: {
|
||||
'count(*)': { operator: 'mean' },
|
||||
'sum(val)': { operator: 'mean' },
|
||||
},
|
||||
drop_missing_columns: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('timecompare in formdata', () => {
|
||||
expect(
|
||||
pivotOperator(
|
||||
{
|
||||
...formData,
|
||||
comparison_type: 'values',
|
||||
time_compare: ['1 year ago', '1 year later'],
|
||||
},
|
||||
{
|
||||
...queryObject,
|
||||
columns: ['foo', 'bar'],
|
||||
is_timeseries: true,
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'pivot',
|
||||
options: {
|
||||
aggregates: {
|
||||
'count(*)': { operator: 'mean' },
|
||||
'count(*)__1 year ago': { operator: 'mean' },
|
||||
'count(*)__1 year later': { operator: 'mean' },
|
||||
'sum(val)': {
|
||||
operator: 'mean',
|
||||
},
|
||||
'sum(val)__1 year ago': {
|
||||
operator: 'mean',
|
||||
},
|
||||
'sum(val)__1 year later': {
|
||||
operator: 'mean',
|
||||
},
|
||||
},
|
||||
drop_missing_columns: false,
|
||||
columns: ['foo', 'bar'],
|
||||
index: ['__timestamp'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { QueryObject, SqlaFormData } from '@superset-ui/core';
|
||||
import { prophetOperator } from '../../../src';
|
||||
|
||||
const formData: SqlaFormData = {
|
||||
metrics: ['count(*)', { label: 'sum(val)', expressionType: 'SQL', sqlExpression: 'sum(val)' }],
|
||||
time_range: '2015 : 2016',
|
||||
time_grain_sqla: 'P1Y',
|
||||
datasource: 'foo',
|
||||
viz_type: 'table',
|
||||
};
|
||||
const queryObject: QueryObject = {
|
||||
metrics: ['count(*)', { label: 'sum(val)', expressionType: 'SQL', sqlExpression: 'sum(val)' }],
|
||||
time_range: '2015 : 2016',
|
||||
granularity: 'P1Y',
|
||||
};
|
||||
|
||||
test('should skip prophetOperator', () => {
|
||||
expect(prophetOperator(formData, queryObject)).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('should do prophetOperator', () => {
|
||||
expect(
|
||||
prophetOperator(
|
||||
{
|
||||
...formData,
|
||||
forecastEnabled: true,
|
||||
forecastPeriods: '3',
|
||||
forecastInterval: '5',
|
||||
forecastSeasonalityYearly: true,
|
||||
forecastSeasonalityWeekly: false,
|
||||
forecastSeasonalityDaily: false,
|
||||
},
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'prophet',
|
||||
options: {
|
||||
time_grain: 'P1Y',
|
||||
periods: 3.0,
|
||||
confidence_interval: 5.0,
|
||||
yearly_seasonality: true,
|
||||
weekly_seasonality: false,
|
||||
daily_seasonality: false,
|
||||
},
|
||||
});
|
||||
});
|
@ -46,43 +46,41 @@ const queryObject: QueryObject = {
|
||||
],
|
||||
};
|
||||
|
||||
describe('resampleOperator', () => {
|
||||
it('should skip resampleOperator', () => {
|
||||
expect(resampleOperator(formData, queryObject)).toEqual(undefined);
|
||||
expect(resampleOperator({ ...formData, resample_method: 'ffill' }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
expect(resampleOperator({ ...formData, resample_rule: '1D' }, queryObject)).toEqual(undefined);
|
||||
});
|
||||
test('should skip resampleOperator', () => {
|
||||
expect(resampleOperator(formData, queryObject)).toEqual(undefined);
|
||||
expect(resampleOperator({ ...formData, resample_method: 'ffill' }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
expect(resampleOperator({ ...formData, resample_rule: '1D' }, queryObject)).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('should do resample', () => {
|
||||
expect(
|
||||
resampleOperator({ ...formData, resample_method: 'ffill', resample_rule: '1D' }, queryObject),
|
||||
).toEqual({
|
||||
operation: 'resample',
|
||||
options: {
|
||||
method: 'ffill',
|
||||
rule: '1D',
|
||||
fill_value: null,
|
||||
time_column: '__timestamp',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should do zerofill resample', () => {
|
||||
expect(
|
||||
resampleOperator(
|
||||
{ ...formData, resample_method: 'zerofill', resample_rule: '1D' },
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'resample',
|
||||
options: {
|
||||
method: 'asfreq',
|
||||
rule: '1D',
|
||||
fill_value: 0,
|
||||
time_column: '__timestamp',
|
||||
},
|
||||
});
|
||||
test('should do resample', () => {
|
||||
expect(
|
||||
resampleOperator({ ...formData, resample_method: 'ffill', resample_rule: '1D' }, queryObject),
|
||||
).toEqual({
|
||||
operation: 'resample',
|
||||
options: {
|
||||
method: 'ffill',
|
||||
rule: '1D',
|
||||
fill_value: null,
|
||||
time_column: '__timestamp',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should do zerofill resample', () => {
|
||||
expect(
|
||||
resampleOperator(
|
||||
{ ...formData, resample_method: 'zerofill', resample_rule: '1D' },
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'resample',
|
||||
options: {
|
||||
method: 'asfreq',
|
||||
rule: '1D',
|
||||
fill_value: 0,
|
||||
time_column: '__timestamp',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -46,26 +46,42 @@ const queryObject: QueryObject = {
|
||||
],
|
||||
};
|
||||
|
||||
describe('rollingWindowOperator', () => {
|
||||
it('skip transformation', () => {
|
||||
expect(rollingWindowOperator(formData, queryObject)).toEqual(undefined);
|
||||
expect(rollingWindowOperator({ ...formData, rolling_type: 'None' }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
expect(rollingWindowOperator({ ...formData, rolling_type: 'foobar' }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
test('skip transformation', () => {
|
||||
expect(rollingWindowOperator(formData, queryObject)).toEqual(undefined);
|
||||
expect(rollingWindowOperator({ ...formData, rolling_type: 'None' }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
expect(rollingWindowOperator({ ...formData, rolling_type: 'foobar' }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const formDataWithoutMetrics = { ...formData };
|
||||
delete formDataWithoutMetrics.metrics;
|
||||
expect(rollingWindowOperator(formDataWithoutMetrics, queryObject)).toEqual(undefined);
|
||||
const formDataWithoutMetrics = { ...formData };
|
||||
delete formDataWithoutMetrics.metrics;
|
||||
expect(rollingWindowOperator(formDataWithoutMetrics, queryObject)).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('rolling_type: cumsum', () => {
|
||||
expect(rollingWindowOperator({ ...formData, rolling_type: 'cumsum' }, queryObject)).toEqual({
|
||||
operation: 'cum',
|
||||
options: {
|
||||
operator: 'sum',
|
||||
columns: {
|
||||
'count(*)': 'count(*)',
|
||||
'sum(val)': 'sum(val)',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rolling_type: cumsum', () => {
|
||||
expect(rollingWindowOperator({ ...formData, rolling_type: 'cumsum' }, queryObject)).toEqual({
|
||||
operation: 'cum',
|
||||
test('rolling_type: sum/mean/std', () => {
|
||||
const rollingTypes = ['sum', 'mean', 'std'];
|
||||
rollingTypes.forEach(rollingType => {
|
||||
expect(rollingWindowOperator({ ...formData, rolling_type: rollingType }, queryObject)).toEqual({
|
||||
operation: 'rolling',
|
||||
options: {
|
||||
operator: 'sum',
|
||||
rolling_type: rollingType,
|
||||
window: 1,
|
||||
min_periods: 0,
|
||||
columns: {
|
||||
'count(*)': 'count(*)',
|
||||
'sum(val)': 'sum(val)',
|
||||
@ -73,34 +89,44 @@ describe('rollingWindowOperator', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('rolling_type: sum/mean/std', () => {
|
||||
const rollingTypes = ['sum', 'mean', 'std'];
|
||||
rollingTypes.forEach(rollingType => {
|
||||
expect(
|
||||
rollingWindowOperator({ ...formData, rolling_type: rollingType }, queryObject),
|
||||
).toEqual({
|
||||
operation: 'rolling',
|
||||
options: {
|
||||
rolling_type: rollingType,
|
||||
window: 1,
|
||||
min_periods: 0,
|
||||
columns: {
|
||||
'count(*)': 'count(*)',
|
||||
'sum(val)': 'sum(val)',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
test('rolling window and "actual values" in the time compare', () => {
|
||||
expect(
|
||||
rollingWindowOperator(
|
||||
{
|
||||
...formData,
|
||||
rolling_type: 'cumsum',
|
||||
comparison_type: 'values',
|
||||
time_compare: ['1 year ago', '1 year later'],
|
||||
},
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'cum',
|
||||
options: {
|
||||
operator: 'sum',
|
||||
columns: {
|
||||
'count(*)': 'count(*)',
|
||||
'count(*)__1 year ago': 'count(*)__1 year ago',
|
||||
'count(*)__1 year later': 'count(*)__1 year later',
|
||||
'sum(val)': 'sum(val)',
|
||||
'sum(val)__1 year ago': 'sum(val)__1 year ago',
|
||||
'sum(val)__1 year later': 'sum(val)__1 year later',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rolling window and "actual values" in the time compare', () => {
|
||||
test('rolling window and "absolute / percentage / ratio" in the time compare', () => {
|
||||
const comparisionTypes = ['absolute', 'percentage', 'ratio'];
|
||||
comparisionTypes.forEach(cType => {
|
||||
expect(
|
||||
rollingWindowOperator(
|
||||
{
|
||||
...formData,
|
||||
rolling_type: 'cumsum',
|
||||
comparison_type: 'values',
|
||||
comparison_type: cType,
|
||||
time_compare: ['1 year ago', '1 year later'],
|
||||
},
|
||||
queryObject,
|
||||
@ -110,42 +136,12 @@ describe('rollingWindowOperator', () => {
|
||||
options: {
|
||||
operator: 'sum',
|
||||
columns: {
|
||||
'count(*)': 'count(*)',
|
||||
'count(*)__1 year ago': 'count(*)__1 year ago',
|
||||
'count(*)__1 year later': 'count(*)__1 year later',
|
||||
'sum(val)': 'sum(val)',
|
||||
'sum(val)__1 year ago': 'sum(val)__1 year ago',
|
||||
'sum(val)__1 year later': 'sum(val)__1 year later',
|
||||
[`${cType}__count(*)__count(*)__1 year ago`]: `${cType}__count(*)__count(*)__1 year ago`,
|
||||
[`${cType}__count(*)__count(*)__1 year later`]: `${cType}__count(*)__count(*)__1 year later`,
|
||||
[`${cType}__sum(val)__sum(val)__1 year ago`]: `${cType}__sum(val)__sum(val)__1 year ago`,
|
||||
[`${cType}__sum(val)__sum(val)__1 year later`]: `${cType}__sum(val)__sum(val)__1 year later`,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rolling window and "absolute / percentage / ratio" in the time compare', () => {
|
||||
const comparisionTypes = ['absolute', 'percentage', 'ratio'];
|
||||
comparisionTypes.forEach(cType => {
|
||||
expect(
|
||||
rollingWindowOperator(
|
||||
{
|
||||
...formData,
|
||||
rolling_type: 'cumsum',
|
||||
comparison_type: cType,
|
||||
time_compare: ['1 year ago', '1 year later'],
|
||||
},
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'cum',
|
||||
options: {
|
||||
operator: 'sum',
|
||||
columns: {
|
||||
[`${cType}__count(*)__count(*)__1 year ago`]: `${cType}__count(*)__count(*)__1 year ago`,
|
||||
[`${cType}__count(*)__count(*)__1 year later`]: `${cType}__count(*)__count(*)__1 year later`,
|
||||
[`${cType}__sum(val)__sum(val)__1 year ago`]: `${cType}__sum(val)__sum(val)__1 year ago`,
|
||||
[`${cType}__sum(val)__sum(val)__1 year later`]: `${cType}__sum(val)__sum(val)__1 year later`,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -46,62 +46,57 @@ const queryObject: QueryObject = {
|
||||
],
|
||||
};
|
||||
|
||||
describe('sortOperator', () => {
|
||||
it('skip sort', () => {
|
||||
expect(sortOperator(formData, queryObject)).toEqual(undefined);
|
||||
expect(sortOperator(formData, { ...queryObject, is_timeseries: false })).toEqual(undefined);
|
||||
expect(
|
||||
sortOperator({ ...formData, rolling_type: 'xxxx' }, { ...queryObject, is_timeseries: true }),
|
||||
).toEqual(undefined);
|
||||
expect(sortOperator(formData, { ...queryObject, is_timeseries: true })).toEqual(undefined);
|
||||
test('skip sort', () => {
|
||||
expect(sortOperator(formData, queryObject)).toEqual(undefined);
|
||||
expect(sortOperator(formData, { ...queryObject, is_timeseries: false })).toEqual(undefined);
|
||||
expect(
|
||||
sortOperator({ ...formData, rolling_type: 'xxxx' }, { ...queryObject, is_timeseries: true }),
|
||||
).toEqual(undefined);
|
||||
expect(sortOperator(formData, { ...queryObject, is_timeseries: true })).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('sort by __timestamp', () => {
|
||||
expect(
|
||||
sortOperator({ ...formData, rolling_type: 'cumsum' }, { ...queryObject, is_timeseries: true }),
|
||||
).toEqual({
|
||||
operation: 'sort',
|
||||
options: {
|
||||
columns: {
|
||||
__timestamp: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('sort by __timestamp', () => {
|
||||
expect(
|
||||
sortOperator(
|
||||
{ ...formData, rolling_type: 'cumsum' },
|
||||
{ ...queryObject, is_timeseries: true },
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'sort',
|
||||
options: {
|
||||
columns: {
|
||||
__timestamp: true,
|
||||
},
|
||||
expect(
|
||||
sortOperator({ ...formData, rolling_type: 'sum' }, { ...queryObject, is_timeseries: true }),
|
||||
).toEqual({
|
||||
operation: 'sort',
|
||||
options: {
|
||||
columns: {
|
||||
__timestamp: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
sortOperator({ ...formData, rolling_type: 'sum' }, { ...queryObject, is_timeseries: true }),
|
||||
).toEqual({
|
||||
operation: 'sort',
|
||||
options: {
|
||||
columns: {
|
||||
__timestamp: true,
|
||||
},
|
||||
expect(
|
||||
sortOperator({ ...formData, rolling_type: 'mean' }, { ...queryObject, is_timeseries: true }),
|
||||
).toEqual({
|
||||
operation: 'sort',
|
||||
options: {
|
||||
columns: {
|
||||
__timestamp: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
sortOperator({ ...formData, rolling_type: 'mean' }, { ...queryObject, is_timeseries: true }),
|
||||
).toEqual({
|
||||
operation: 'sort',
|
||||
options: {
|
||||
columns: {
|
||||
__timestamp: true,
|
||||
},
|
||||
expect(
|
||||
sortOperator({ ...formData, rolling_type: 'std' }, { ...queryObject, is_timeseries: true }),
|
||||
).toEqual({
|
||||
operation: 'sort',
|
||||
options: {
|
||||
columns: {
|
||||
__timestamp: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
sortOperator({ ...formData, rolling_type: 'std' }, { ...queryObject, is_timeseries: true }),
|
||||
).toEqual({
|
||||
operation: 'sort',
|
||||
options: {
|
||||
columns: {
|
||||
__timestamp: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -54,70 +54,91 @@ const queryObject: QueryObject = {
|
||||
],
|
||||
};
|
||||
|
||||
describe('timeCompare', () => {
|
||||
it('time compare: skip transformation', () => {
|
||||
expect(timeCompareOperator(formData, queryObject)).toEqual(undefined);
|
||||
expect(timeCompareOperator({ ...formData, time_compare: [] }, queryObject)).toEqual(undefined);
|
||||
expect(timeCompareOperator({ ...formData, comparison_type: null }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
expect(timeCompareOperator({ ...formData, comparison_type: 'foobar' }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
test('time compare: skip transformation', () => {
|
||||
expect(timeCompareOperator(formData, queryObject)).toEqual(undefined);
|
||||
expect(timeCompareOperator({ ...formData, time_compare: [] }, queryObject)).toEqual(undefined);
|
||||
expect(timeCompareOperator({ ...formData, comparison_type: null }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
expect(timeCompareOperator({ ...formData, comparison_type: 'foobar' }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
timeCompareOperator(
|
||||
{ ...formData, comparison_type: 'values', time_compare: ['1 year ago', '1 year later'] },
|
||||
queryObject,
|
||||
),
|
||||
).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('time compare: absolute/percentage/ratio', () => {
|
||||
const comparisionTypes = ['absolute', 'percentage', 'ratio'];
|
||||
comparisionTypes.forEach(cType => {
|
||||
expect(
|
||||
timeCompareOperator(
|
||||
{ ...formData, comparison_type: 'values', time_compare: ['1 year ago', '1 year later'] },
|
||||
{ ...formData, comparison_type: cType, time_compare: ['1 year ago', '1 year later'] },
|
||||
queryObject,
|
||||
),
|
||||
).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('time compare: absolute/percentage/ratio', () => {
|
||||
const comparisionTypes = ['absolute', 'percentage', 'ratio'];
|
||||
comparisionTypes.forEach(cType => {
|
||||
expect(
|
||||
timeCompareOperator(
|
||||
{ ...formData, comparison_type: cType, time_compare: ['1 year ago', '1 year later'] },
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'compare',
|
||||
options: {
|
||||
source_columns: ['count(*)', 'count(*)'],
|
||||
compare_columns: ['count(*)__1 year ago', 'count(*)__1 year later'],
|
||||
compare_type: cType,
|
||||
drop_original_columns: true,
|
||||
},
|
||||
});
|
||||
).toEqual({
|
||||
operation: 'compare',
|
||||
options: {
|
||||
source_columns: ['count(*)', 'count(*)'],
|
||||
compare_columns: ['count(*)__1 year ago', 'count(*)__1 year later'],
|
||||
compare_type: cType,
|
||||
drop_original_columns: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('time compare pivot: skip transformation', () => {
|
||||
expect(timeComparePivotOperator(formData, queryObject)).toEqual(undefined);
|
||||
expect(timeComparePivotOperator({ ...formData, time_compare: [] }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
expect(timeComparePivotOperator({ ...formData, comparison_type: null }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
expect(timeCompareOperator({ ...formData, comparison_type: 'foobar' }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
test('time compare pivot: skip transformation', () => {
|
||||
expect(timeComparePivotOperator(formData, queryObject)).toEqual(undefined);
|
||||
expect(timeComparePivotOperator({ ...formData, time_compare: [] }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
expect(timeComparePivotOperator({ ...formData, comparison_type: null }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
expect(timeCompareOperator({ ...formData, comparison_type: 'foobar' }, queryObject)).toEqual(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('time compare pivot: values', () => {
|
||||
expect(
|
||||
timeComparePivotOperator(
|
||||
{ ...formData, comparison_type: 'values', time_compare: ['1 year ago', '1 year later'] },
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'pivot',
|
||||
options: {
|
||||
aggregates: {
|
||||
'count(*)': { operator: 'mean' },
|
||||
'count(*)__1 year ago': { operator: 'mean' },
|
||||
'count(*)__1 year later': { operator: 'mean' },
|
||||
},
|
||||
drop_missing_columns: false,
|
||||
columns: [],
|
||||
index: ['__timestamp'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('time compare pivot: values', () => {
|
||||
test('time compare pivot: absolute/percentage/ratio', () => {
|
||||
const comparisionTypes = ['absolute', 'percentage', 'ratio'];
|
||||
comparisionTypes.forEach(cType => {
|
||||
expect(
|
||||
timeComparePivotOperator(
|
||||
{ ...formData, comparison_type: 'values', time_compare: ['1 year ago', '1 year later'] },
|
||||
{ ...formData, comparison_type: cType, time_compare: ['1 year ago', '1 year later'] },
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'pivot',
|
||||
options: {
|
||||
aggregates: {
|
||||
'count(*)': { operator: 'mean' },
|
||||
'count(*)__1 year ago': { operator: 'mean' },
|
||||
'count(*)__1 year later': { operator: 'mean' },
|
||||
[`${cType}__count(*)__count(*)__1 year ago`]: { operator: 'mean' },
|
||||
[`${cType}__count(*)__count(*)__1 year later`]: { operator: 'mean' },
|
||||
},
|
||||
drop_missing_columns: false,
|
||||
columns: [],
|
||||
@ -125,27 +146,4 @@ describe('timeCompare', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('time compare pivot: absolute/percentage/ratio', () => {
|
||||
const comparisionTypes = ['absolute', 'percentage', 'ratio'];
|
||||
comparisionTypes.forEach(cType => {
|
||||
expect(
|
||||
timeComparePivotOperator(
|
||||
{ ...formData, comparison_type: cType, time_compare: ['1 year ago', '1 year later'] },
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'pivot',
|
||||
options: {
|
||||
aggregates: {
|
||||
[`${cType}__count(*)__count(*)__1 year ago`]: { operator: 'mean' },
|
||||
[`${cType}__count(*)__count(*)__1 year later`]: { operator: 'mean' },
|
||||
},
|
||||
drop_missing_columns: false,
|
||||
columns: [],
|
||||
index: ['__timestamp'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -16,31 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { buildQueryContext, getMetricLabel } from '@superset-ui/core';
|
||||
import { BoxPlotQueryFormData, BoxPlotQueryObjectWhiskerType } from './types';
|
||||
|
||||
const PERCENTILE_REGEX = /(\d+)\/(\d+) percentiles/;
|
||||
import { buildQueryContext } from '@superset-ui/core';
|
||||
import { boxplotOperator } from '@superset-ui/chart-controls';
|
||||
import { BoxPlotQueryFormData } from './types';
|
||||
|
||||
export default function buildQuery(formData: BoxPlotQueryFormData) {
|
||||
const { columns = [], granularity_sqla, groupby = [], whiskerOptions } = formData;
|
||||
const { columns = [], granularity_sqla, groupby = [] } = formData;
|
||||
return buildQueryContext(formData, baseQueryObject => {
|
||||
let whiskerType: BoxPlotQueryObjectWhiskerType;
|
||||
let percentiles: [number, number] | undefined;
|
||||
const { metrics = [] } = baseQueryObject;
|
||||
const percentileMatch = PERCENTILE_REGEX.exec(whiskerOptions as string);
|
||||
|
||||
if (whiskerOptions === 'Tukey' || !whiskerOptions) {
|
||||
whiskerType = 'tukey';
|
||||
} else if (whiskerOptions === 'Min/max (no outliers)') {
|
||||
whiskerType = 'min/max';
|
||||
} else if (percentileMatch) {
|
||||
whiskerType = 'percentile';
|
||||
percentiles = [parseInt(percentileMatch[1], 10), parseInt(percentileMatch[2], 10)];
|
||||
} else {
|
||||
throw new Error(`Unsupported whisker type: ${whiskerOptions}`);
|
||||
}
|
||||
const distributionColumns: string[] = [];
|
||||
|
||||
// For now default to using the temporal column as distribution column.
|
||||
// In the future this control should be made mandatory.
|
||||
if (!columns.length && granularity_sqla) {
|
||||
@ -51,17 +34,7 @@ export default function buildQuery(formData: BoxPlotQueryFormData) {
|
||||
...baseQueryObject,
|
||||
columns: [...distributionColumns, ...columns, ...groupby],
|
||||
series_columns: groupby,
|
||||
post_processing: [
|
||||
{
|
||||
operation: 'boxplot',
|
||||
options: {
|
||||
whisker_type: whiskerType,
|
||||
percentiles,
|
||||
groupby,
|
||||
metrics: metrics.map(getMetricLabel),
|
||||
},
|
||||
},
|
||||
],
|
||||
post_processing: [boxplotOperator(formData, baseQueryObject)],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
@ -23,7 +23,6 @@ import {
|
||||
QueryFormData,
|
||||
SetDataMaskHook,
|
||||
} from '@superset-ui/core';
|
||||
import { PostProcessingBoxplot } from '@superset-ui/core/lib/query/types/PostProcessing';
|
||||
import { EChartsOption } from 'echarts';
|
||||
import { EchartsTitleFormData, DEFAULT_TITLE_FORM_DATA } from '../types';
|
||||
|
||||
@ -53,8 +52,6 @@ export interface EchartsBoxPlotChartProps extends ChartProps {
|
||||
queriesData: ChartDataResponseResult[];
|
||||
}
|
||||
|
||||
export type BoxPlotQueryObjectWhiskerType = PostProcessingBoxplot['options']['whisker_type'];
|
||||
|
||||
export interface BoxPlotChartTransformedProps {
|
||||
formData: BoxPlotQueryFormData;
|
||||
height: number;
|
||||
|
@ -24,6 +24,8 @@ import {
|
||||
sortOperator,
|
||||
pivotOperator,
|
||||
resampleOperator,
|
||||
contributionOperator,
|
||||
prophetOperator,
|
||||
} from '@superset-ui/chart-controls';
|
||||
|
||||
export default function buildQuery(formData: QueryFormData) {
|
||||
@ -40,27 +42,8 @@ export default function buildQuery(formData: QueryFormData) {
|
||||
sortOperator(formData, { ...baseQueryObject, is_timeseries: true }),
|
||||
rollingWindowOperator(formData, baseQueryObject),
|
||||
pivotOperator(formData, { ...baseQueryObject, is_timeseries: true }),
|
||||
formData.contributionMode
|
||||
? {
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
orientation: formData.contributionMode,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
formData.forecastEnabled
|
||||
? {
|
||||
operation: 'prophet',
|
||||
options: {
|
||||
time_grain: formData.time_grain_sqla,
|
||||
periods: parseInt(formData.forecastPeriods, 10),
|
||||
confidence_interval: parseFloat(formData.forecastInterval),
|
||||
yearly_seasonality: formData.forecastSeasonalityYearly,
|
||||
weekly_seasonality: formData.forecastSeasonalityWeekly,
|
||||
daily_seasonality: formData.forecastSeasonalityDaily,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
contributionOperator(formData, baseQueryObject),
|
||||
prophetOperator(formData, baseQueryObject),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
Loading…
Reference in New Issue
Block a user