feat: support mulitple temporal filters in AdhocFilter and move the Time Section away (#21767)
This commit is contained in:
parent
25be9ab4bc
commit
a9b229dd1d
|
|
@ -16,8 +16,8 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Dataset } from '@superset-ui/chart-controls';
|
||||
import { DatasourceType } from '@superset-ui/core';
|
||||
import { Dataset } from './types';
|
||||
|
||||
export const TestDataset: Dataset = {
|
||||
column_format: {},
|
||||
|
|
@ -38,3 +38,4 @@ export * from './shared-controls/emitFilterControl';
|
|||
export * from './shared-controls/components';
|
||||
export * from './types';
|
||||
export * from './shared-controls/mixins';
|
||||
export * from './fixtures';
|
||||
|
|
|
|||
|
|
@ -38,19 +38,23 @@ export const legacyTimeseriesTime: ControlPanelSectionConfig = {
|
|||
],
|
||||
};
|
||||
|
||||
export const genericTime: ControlPanelSectionConfig = {
|
||||
...baseTimeSection,
|
||||
controlSetRows: [
|
||||
['granularity_sqla'],
|
||||
[hasGenericChartAxes ? null : 'time_grain_sqla'],
|
||||
['time_range'],
|
||||
],
|
||||
};
|
||||
export const genericTime: ControlPanelSectionConfig = hasGenericChartAxes
|
||||
? { controlSetRows: [] }
|
||||
: {
|
||||
...baseTimeSection,
|
||||
controlSetRows: [
|
||||
['granularity_sqla'],
|
||||
['time_grain_sqla'],
|
||||
['time_range'],
|
||||
],
|
||||
};
|
||||
|
||||
export const legacyRegularTime: ControlPanelSectionConfig = {
|
||||
...baseTimeSection,
|
||||
controlSetRows: [['granularity_sqla'], ['time_range']],
|
||||
};
|
||||
export const legacyRegularTime: ControlPanelSectionConfig = hasGenericChartAxes
|
||||
? { controlSetRows: [] }
|
||||
: {
|
||||
...baseTimeSection,
|
||||
controlSetRows: [['granularity_sqla'], ['time_range']],
|
||||
};
|
||||
|
||||
export const datasourceAndVizType: ControlPanelSectionConfig = {
|
||||
label: t('Datasource & Chart Type'),
|
||||
|
|
|
|||
|
|
@ -40,8 +40,9 @@ import {
|
|||
ColumnMeta,
|
||||
FilterOption,
|
||||
temporalColumnMixin,
|
||||
datePickerInAdhocFilterMixin,
|
||||
xAxisMixin,
|
||||
} from '..';
|
||||
import { xAxisMixin } from './mixins';
|
||||
|
||||
type Control = {
|
||||
savedMetrics?: Metric[] | null;
|
||||
|
|
@ -149,6 +150,7 @@ export const dndAdhocFilterControl: SharedControlConfig<
|
|||
datasource,
|
||||
}),
|
||||
provideFormDataToProps: true,
|
||||
...datePickerInAdhocFilterMixin,
|
||||
};
|
||||
|
||||
export const dndAdhocMetricsControl: SharedControlConfig<
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@
|
|||
* under the License.
|
||||
*/
|
||||
import {
|
||||
ensureIsArray,
|
||||
hasGenericChartAxes,
|
||||
NO_TIME_RANGE,
|
||||
QueryFormData,
|
||||
t,
|
||||
validateNonEmpty,
|
||||
|
|
@ -63,3 +65,57 @@ export const temporalColumnMixin: Pick<BaseControlConfig, 'mapStateToProps'> = {
|
|||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const datePickerInAdhocFilterMixin: Pick<
|
||||
BaseControlConfig,
|
||||
'initialValue'
|
||||
> = {
|
||||
initialValue: (control: ControlState, state: ControlPanelState | null) => {
|
||||
// skip initialValue if
|
||||
// 1) GENERIC_CHART_AXES is disabled
|
||||
// 2) there was a time filter in adhoc filters
|
||||
if (
|
||||
!hasGenericChartAxes ||
|
||||
ensureIsArray(control.value).findIndex(
|
||||
(flt: any) => flt?.operator === 'TEMPORAL_RANGE',
|
||||
) > -1
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// should migrate original granularity_sqla and time_range into adhoc filter
|
||||
// 1) granularity_sqla and time_range are existed
|
||||
if (state?.form_data?.granularity_sqla && state?.form_data?.time_range) {
|
||||
return [
|
||||
...ensureIsArray(control.value),
|
||||
{
|
||||
clause: 'WHERE',
|
||||
subject: state.form_data.granularity_sqla,
|
||||
operator: 'TEMPORAL_RANGE',
|
||||
comparator: state.form_data.time_range,
|
||||
expressionType: 'SIMPLE',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// should apply the default time filter into adhoc filter
|
||||
// 1) temporal column is existed in current datasource
|
||||
const temporalColumn =
|
||||
state?.datasource &&
|
||||
getTemporalColumns(state.datasource).defaultTemporalColumn;
|
||||
if (hasGenericChartAxes && temporalColumn) {
|
||||
return [
|
||||
...ensureIsArray(control.value),
|
||||
{
|
||||
clause: 'WHERE',
|
||||
subject: temporalColumn,
|
||||
operator: 'TEMPORAL_RANGE',
|
||||
comparator: state?.common?.conf?.DEFAULT_TIME_FILTER || NO_TIME_RANGE,
|
||||
expressionType: 'SIMPLE',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ import type {
|
|||
AdhocColumn,
|
||||
Column,
|
||||
DatasourceType,
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
Metric,
|
||||
QueryColumn,
|
||||
QueryFormColumn,
|
||||
QueryFormData,
|
||||
QueryFormMetric,
|
||||
|
|
@ -80,12 +80,15 @@ export interface Dataset {
|
|||
description: string | null;
|
||||
uid?: string;
|
||||
owners?: Owner[];
|
||||
filter_select?: boolean;
|
||||
filter_select_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ControlPanelState {
|
||||
form_data: QueryFormData;
|
||||
datasource: Dataset | QueryResponse | null;
|
||||
controls: ControlStateMapping;
|
||||
common: JsonObject;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -449,9 +452,7 @@ export type ColorFormatters = {
|
|||
|
||||
export default {};
|
||||
|
||||
export function isColumnMeta(
|
||||
column: AdhocColumn | ColumnMeta | QueryColumn,
|
||||
): column is ColumnMeta {
|
||||
export function isColumnMeta(column: AnyDict): column is ColumnMeta {
|
||||
return !!column && 'column_name' in column;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ import {
|
|||
isQueryResponse,
|
||||
} from '@superset-ui/chart-controls';
|
||||
|
||||
export const getTemporalColumns = (
|
||||
export function getTemporalColumns(
|
||||
datasource: ValueOf<Pick<ControlPanelState, 'datasource'>>,
|
||||
) => {
|
||||
) {
|
||||
const rv: {
|
||||
temporalColumns: ColumnMeta[] | QueryColumn[];
|
||||
defaultTemporalColumn: string | null | undefined;
|
||||
|
|
@ -61,4 +61,17 @@ export const getTemporalColumns = (
|
|||
}
|
||||
|
||||
return rv;
|
||||
};
|
||||
}
|
||||
|
||||
export function isTemporalColumn(
|
||||
columnName: string,
|
||||
datasource: ValueOf<Pick<ControlPanelState, 'datasource'>>,
|
||||
): boolean {
|
||||
const columns = getTemporalColumns(datasource).temporalColumns;
|
||||
for (let i = 0; i < columns.length; i += 1) {
|
||||
if (columns[i].column_name === columnName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,4 +24,4 @@ export { default as mainMetric } from './mainMetric';
|
|||
export { default as columnChoices } from './columnChoices';
|
||||
export * from './defineSavedMetrics';
|
||||
export * from './getStandardizedControls';
|
||||
export { getTemporalColumns } from './getTemporalColumns';
|
||||
export * from './getTemporalColumns';
|
||||
|
|
|
|||
|
|
@ -17,8 +17,12 @@
|
|||
* under the License.
|
||||
*/
|
||||
import { testQueryResponse, testQueryResults } from '@superset-ui/core';
|
||||
import { Dataset, getTemporalColumns } from '../../src';
|
||||
import { TestDataset } from '../fixtures';
|
||||
import {
|
||||
Dataset,
|
||||
getTemporalColumns,
|
||||
isTemporalColumn,
|
||||
TestDataset,
|
||||
} from '../../src';
|
||||
|
||||
test('get temporal columns from a Dataset', () => {
|
||||
expect(getTemporalColumns(TestDataset)).toEqual({
|
||||
|
|
@ -93,3 +97,8 @@ test('should accept empty Dataset or queryResponse', () => {
|
|||
defaultTemporalColumn: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('should determine temporal columns in a Dataset', () => {
|
||||
expect(isTemporalColumn('ds', TestDataset)).toBeTruthy();
|
||||
expect(isTemporalColumn('num', TestDataset)).toBeFalsy();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,11 +20,16 @@
|
|||
import buildQueryObject from './buildQueryObject';
|
||||
import DatasourceKey from './DatasourceKey';
|
||||
import { QueryFieldAliases, QueryFormData } from './types/QueryFormData';
|
||||
import { QueryContext, QueryObject } from './types/Query';
|
||||
import {
|
||||
BinaryQueryObjectFilterClause,
|
||||
QueryContext,
|
||||
QueryObject,
|
||||
} from './types/Query';
|
||||
import { SetDataMaskHook } from '../chart';
|
||||
import { JsonObject } from '../connection';
|
||||
import { normalizeTimeColumn } from './normalizeTimeColumn';
|
||||
import { isXAxisSet } from './getXAxis';
|
||||
import { hasGenericChartAxes, isXAxisSet } from './getXAxis';
|
||||
import { ensureIsArray } from '../utils';
|
||||
|
||||
const WRAP_IN_ARRAY = (baseQueryObject: QueryObject) => [baseQueryObject];
|
||||
|
||||
|
|
@ -48,15 +53,26 @@ export default function buildQueryContext(
|
|||
? { buildQuery: options, queryFields: {} }
|
||||
: options || {};
|
||||
let queries = buildQuery(buildQueryObject(formData, queryFields));
|
||||
// --- query mutator begin ---
|
||||
// todo(Yongjie): move the query mutator into buildQueryObject instead of buildQueryContext
|
||||
queries.forEach(query => {
|
||||
if (Array.isArray(query.post_processing)) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
query.post_processing = query.post_processing.filter(Boolean);
|
||||
}
|
||||
if (hasGenericChartAxes && query.time_range) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
query.filters = ensureIsArray(query.filters).map(flt =>
|
||||
flt?.op === 'TEMPORAL_RANGE'
|
||||
? ({ ...flt, val: query.time_range } as BinaryQueryObjectFilterClause)
|
||||
: flt,
|
||||
);
|
||||
}
|
||||
});
|
||||
if (isXAxisSet(formData)) {
|
||||
queries = queries.map(query => normalizeTimeColumn(formData, query));
|
||||
}
|
||||
// --- query mutator end ---
|
||||
return {
|
||||
datasource: new DatasourceKey(formData.datasource).toObject(),
|
||||
force: formData.force || false,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const BINARY_OPERATORS = [
|
|||
'ILIKE',
|
||||
'LIKE',
|
||||
'REGEX',
|
||||
'TEMPORAL_RANGE',
|
||||
] as const;
|
||||
|
||||
/** List of operators that require another operand that is a set */
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ export const CtasEnum = {
|
|||
|
||||
export type QueryColumn = {
|
||||
name: string;
|
||||
column_name?: string;
|
||||
type: string | null;
|
||||
is_dttm: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
*/
|
||||
import { buildQueryContext } from '@superset-ui/core';
|
||||
import * as queryModule from '../../src/query/normalizeTimeColumn';
|
||||
import * as getXAxisModule from '../../src/query/getXAxis';
|
||||
|
||||
describe('buildQueryContext', () => {
|
||||
it('should build datasource for table sources and apply defaults', () => {
|
||||
|
|
@ -98,6 +99,7 @@ describe('buildQueryContext', () => {
|
|||
]),
|
||||
);
|
||||
});
|
||||
// todo(Yongjie): move these test case into buildQueryObject.test.ts
|
||||
it('should remove undefined value in post_processing', () => {
|
||||
const queryContext = buildQueryContext(
|
||||
{
|
||||
|
|
@ -124,12 +126,9 @@ describe('buildQueryContext', () => {
|
|||
]);
|
||||
});
|
||||
it('should call normalizeTimeColumn if GENERIC_CHART_AXES is enabled and has x_axis', () => {
|
||||
// @ts-ignore
|
||||
const spy = jest.spyOn(window, 'window', 'get').mockImplementation(() => ({
|
||||
featureFlags: {
|
||||
GENERIC_CHART_AXES: true,
|
||||
},
|
||||
}));
|
||||
Object.defineProperty(getXAxisModule, 'hasGenericChartAxes', {
|
||||
value: true,
|
||||
});
|
||||
const spyNormalizeTimeColumn = jest.spyOn(
|
||||
queryModule,
|
||||
'normalizeTimeColumn',
|
||||
|
|
@ -144,16 +143,12 @@ describe('buildQueryContext', () => {
|
|||
() => [{}],
|
||||
);
|
||||
expect(spyNormalizeTimeColumn).toBeCalled();
|
||||
spy.mockRestore();
|
||||
spyNormalizeTimeColumn.mockRestore();
|
||||
});
|
||||
it("shouldn't call normalizeTimeColumn if GENERIC_CHART_AXES is disabled", () => {
|
||||
// @ts-ignore
|
||||
const spy = jest.spyOn(window, 'window', 'get').mockImplementation(() => ({
|
||||
featureFlags: {
|
||||
GENERIC_CHART_AXES: false,
|
||||
},
|
||||
}));
|
||||
Object.defineProperty(getXAxisModule, 'hasGenericChartAxes', {
|
||||
value: false,
|
||||
});
|
||||
const spyNormalizeTimeColumn = jest.spyOn(
|
||||
queryModule,
|
||||
'normalizeTimeColumn',
|
||||
|
|
@ -167,7 +162,43 @@ describe('buildQueryContext', () => {
|
|||
() => [{}],
|
||||
);
|
||||
expect(spyNormalizeTimeColumn).not.toBeCalled();
|
||||
spy.mockRestore();
|
||||
spyNormalizeTimeColumn.mockRestore();
|
||||
});
|
||||
it('should orverride time filter if GENERIC_CHART_AXES is enabled', () => {
|
||||
Object.defineProperty(getXAxisModule, 'hasGenericChartAxes', {
|
||||
value: true,
|
||||
});
|
||||
|
||||
const queryContext = buildQueryContext(
|
||||
{
|
||||
datasource: '5__table',
|
||||
viz_type: 'table',
|
||||
},
|
||||
() => [
|
||||
{
|
||||
filters: [
|
||||
{
|
||||
col: 'col1',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2001 : 2002',
|
||||
},
|
||||
{
|
||||
col: 'col2',
|
||||
op: 'IN',
|
||||
val: ['a', 'b'],
|
||||
},
|
||||
],
|
||||
time_range: '1990 : 1991',
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(queryContext.queries[0].filters).toEqual([
|
||||
{ col: 'col1', op: 'TEMPORAL_RANGE', val: '1990 : 1991' },
|
||||
{
|
||||
col: 'col2',
|
||||
op: 'IN',
|
||||
val: ['a', 'b'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,54 +18,9 @@
|
|||
*/
|
||||
import { isXAxisSet } from '@superset-ui/core';
|
||||
|
||||
describe('GENERIC_CHART_AXES is enabled', () => {
|
||||
let windowSpy: any;
|
||||
|
||||
beforeAll(() => {
|
||||
// @ts-ignore
|
||||
windowSpy = jest.spyOn(window, 'window', 'get').mockImplementation(() => ({
|
||||
featureFlags: {
|
||||
GENERIC_CHART_AXES: true,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
windowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('isEnabledAxies when FF is disabled', () => {
|
||||
expect(
|
||||
isXAxisSet({ datasource: '123', viz_type: 'table' }),
|
||||
).not.toBeTruthy();
|
||||
expect(
|
||||
isXAxisSet({ datasource: '123', viz_type: 'table', x_axis: 'axis' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GENERIC_CHART_AXES is disabled', () => {
|
||||
let windowSpy: any;
|
||||
|
||||
beforeAll(() => {
|
||||
// @ts-ignore
|
||||
windowSpy = jest.spyOn(window, 'window', 'get').mockImplementation(() => ({
|
||||
featureFlags: {
|
||||
GENERIC_CHART_AXES: false,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
windowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('isEnabledAxies when FF is disabled', () => {
|
||||
expect(
|
||||
isXAxisSet({ datasource: '123', viz_type: 'table' }),
|
||||
).not.toBeTruthy();
|
||||
expect(
|
||||
isXAxisSet({ datasource: '123', viz_type: 'table', x_axis: 'axis' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
test('isXAxisSet', () => {
|
||||
expect(isXAxisSet({ datasource: '123', viz_type: 'table' })).not.toBeTruthy();
|
||||
expect(
|
||||
isXAxisSet({ datasource: '123', viz_type: 'table', x_axis: 'axis' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import { styleControlSetItem } from './controls/style';
|
|||
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
sections.legacyTimeseriesTime,
|
||||
sections.genericTime,
|
||||
{
|
||||
label: t('Query'),
|
||||
expanded: true,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export interface ModalProps {
|
|||
draggableConfig?: DraggableProps;
|
||||
destroyOnClose?: boolean;
|
||||
maskClosable?: boolean;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
interface StyledModalProps {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ test('creates hydrate action from initial data', () => {
|
|||
standalone: null,
|
||||
force: null,
|
||||
saveAction: null,
|
||||
common: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -159,6 +160,7 @@ test('creates hydrate action with existing state', () => {
|
|||
standalone: null,
|
||||
force: null,
|
||||
saveAction: null,
|
||||
common: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ export const hydrateExplore =
|
|||
force: getUrlParam(URL_PARAMS.force),
|
||||
metadata,
|
||||
saveAction,
|
||||
common,
|
||||
};
|
||||
|
||||
// apply initial mapStateToProps for all controls, must execute AFTER
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { css, styled, t, useTheme, NO_TIME_RANGE } from '@superset-ui/core';
|
|||
import Button from 'src/components/Button';
|
||||
import ControlHeader from 'src/explore/components/ControlHeader';
|
||||
import Label, { Type } from 'src/components/Label';
|
||||
import Modal from 'src/components/Modal';
|
||||
import { Divider } from 'src/components';
|
||||
import Icons from 'src/components/Icons';
|
||||
import Select from 'src/components/Select/Select';
|
||||
|
|
@ -32,10 +33,12 @@ import ControlPopover from '../ControlPopover/ControlPopover';
|
|||
|
||||
import { DateFilterControlProps, FrameType } from './types';
|
||||
import {
|
||||
DATE_FILTER_TEST_KEY,
|
||||
fetchTimeRange,
|
||||
FRAME_OPTIONS,
|
||||
getDateFilterControlTestId,
|
||||
guessFrame,
|
||||
useDefaultTimeFilter,
|
||||
} from './utils';
|
||||
import {
|
||||
CommonFrame,
|
||||
|
|
@ -44,7 +47,6 @@ import {
|
|||
AdvancedFrame,
|
||||
} from './components';
|
||||
|
||||
const StyledPopover = styled(ControlPopover)``;
|
||||
const StyledRangeType = styled(Select)`
|
||||
width: 272px;
|
||||
`;
|
||||
|
|
@ -121,12 +123,15 @@ const IconWrapper = styled.span`
|
|||
|
||||
export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
const {
|
||||
value = NO_TIME_RANGE,
|
||||
onChange,
|
||||
type,
|
||||
onOpenPopover = noOp,
|
||||
onClosePopover = noOp,
|
||||
overlayStyle = 'Popover',
|
||||
} = props;
|
||||
const defaultTimeFilter = useDefaultTimeFilter();
|
||||
|
||||
const value = props.value ?? defaultTimeFilter;
|
||||
const [actualTimeRange, setActualTimeRange] = useState<string>(value);
|
||||
|
||||
const [show, setShow] = useState<boolean>(false);
|
||||
|
|
@ -137,6 +142,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
|||
const [validTimeRange, setValidTimeRange] = useState<boolean>(false);
|
||||
const [evalResponse, setEvalResponse] = useState<string>(value);
|
||||
const [tooltipTitle, setTooltipTitle] = useState<string>(value);
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (value === NO_TIME_RANGE) {
|
||||
|
|
@ -180,6 +186,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
|||
setValidTimeRange(true);
|
||||
}
|
||||
setLastFetchedTimeRange(value);
|
||||
setEvalResponse(actualRange || value);
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
|
|
@ -225,7 +232,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
|||
setShow(false);
|
||||
}
|
||||
|
||||
const togglePopover = () => {
|
||||
const toggleOverlay = () => {
|
||||
if (show) {
|
||||
onHide();
|
||||
onClosePopover();
|
||||
|
|
@ -242,8 +249,6 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
|||
setFrame(value);
|
||||
}
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const overlayContent = (
|
||||
<ContentStyleWrapper>
|
||||
<div className="control-label">{t('RANGE TYPE')}</div>
|
||||
|
|
@ -266,7 +271,9 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
|||
{frame === 'Custom' && (
|
||||
<CustomFrame value={timeRangeValue} onChange={setTimeRangeValue} />
|
||||
)}
|
||||
{frame === 'No filter' && <div data-test="no-filter" />}
|
||||
{frame === 'No filter' && (
|
||||
<div data-test={DATE_FILTER_TEST_KEY.noFilter} />
|
||||
)}
|
||||
<Divider />
|
||||
<div>
|
||||
<div className="section-title">{t('Actual time range')}</div>
|
||||
|
|
@ -285,7 +292,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
|||
cta
|
||||
key="cancel"
|
||||
onClick={onHide}
|
||||
data-test="cancel-button"
|
||||
data-test={DATE_FILTER_TEST_KEY.cancelButton}
|
||||
>
|
||||
{t('CANCEL')}
|
||||
</Button>
|
||||
|
|
@ -310,25 +317,56 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
|||
</IconWrapper>
|
||||
);
|
||||
|
||||
const popoverContent = (
|
||||
<ControlPopover
|
||||
placement="right"
|
||||
trigger="click"
|
||||
content={overlayContent}
|
||||
title={title}
|
||||
defaultVisible={show}
|
||||
visible={show}
|
||||
onVisibleChange={toggleOverlay}
|
||||
overlayStyle={{ width: '600px' }}
|
||||
>
|
||||
<Tooltip placement="top" title={tooltipTitle}>
|
||||
<Label
|
||||
className="pointer"
|
||||
data-test={DATE_FILTER_TEST_KEY.popoverOverlay}
|
||||
>
|
||||
{actualTimeRange}
|
||||
</Label>
|
||||
</Tooltip>
|
||||
</ControlPopover>
|
||||
);
|
||||
|
||||
const modalContent = (
|
||||
<>
|
||||
<Tooltip placement="top" title={tooltipTitle}>
|
||||
<Label
|
||||
className="pointer"
|
||||
onClick={toggleOverlay}
|
||||
data-test={DATE_FILTER_TEST_KEY.modalOverlay}
|
||||
>
|
||||
{actualTimeRange}
|
||||
</Label>
|
||||
</Tooltip>
|
||||
<Modal
|
||||
title={title}
|
||||
show={show}
|
||||
onHide={toggleOverlay}
|
||||
width="600px"
|
||||
hideFooter
|
||||
zIndex={Number.MAX_SAFE_INTEGER}
|
||||
>
|
||||
{overlayContent}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlHeader {...props} />
|
||||
<StyledPopover
|
||||
placement="right"
|
||||
trigger="click"
|
||||
content={overlayContent}
|
||||
title={title}
|
||||
defaultVisible={show}
|
||||
visible={show}
|
||||
onVisibleChange={togglePopover}
|
||||
overlayStyle={{ width: '600px' }}
|
||||
>
|
||||
<Tooltip placement="top" title={tooltipTitle}>
|
||||
<Label className="pointer" data-test="time-range-trigger">
|
||||
{actualTimeRange}
|
||||
</Label>
|
||||
</Tooltip>
|
||||
</StyledPopover>
|
||||
{overlayStyle === 'Modal' ? modalContent : popoverContent}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { Radio } from 'src/components/Radio';
|
|||
import {
|
||||
COMMON_RANGE_OPTIONS,
|
||||
COMMON_RANGE_SET,
|
||||
DATE_FILTER_TEST_KEY,
|
||||
} from 'src/explore/components/controls/DateFilterControl/utils';
|
||||
import {
|
||||
CommonRangeType,
|
||||
|
|
@ -38,7 +39,12 @@ export function CommonFrame(props: FrameComponentProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="section-title">{t('Configure Time Range: Last...')}</div>
|
||||
<div
|
||||
className="section-title"
|
||||
data-test={DATE_FILTER_TEST_KEY.commonFrame}
|
||||
>
|
||||
{t('Configure Time Range: Last...')}
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={commonRange}
|
||||
onChange={(e: any) => props.onChange(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -21,4 +21,5 @@ export {
|
|||
DATE_FILTER_CONTROL_TEST_ID,
|
||||
fetchTimeRange,
|
||||
guessFrame,
|
||||
DATE_FILTER_TEST_KEY,
|
||||
} from './utils';
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { AdvancedFrame } from '.';
|
||||
import { AdvancedFrame } from '../components';
|
||||
|
||||
test('renders with default props', () => {
|
||||
render(<AdvancedFrame onChange={jest.fn()} value="Last week" />);
|
||||
|
|
@ -22,7 +22,7 @@ import { Provider } from 'react-redux';
|
|||
import configureStore from 'redux-mock-store';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CustomFrame } from '.';
|
||||
import { CustomFrame } from '../components';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* 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 React from 'react';
|
||||
import thunk from 'redux-thunk';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { NO_TIME_RANGE } from '@superset-ui/core';
|
||||
import DateFilterLabel from '..';
|
||||
import { DateFilterControlProps } from '../types';
|
||||
import { DATE_FILTER_TEST_KEY } from '../utils';
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
function setup(
|
||||
props: Omit<DateFilterControlProps, 'name'>,
|
||||
store: any = mockStore({}),
|
||||
) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<DateFilterLabel
|
||||
name="time_range"
|
||||
onChange={props.onChange}
|
||||
overlayStyle={props.overlayStyle}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
test('DateFilter with default props', () => {
|
||||
render(setup({ onChange: () => {} }));
|
||||
// label
|
||||
expect(screen.getByText(NO_TIME_RANGE)).toBeInTheDocument();
|
||||
|
||||
// should be popover by default
|
||||
userEvent.click(screen.getByText(NO_TIME_RANGE));
|
||||
expect(
|
||||
screen.getByTestId(DATE_FILTER_TEST_KEY.popoverOverlay),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('DateFilter shoule be applied the overlayStyle props', () => {
|
||||
render(setup({ onChange: () => {}, overlayStyle: 'Modal' }));
|
||||
// should be Modal as overlay
|
||||
userEvent.click(screen.getByText(NO_TIME_RANGE));
|
||||
expect(
|
||||
screen.getByTestId(DATE_FILTER_TEST_KEY.modalOverlay),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('DateFilter shoule be applied the global config time_filter from the store', () => {
|
||||
render(
|
||||
setup(
|
||||
{ onChange: () => {} },
|
||||
mockStore({
|
||||
common: { conf: { DEFAULT_TIME_FILTER: 'Last week' } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
// the label should be 'Last week'
|
||||
expect(screen.getByText('Last week')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByText('Last week'));
|
||||
expect(
|
||||
screen.getByTestId(DATE_FILTER_TEST_KEY.commonFrame),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -99,4 +99,5 @@ export interface DateFilterControlProps {
|
|||
type?: Type;
|
||||
onOpenPopover?: () => void;
|
||||
onClosePopover?: () => void;
|
||||
overlayStyle?: 'Modal' | 'Popover';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,3 +137,11 @@ export const DATE_FILTER_CONTROL_TEST_ID = 'date-filter-control';
|
|||
export const getDateFilterControlTestId = testWithId(
|
||||
DATE_FILTER_CONTROL_TEST_ID,
|
||||
);
|
||||
|
||||
export enum DATE_FILTER_TEST_KEY {
|
||||
commonFrame = 'common-frame',
|
||||
modalOverlay = 'modal-overlay',
|
||||
popoverOverlay = 'time-range-trigger',
|
||||
noFilter = 'no-filter',
|
||||
cancelButton = 'cancel-button',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@
|
|||
* under the License.
|
||||
*/
|
||||
import rison from 'rison';
|
||||
import { SupersetClient, NO_TIME_RANGE } from '@superset-ui/core';
|
||||
import { SupersetClient, NO_TIME_RANGE, JsonObject } from '@superset-ui/core';
|
||||
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
COMMON_RANGE_VALUES_SET,
|
||||
CALENDAR_RANGE_VALUES_SET,
|
||||
|
|
@ -84,3 +85,11 @@ export const fetchTimeRange = async (
|
|||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function useDefaultTimeFilter() {
|
||||
return (
|
||||
useSelector(
|
||||
(state: JsonObject) => state?.common?.conf?.DEFAULT_TIME_FILTER,
|
||||
) ?? NO_TIME_RANGE
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { DndItemType } from 'src/explore/components/DndItemType';
|
|||
import AdhocFilterPopoverTrigger from 'src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger';
|
||||
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
|
||||
import { OptionSortType } from 'src/explore/types';
|
||||
import { useGetTimeRangeLabel } from 'src/explore/components/controls/FilterControl/utils';
|
||||
import OptionWrapper from './OptionWrapper';
|
||||
|
||||
export interface DndAdhocFilterOptionProps {
|
||||
|
|
@ -45,6 +46,8 @@ export default function DndAdhocFilterOption({
|
|||
partitionColumn,
|
||||
index,
|
||||
}: DndAdhocFilterOptionProps) {
|
||||
const { actualTimeRange, title } = useGetTimeRangeLabel(adhocFilter);
|
||||
|
||||
return (
|
||||
<AdhocFilterPopoverTrigger
|
||||
key={index}
|
||||
|
|
@ -57,8 +60,8 @@ export default function DndAdhocFilterOption({
|
|||
<OptionWrapper
|
||||
key={index}
|
||||
index={index}
|
||||
label={adhocFilter.getDefaultLabel()}
|
||||
tooltipTitle={adhocFilter.getTooltipTitle()}
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
|
||||
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
|
||||
clickClose={onClickClose}
|
||||
onShiftOptions={onShiftOptions}
|
||||
type={DndItemType.FilterOption}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,19 @@
|
|||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { FeatureFlag, GenericDataType } from '@superset-ui/core';
|
||||
import thunk from 'redux-thunk';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import {
|
||||
ensureIsArray,
|
||||
FeatureFlag,
|
||||
GenericDataType,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { ColumnMeta } from '@superset-ui/chart-controls';
|
||||
import { TimeseriesDefaultFormData } from '@superset-ui/plugin-chart-echarts';
|
||||
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
|
||||
import AdhocFilter, {
|
||||
|
|
@ -28,7 +40,6 @@ import {
|
|||
DndFilterSelectProps,
|
||||
} from 'src/explore/components/controls/DndColumnSelectControl/DndFilterSelect';
|
||||
import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
|
||||
import { TimeseriesDefaultFormData } from '@superset-ui/plugin-chart-echarts';
|
||||
|
||||
const defaultProps: DndFilterSelectProps = {
|
||||
type: 'DndFilterSelect',
|
||||
|
|
@ -56,8 +67,32 @@ afterAll(() => {
|
|||
window.featureFlags = {};
|
||||
});
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
function setup({
|
||||
value = undefined,
|
||||
formData = baseFormData,
|
||||
columns = [],
|
||||
}: {
|
||||
value?: AdhocFilter;
|
||||
formData?: QueryFormData;
|
||||
columns?: ColumnMeta[];
|
||||
} = {}) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<DndFilterSelect
|
||||
{...defaultProps}
|
||||
value={ensureIsArray(value)}
|
||||
formData={formData}
|
||||
columns={columns}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
test('renders with default props', async () => {
|
||||
render(<DndFilterSelect {...defaultProps} />, { useDnd: true });
|
||||
render(setup(), { useDnd: true });
|
||||
expect(
|
||||
await screen.findByText('Drop columns or metrics here'),
|
||||
).toBeInTheDocument();
|
||||
|
|
@ -68,7 +103,7 @@ test('renders with value', async () => {
|
|||
sqlExpression: 'COUNT(*)',
|
||||
expressionType: EXPRESSION_TYPES.SQL,
|
||||
});
|
||||
render(<DndFilterSelect {...defaultProps} value={[value]} />, {
|
||||
render(setup({ value }), {
|
||||
useDnd: true,
|
||||
});
|
||||
expect(await screen.findByText('COUNT(*)')).toBeInTheDocument();
|
||||
|
|
@ -76,14 +111,13 @@ test('renders with value', async () => {
|
|||
|
||||
test('renders options with saved metric', async () => {
|
||||
render(
|
||||
<DndFilterSelect
|
||||
{...defaultProps}
|
||||
formData={{
|
||||
setup({
|
||||
formData: {
|
||||
...baseFormData,
|
||||
...TimeseriesDefaultFormData,
|
||||
metrics: ['saved_metric'],
|
||||
}}
|
||||
/>,
|
||||
},
|
||||
}),
|
||||
{
|
||||
useDnd: true,
|
||||
},
|
||||
|
|
@ -95,17 +129,16 @@ test('renders options with saved metric', async () => {
|
|||
|
||||
test('renders options with column', async () => {
|
||||
render(
|
||||
<DndFilterSelect
|
||||
{...defaultProps}
|
||||
columns={[
|
||||
setup({
|
||||
columns: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'VARCHAR',
|
||||
type_generic: GenericDataType.STRING,
|
||||
column_name: 'Column',
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
],
|
||||
}),
|
||||
{
|
||||
useDnd: true,
|
||||
},
|
||||
|
|
@ -121,14 +154,13 @@ test('renders options with adhoc metric', async () => {
|
|||
metric_name: 'avg__num',
|
||||
});
|
||||
render(
|
||||
<DndFilterSelect
|
||||
{...defaultProps}
|
||||
formData={{
|
||||
setup({
|
||||
formData: {
|
||||
...baseFormData,
|
||||
...TimeseriesDefaultFormData,
|
||||
metrics: [adhocMetric],
|
||||
}}
|
||||
/>,
|
||||
},
|
||||
}),
|
||||
{
|
||||
useDnd: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
FeatureFlag,
|
||||
hasGenericChartAxes,
|
||||
isFeatureEnabled,
|
||||
logging,
|
||||
Metric,
|
||||
|
|
@ -27,7 +28,12 @@ import {
|
|||
SupersetClient,
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import { ColumnMeta, withDndFallback } from '@superset-ui/chart-controls';
|
||||
import {
|
||||
ColumnMeta,
|
||||
isColumnMeta,
|
||||
isTemporalColumn,
|
||||
withDndFallback,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import {
|
||||
OPERATOR_ENUM_TO_OPERATOR_TYPE,
|
||||
Operators,
|
||||
|
|
@ -50,6 +56,7 @@ import { DndItemType } from 'src/explore/components/DndItemType';
|
|||
import { ControlComponentProps } from 'src/explore/components/Control';
|
||||
import AdhocFilterControl from '../FilterControl/AdhocFilterControl';
|
||||
import DndAdhocFilterOption from './DndAdhocFilterOption';
|
||||
import { useDefaultTimeFilter } from '../DateFilterControl/utils';
|
||||
|
||||
const EMPTY_OBJECT = {};
|
||||
const DND_ACCEPTED_TYPES = [
|
||||
|
|
@ -324,6 +331,7 @@ const DndFilterSelect = (props: DndFilterSelectProps) => {
|
|||
togglePopover(true);
|
||||
}, [togglePopover]);
|
||||
|
||||
const defaultTimeFilter = useDefaultTimeFilter();
|
||||
const adhocFilter = useMemo(() => {
|
||||
if (isSavedMetric(droppedItem)) {
|
||||
return new AdhocFilter({
|
||||
|
|
@ -346,6 +354,15 @@ const DndFilterSelect = (props: DndFilterSelectProps) => {
|
|||
config.operator = OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.IN].operation;
|
||||
config.operatorId = Operators.IN;
|
||||
}
|
||||
if (
|
||||
hasGenericChartAxes &&
|
||||
isColumnMeta(droppedItem) &&
|
||||
isTemporalColumn(droppedItem?.column_name, props.datasource)
|
||||
) {
|
||||
config.operator = Operators.TEMPORAL_RANGE;
|
||||
config.operatorId = Operators.TEMPORAL_RANGE;
|
||||
config.comparator = defaultTimeFilter;
|
||||
}
|
||||
return new AdhocFilter(config);
|
||||
}, [droppedItem]);
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,9 @@ function translateToSql(adhocMetric, { useSimple } = {}) {
|
|||
const { subject, comparator } = adhocMetric;
|
||||
const operator =
|
||||
adhocMetric.operator &&
|
||||
CUSTOM_OPERATIONS.indexOf(adhocMetric.operator) >= 0
|
||||
// 'LATEST PARTITION' supported callback only
|
||||
adhocMetric.operator ===
|
||||
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.LATEST_PARTITION].operation
|
||||
? OPERATORS_TO_SQL[adhocMetric.operator](adhocMetric)
|
||||
: OPERATORS_TO_SQL[adhocMetric.operator];
|
||||
return getSimpleSQLExpression(subject, operator, comparator);
|
||||
|
|
|
|||
|
|
@ -18,8 +18,12 @@
|
|||
*/
|
||||
/* eslint-disable no-unused-expressions */
|
||||
import React from 'react';
|
||||
import * as redux from 'react-redux';
|
||||
import sinon from 'sinon';
|
||||
import { shallow } from 'enzyme';
|
||||
import thunk from 'redux-thunk';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import AdhocFilter, {
|
||||
EXPRESSION_TYPES,
|
||||
|
|
@ -37,6 +41,7 @@ import * as featureFlags from 'src/featureFlags';
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { TestDataset } from '@superset-ui/chart-controls';
|
||||
import AdhocFilterEditPopoverSimpleTabContent, {
|
||||
useSimpleTabFilterProps,
|
||||
Props,
|
||||
|
|
@ -99,10 +104,11 @@ const getAdvancedDataTypeTestProps = (overrides?: Record<string, any>) => {
|
|||
onChange,
|
||||
options: [{ type: 'DOUBLE', column_name: 'advancedDataType', id: 5 }],
|
||||
datasource: {
|
||||
id: 'test-id',
|
||||
columns: [],
|
||||
type: 'postgres',
|
||||
filter_select: false,
|
||||
...TestDataset,
|
||||
...{
|
||||
columns: [],
|
||||
filter_select: false,
|
||||
},
|
||||
},
|
||||
partitionColumn: 'test',
|
||||
...overrides,
|
||||
|
|
@ -114,15 +120,18 @@ const getAdvancedDataTypeTestProps = (overrides?: Record<string, any>) => {
|
|||
function setup(overrides?: Record<string, any>) {
|
||||
const onChange = sinon.spy();
|
||||
const validHandler = sinon.spy();
|
||||
const spy = jest.spyOn(redux, 'useSelector');
|
||||
spy.mockReturnValue({});
|
||||
const props = {
|
||||
adhocFilter: simpleAdhocFilter,
|
||||
onChange,
|
||||
options,
|
||||
datasource: {
|
||||
id: 'test-id',
|
||||
columns: [],
|
||||
type: 'postgres',
|
||||
filter_select: false,
|
||||
...TestDataset,
|
||||
...{
|
||||
columns: [],
|
||||
filter_select: false,
|
||||
},
|
||||
},
|
||||
partitionColumn: 'test',
|
||||
...overrides,
|
||||
|
|
@ -372,14 +381,19 @@ fetchMock.get(ADVANCED_DATA_TYPE_ENDPOINT_INVALID, {
|
|||
values: [],
|
||||
},
|
||||
});
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
describe('AdhocFilterEditPopoverSimpleTabContent Advanced data Type Test', () => {
|
||||
const setupFilter = async (props: Props) => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<AdhocFilterEditPopoverSimpleTabContent {...props} />
|
||||
</ThemeProvider>,
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<AdhocFilterEditPopoverSimpleTabContent {...props} />
|
||||
</ThemeProvider>
|
||||
,
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,7 +19,14 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import FormItem from 'src/components/Form/FormItem';
|
||||
import { Select } from 'src/components';
|
||||
import { t, SupersetClient, SupersetTheme, styled } from '@superset-ui/core';
|
||||
import {
|
||||
t,
|
||||
SupersetClient,
|
||||
SupersetTheme,
|
||||
styled,
|
||||
hasGenericChartAxes,
|
||||
isDefined,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
Operators,
|
||||
OPERATORS_OPTIONS,
|
||||
|
|
@ -39,8 +46,14 @@ import { Tooltip } from 'src/components/Tooltip';
|
|||
import { Input } from 'src/components/Input';
|
||||
import { optionLabel } from 'src/utils/common';
|
||||
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
|
||||
import { ColumnMeta } from '@superset-ui/chart-controls';
|
||||
import {
|
||||
ColumnMeta,
|
||||
Dataset,
|
||||
isTemporalColumn,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import useAdvancedDataTypes from './useAdvancedDataTypes';
|
||||
import { useDatePickerInAdhocFilter } from '../utils';
|
||||
import { useDefaultTimeFilter } from '../../DateFilterControl/utils';
|
||||
|
||||
const StyledInput = styled(Input)`
|
||||
margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
|
||||
|
|
@ -88,12 +101,7 @@ export interface Props {
|
|||
adhocFilter: AdhocFilter;
|
||||
onChange: (filter: AdhocFilter) => void;
|
||||
options: ColumnType[];
|
||||
datasource: {
|
||||
id: string;
|
||||
columns: ColumnMeta[];
|
||||
type: string;
|
||||
filter_select: boolean;
|
||||
};
|
||||
datasource: Dataset;
|
||||
partitionColumn: string;
|
||||
operators?: Operators[];
|
||||
validHandler: (isValid: boolean) => void;
|
||||
|
|
@ -106,6 +114,8 @@ export interface AdvancedDataTypesState {
|
|||
}
|
||||
|
||||
export const useSimpleTabFilterProps = (props: Props) => {
|
||||
const defaultTimeFilter = useDefaultTimeFilter();
|
||||
|
||||
const isOperatorRelevant = (operator: Operators, subject: string) => {
|
||||
const column = props.datasource.columns?.find(
|
||||
col => col.column_name === subject,
|
||||
|
|
@ -116,10 +126,14 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
|||
!!column && (column.type === 'INT' || column.type === 'INTEGER');
|
||||
const isColumnFunction = !!column && !!column.expression;
|
||||
|
||||
if (operator && CUSTOM_OPERATORS.has(operator)) {
|
||||
if (operator && operator === Operators.LATEST_PARTITION) {
|
||||
const { partitionColumn } = props;
|
||||
return partitionColumn && subject && subject === partitionColumn;
|
||||
}
|
||||
if (operator && operator === Operators.TEMPORAL_RANGE) {
|
||||
// hide the TEMPORAL_RANGE operator
|
||||
return false;
|
||||
}
|
||||
if (operator === Operators.IS_TRUE || operator === Operators.IS_FALSE) {
|
||||
return isColumnBoolean || isColumnNumber || isColumnFunction;
|
||||
}
|
||||
|
|
@ -152,17 +166,33 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
|||
subject = option.label;
|
||||
clause = CLAUSES.HAVING;
|
||||
}
|
||||
const { operator, operatorId } = props.adhocFilter;
|
||||
let { operator, operatorId, comparator } = props.adhocFilter;
|
||||
operator =
|
||||
operator && operatorId && isOperatorRelevant(operatorId, subject)
|
||||
? OPERATOR_ENUM_TO_OPERATOR_TYPE[operatorId].operation
|
||||
: null;
|
||||
if (!isDefined(operator)) {
|
||||
// if operator is `null`, use the `IN` and reset the comparator.
|
||||
operator = Operators.IN;
|
||||
operatorId = Operators.IN;
|
||||
comparator = undefined;
|
||||
}
|
||||
|
||||
if (hasGenericChartAxes && isTemporalColumn(id, props.datasource)) {
|
||||
subject = id;
|
||||
operator = Operators.TEMPORAL_RANGE;
|
||||
operatorId = Operators.TEMPORAL_RANGE;
|
||||
comparator = defaultTimeFilter;
|
||||
}
|
||||
|
||||
props.onChange(
|
||||
props.adhocFilter.duplicateWith({
|
||||
subject,
|
||||
clause,
|
||||
operator:
|
||||
operator && operatorId && isOperatorRelevant(operatorId, subject)
|
||||
? OPERATOR_ENUM_TO_OPERATOR_TYPE[operatorId].operation
|
||||
: null,
|
||||
operator,
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
operatorId,
|
||||
comparator,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
@ -221,12 +251,23 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
|||
}),
|
||||
);
|
||||
};
|
||||
const onDatePickerChange = (columnName: string, timeRange: string) => {
|
||||
props.onChange(
|
||||
props.adhocFilter.duplicateWith({
|
||||
subject: columnName,
|
||||
operator: Operators.TEMPORAL_RANGE,
|
||||
comparator: timeRange,
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
}),
|
||||
);
|
||||
};
|
||||
return {
|
||||
onSubjectChange,
|
||||
onOperatorChange,
|
||||
onComparatorChange,
|
||||
isOperatorRelevant,
|
||||
clearOperator,
|
||||
onDatePickerChange,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -236,6 +277,7 @@ const AdhocFilterEditPopoverSimpleTabContent: React.FC<Props> = props => {
|
|||
onOperatorChange,
|
||||
isOperatorRelevant,
|
||||
onComparatorChange,
|
||||
onDatePickerChange,
|
||||
} = useSimpleTabFilterProps(props);
|
||||
const [suggestions, setSuggestions] = useState<
|
||||
Record<'label' | 'value', any>[]
|
||||
|
|
@ -343,6 +385,16 @@ const AdhocFilterEditPopoverSimpleTabContent: React.FC<Props> = props => {
|
|||
const labelText =
|
||||
comparator && comparator.length > 0 && createSuggestionsPlaceholder();
|
||||
|
||||
const datePicker = useDatePickerInAdhocFilter({
|
||||
columnName: props.adhocFilter.subject,
|
||||
timeRange:
|
||||
props.adhocFilter.operator === Operators.TEMPORAL_RANGE
|
||||
? props.adhocFilter.comparator
|
||||
: undefined,
|
||||
datasource: props.datasource,
|
||||
onChange: onDatePickerChange,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const refreshComparatorSuggestions = () => {
|
||||
const { datasource } = props;
|
||||
|
|
@ -375,7 +427,9 @@ const AdhocFilterEditPopoverSimpleTabContent: React.FC<Props> = props => {
|
|||
});
|
||||
}
|
||||
};
|
||||
refreshComparatorSuggestions();
|
||||
if (!datePicker) {
|
||||
refreshComparatorSuggestions();
|
||||
}
|
||||
}, [props.adhocFilter.subject]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -481,7 +535,7 @@ const AdhocFilterEditPopoverSimpleTabContent: React.FC<Props> = props => {
|
|||
return (
|
||||
<>
|
||||
{subjectComponent}
|
||||
{operatorsAndOperandComponent}
|
||||
{datePicker ?? operatorsAndOperandComponent}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import AdhocFilterPopoverTrigger from 'src/explore/components/controls/FilterCon
|
|||
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
|
||||
import { OptionSortType } from 'src/explore/types';
|
||||
import { Operators } from 'src/explore/constants';
|
||||
import { useGetTimeRangeLabel } from '../utils';
|
||||
|
||||
export interface AdhocFilterOptionProps {
|
||||
adhocFilter: AdhocFilter;
|
||||
|
|
@ -51,6 +52,8 @@ export default function AdhocFilterOption({
|
|||
sections,
|
||||
operators,
|
||||
}: AdhocFilterOptionProps) {
|
||||
const { actualTimeRange, title } = useGetTimeRangeLabel(adhocFilter);
|
||||
|
||||
return (
|
||||
<AdhocFilterPopoverTrigger
|
||||
sections={sections}
|
||||
|
|
@ -62,8 +65,8 @@ export default function AdhocFilterOption({
|
|||
partitionColumn={partitionColumn}
|
||||
>
|
||||
<OptionControlLabel
|
||||
label={adhocFilter.getDefaultLabel()}
|
||||
tooltipTitle={adhocFilter.getTooltipTitle()}
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
|
||||
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
|
||||
onRemove={onRemoveFilter}
|
||||
onMoveLabel={onMoveLabel}
|
||||
onDropLabel={onDropLabel}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
export { useGetTimeRangeLabel } from './useGetTimeRangeLabel';
|
||||
export { useDatePickerInAdhocFilter } from './useDatePickerInAdhocFilter';
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* 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 React from 'react';
|
||||
|
||||
import { hasGenericChartAxes, t } from '@superset-ui/core';
|
||||
import { Dataset, isTemporalColumn } from '@superset-ui/chart-controls';
|
||||
import DateFilterControl from 'src/explore/components/controls/DateFilterControl/DateFilterLabel';
|
||||
import ControlHeader from 'src/explore/components/ControlHeader';
|
||||
|
||||
interface DatePickerInFilterProps {
|
||||
columnName: string;
|
||||
timeRange?: string;
|
||||
datasource: Dataset;
|
||||
onChange: (columnName: string, timeRange: string) => void;
|
||||
}
|
||||
|
||||
export const useDatePickerInAdhocFilter = ({
|
||||
columnName,
|
||||
timeRange,
|
||||
datasource,
|
||||
onChange,
|
||||
}: DatePickerInFilterProps): React.ReactElement | undefined => {
|
||||
const onTimeRangeChange = (val: string) => onChange(columnName, val);
|
||||
|
||||
return hasGenericChartAxes && isTemporalColumn(columnName, datasource) ? (
|
||||
<>
|
||||
<ControlHeader label={t('Time Range')} />
|
||||
<DateFilterControl
|
||||
value={timeRange}
|
||||
name="time_range"
|
||||
onChange={onTimeRangeChange}
|
||||
overlayStyle="Modal"
|
||||
/>
|
||||
</>
|
||||
) : undefined;
|
||||
};
|
||||
|
|
@ -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 { renderHook } from '@testing-library/react-hooks';
|
||||
import { TestDataset } from '@superset-ui/chart-controls';
|
||||
import * as supersetCoreModule from '@superset-ui/core';
|
||||
import { useDatePickerInAdhocFilter } from './useDatePickerInAdhocFilter';
|
||||
|
||||
test('should return undefined if Generic Axis is disabled', () => {
|
||||
Object.defineProperty(supersetCoreModule, 'hasGenericChartAxes', {
|
||||
value: false,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useDatePickerInAdhocFilter({
|
||||
columnName: 'ds',
|
||||
datasource: TestDataset,
|
||||
onChange: jest.fn(),
|
||||
}),
|
||||
);
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return undefined if column is not temporal', () => {
|
||||
Object.defineProperty(supersetCoreModule, 'hasGenericChartAxes', {
|
||||
value: true,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useDatePickerInAdhocFilter({
|
||||
columnName: 'gender',
|
||||
datasource: TestDataset,
|
||||
onChange: jest.fn(),
|
||||
}),
|
||||
);
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return JSX', () => {
|
||||
Object.defineProperty(supersetCoreModule, 'hasGenericChartAxes', {
|
||||
value: true,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useDatePickerInAdhocFilter({
|
||||
columnName: 'ds',
|
||||
datasource: TestDataset,
|
||||
onChange: jest.fn(),
|
||||
}),
|
||||
);
|
||||
expect(result.current).not.toBeUndefined();
|
||||
});
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* 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 { renderHook } from '@testing-library/react-hooks';
|
||||
import { NO_TIME_RANGE } from '@superset-ui/core';
|
||||
import { Operators } from 'src/explore/constants';
|
||||
import * as FetchTimeRangeModule from 'src/explore/components/controls/DateFilterControl';
|
||||
import { useGetTimeRangeLabel } from './useGetTimeRangeLabel';
|
||||
import AdhocFilter, { CLAUSES, EXPRESSION_TYPES } from '../AdhocFilter';
|
||||
|
||||
test('should return empty object if operator is not TEMPORAL_RANGE', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
subject: 'value',
|
||||
operator: '>',
|
||||
comparator: '10',
|
||||
clause: CLAUSES.WHERE,
|
||||
});
|
||||
const { result } = renderHook(() => useGetTimeRangeLabel(adhocFilter));
|
||||
expect(result.current).toEqual({});
|
||||
});
|
||||
|
||||
test('should return empty object if expressionType is SQL', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: EXPRESSION_TYPES.SQL,
|
||||
subject: 'temporal column',
|
||||
operator: Operators.TEMPORAL_RANGE,
|
||||
comparator: 'Last week',
|
||||
clause: CLAUSES.WHERE,
|
||||
});
|
||||
const { result } = renderHook(() => useGetTimeRangeLabel(adhocFilter));
|
||||
expect(result.current).toEqual({});
|
||||
});
|
||||
|
||||
test('should get "No filter" label', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
subject: 'temporal column',
|
||||
operator: Operators.TEMPORAL_RANGE,
|
||||
comparator: NO_TIME_RANGE,
|
||||
clause: CLAUSES.WHERE,
|
||||
});
|
||||
const { result } = renderHook(() => useGetTimeRangeLabel(adhocFilter));
|
||||
expect(result.current).toEqual({
|
||||
actualTimeRange: 'temporal column (No filter)',
|
||||
title: 'No filter',
|
||||
});
|
||||
});
|
||||
|
||||
test('should get actualTimeRange and title', async () => {
|
||||
jest
|
||||
.spyOn(FetchTimeRangeModule, 'fetchTimeRange')
|
||||
.mockResolvedValue({ value: 'MOCK TIME' });
|
||||
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
subject: 'temporal column',
|
||||
operator: Operators.TEMPORAL_RANGE,
|
||||
comparator: 'Last week',
|
||||
clause: CLAUSES.WHERE,
|
||||
});
|
||||
|
||||
const { result } = await renderHook(() => useGetTimeRangeLabel(adhocFilter));
|
||||
expect(result.current).toEqual({
|
||||
actualTimeRange: 'MOCK TIME',
|
||||
title: 'Last week',
|
||||
});
|
||||
});
|
||||
|
||||
test('should get actualTimeRange and title when gets an error', async () => {
|
||||
jest
|
||||
.spyOn(FetchTimeRangeModule, 'fetchTimeRange')
|
||||
.mockResolvedValue({ error: 'MOCK ERROR' });
|
||||
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
subject: 'temporal column',
|
||||
operator: Operators.TEMPORAL_RANGE,
|
||||
comparator: 'Last week',
|
||||
clause: CLAUSES.WHERE,
|
||||
});
|
||||
|
||||
const { result } = await renderHook(() => useGetTimeRangeLabel(adhocFilter));
|
||||
expect(result.current).toEqual({
|
||||
actualTimeRange: 'temporal column (Last week)',
|
||||
title: 'MOCK ERROR',
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* 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 { useEffect, useState } from 'react';
|
||||
import { NO_TIME_RANGE } from '@superset-ui/core';
|
||||
import { fetchTimeRange } from 'src/explore/components/controls/DateFilterControl';
|
||||
import { Operators } from 'src/explore/constants';
|
||||
import AdhocFilter, { EXPRESSION_TYPES } from '../AdhocFilter';
|
||||
|
||||
interface Results {
|
||||
actualTimeRange?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const useGetTimeRangeLabel = (adhocFilter: AdhocFilter): Results => {
|
||||
const [actualTimeRange, setActualTimeRange] = useState<Results>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
adhocFilter.operator !== Operators.TEMPORAL_RANGE ||
|
||||
adhocFilter.expressionType !== EXPRESSION_TYPES.SIMPLE
|
||||
) {
|
||||
setActualTimeRange({});
|
||||
}
|
||||
if (
|
||||
adhocFilter.operator === Operators.TEMPORAL_RANGE &&
|
||||
adhocFilter.comparator === NO_TIME_RANGE
|
||||
) {
|
||||
setActualTimeRange({
|
||||
actualTimeRange: `${adhocFilter.subject} (${NO_TIME_RANGE})`,
|
||||
title: NO_TIME_RANGE,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
adhocFilter.operator === Operators.TEMPORAL_RANGE &&
|
||||
adhocFilter.expressionType === EXPRESSION_TYPES.SIMPLE &&
|
||||
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,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [adhocFilter]);
|
||||
|
||||
return actualTimeRange;
|
||||
};
|
||||
|
|
@ -45,6 +45,7 @@ export enum Operators {
|
|||
LATEST_PARTITION = 'LATEST_PARTITION',
|
||||
IS_TRUE = 'IS_TRUE',
|
||||
IS_FALSE = 'IS_FALSE',
|
||||
TEMPORAL_RANGE = 'TEMPORAL_RANGE',
|
||||
}
|
||||
|
||||
export interface OperatorType {
|
||||
|
|
@ -80,6 +81,10 @@ export const OPERATOR_ENUM_TO_OPERATOR_TYPE: {
|
|||
},
|
||||
[Operators.IS_TRUE]: { display: 'Is true', operation: '==' },
|
||||
[Operators.IS_FALSE]: { display: 'Is false', operation: '==' },
|
||||
[Operators.TEMPORAL_RANGE]: {
|
||||
display: 'TEMPORAL_RANGE',
|
||||
operation: 'TEMPORAL_RANGE',
|
||||
},
|
||||
};
|
||||
|
||||
export const OPERATORS_OPTIONS = Object.values(Operators) as Operators[];
|
||||
|
|
@ -96,7 +101,10 @@ export const HAVING_OPERATORS = [
|
|||
export const MULTI_OPERATORS = new Set([Operators.IN, Operators.NOT_IN]);
|
||||
// CUSTOM_OPERATORS will show operator in simple mode,
|
||||
// but will generate customized sqlExpression
|
||||
export const CUSTOM_OPERATORS = new Set([Operators.LATEST_PARTITION]);
|
||||
export const CUSTOM_OPERATORS = new Set([
|
||||
Operators.LATEST_PARTITION,
|
||||
Operators.TEMPORAL_RANGE,
|
||||
]);
|
||||
// DISABLE_INPUT_OPERATORS will disable filter value input
|
||||
// in adhocFilter control
|
||||
export const DISABLE_INPUT_OPERATORS = [
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ describe('controlUtils', () => {
|
|||
},
|
||||
controls: {},
|
||||
form_data: { datasource: '1__table', viz_type: 'table' },
|
||||
common: {},
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ export interface ExplorePageState {
|
|||
controlsTransferred: string[];
|
||||
standalone: boolean;
|
||||
force: boolean;
|
||||
common: JsonObject;
|
||||
};
|
||||
sliceEntities?: JsonObject; // propagated from Dashboard view
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from superset.common.db_query_status import QueryStatus
|
|||
from superset.common.query_actions import get_query_results
|
||||
from superset.common.utils import dataframe_utils
|
||||
from superset.common.utils.query_cache_manager import QueryCacheManager
|
||||
from superset.common.utils.time_range_utils import get_since_until_from_query_object
|
||||
from superset.connectors.base.models import BaseDatasource
|
||||
from superset.constants import CacheRegion
|
||||
from superset.exceptions import (
|
||||
|
|
@ -56,6 +57,7 @@ from superset.utils.core import (
|
|||
get_column_names_from_columns,
|
||||
get_column_names_from_metrics,
|
||||
get_metric_names,
|
||||
get_xaxis_label,
|
||||
normalize_dttm_col,
|
||||
TIME_COMPARISON,
|
||||
)
|
||||
|
|
@ -314,15 +316,36 @@ class QueryContextProcessor:
|
|||
rv_dfs: List[pd.DataFrame] = [df]
|
||||
|
||||
time_offsets = query_object.time_offsets
|
||||
outer_from_dttm = query_object.from_dttm
|
||||
outer_to_dttm = query_object.to_dttm
|
||||
outer_from_dttm, outer_to_dttm = get_since_until_from_query_object(query_object)
|
||||
if not outer_from_dttm or not outer_to_dttm:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"An enclosed time range (both start and end) must be specified "
|
||||
"when using a Time Comparison."
|
||||
)
|
||||
)
|
||||
for offset in time_offsets:
|
||||
try:
|
||||
# pylint: disable=line-too-long
|
||||
# Since the xaxis is also a column name for the time filter, xaxis_label will be set as granularity
|
||||
# these query object are equivalent:
|
||||
# 1) { granularity: 'dttm_col', time_range: '2020 : 2021', time_offsets: ['1 year ago']}
|
||||
# 2) { columns: [
|
||||
# {label: 'dttm_col', sqlExpression: 'dttm_col', "columnType": "BASE_AXIS" }
|
||||
# ],
|
||||
# time_offsets: ['1 year ago'],
|
||||
# filters: [{col: 'dttm_col', op: 'TEMPORAL_RANGE', val: '2020 : 2021'}],
|
||||
# }
|
||||
query_object_clone.from_dttm = get_past_or_future(
|
||||
offset,
|
||||
outer_from_dttm,
|
||||
)
|
||||
query_object_clone.to_dttm = get_past_or_future(offset, outer_to_dttm)
|
||||
|
||||
xaxis_label = get_xaxis_label(query_object.columns)
|
||||
query_object_clone.granularity = (
|
||||
query_object_clone.granularity or xaxis_label
|
||||
)
|
||||
except ValueError as ex:
|
||||
raise QueryObjectValidationError(str(ex)) from ex
|
||||
# make sure subquery use main query where clause
|
||||
|
|
@ -330,14 +353,12 @@ class QueryContextProcessor:
|
|||
query_object_clone.inner_to_dttm = outer_to_dttm
|
||||
query_object_clone.time_offsets = []
|
||||
query_object_clone.post_processing = []
|
||||
query_object_clone.filter = [
|
||||
flt
|
||||
for flt in query_object_clone.filter
|
||||
if flt.get("col") != xaxis_label
|
||||
]
|
||||
|
||||
if not query_object.from_dttm or not query_object.to_dttm:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"An enclosed time range (both start and end) must be specified "
|
||||
"when using a Time Comparison."
|
||||
)
|
||||
)
|
||||
# `offset` is added to the hash function
|
||||
cache_key = self.query_cache_key(query_object_clone, time_offset=offset)
|
||||
cache = QueryCacheManager.get(
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from pprint import pformat
|
||||
from typing import Any, Dict, List, NamedTuple, Optional, TYPE_CHECKING
|
||||
|
||||
|
|
@ -46,7 +46,6 @@ from superset.utils.core import (
|
|||
json_int_dttm_ser,
|
||||
QueryObjectFilterClause,
|
||||
)
|
||||
from superset.utils.date_parser import parse_human_timedelta
|
||||
from superset.utils.hashing import md5_sha_from_dict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -106,7 +105,7 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
|
|||
series_limit: int
|
||||
series_limit_metric: Optional[Metric]
|
||||
time_offsets: List[str]
|
||||
time_shift: Optional[timedelta]
|
||||
time_shift: Optional[str]
|
||||
time_range: Optional[str]
|
||||
to_dttm: Optional[datetime]
|
||||
|
||||
|
|
@ -156,7 +155,7 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
|
|||
self.series_limit = series_limit
|
||||
self.series_limit_metric = series_limit_metric
|
||||
self.time_range = time_range
|
||||
self.time_shift = parse_human_timedelta(time_shift)
|
||||
self.time_shift = time_shift
|
||||
self.from_dttm = kwargs.get("from_dttm")
|
||||
self.to_dttm = kwargs.get("to_dttm")
|
||||
self.result_type = kwargs.get("result_type")
|
||||
|
|
@ -336,6 +335,7 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
|
|||
"series_limit": self.series_limit,
|
||||
"series_limit_metric": self.series_limit_metric,
|
||||
"to_dttm": self.to_dttm,
|
||||
"time_shift": self.time_shift,
|
||||
}
|
||||
return query_object_dict
|
||||
|
||||
|
|
|
|||
|
|
@ -16,13 +16,12 @@
|
|||
# under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
|
||||
from superset.common.chart_data import ChartDataResultType
|
||||
from superset.common.query_object import QueryObject
|
||||
from superset.common.utils.time_range_utils import get_since_until_from_time_range
|
||||
from superset.utils.core import apply_max_row_limit, DatasourceDict, DatasourceType
|
||||
from superset.utils.date_parser import get_since_until
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
|
@ -62,7 +61,9 @@ class QueryObjectFactory: # pylint: disable=too-few-public-methods
|
|||
processed_extras = self._process_extras(extras)
|
||||
result_type = kwargs.setdefault("result_type", parent_result_type)
|
||||
row_limit = self._process_row_limit(row_limit, result_type)
|
||||
from_dttm, to_dttm = self._get_dttms(time_range, time_shift, processed_extras)
|
||||
from_dttm, to_dttm = get_since_until_from_time_range(
|
||||
time_range, time_shift, processed_extras
|
||||
)
|
||||
kwargs["from_dttm"] = from_dttm
|
||||
kwargs["to_dttm"] = to_dttm
|
||||
return QueryObject(
|
||||
|
|
@ -98,23 +99,6 @@ class QueryObjectFactory: # pylint: disable=too-few-public-methods
|
|||
)
|
||||
return apply_max_row_limit(row_limit or default_row_limit)
|
||||
|
||||
def _get_dttms(
|
||||
self,
|
||||
time_range: Optional[str],
|
||||
time_shift: Optional[str],
|
||||
extras: Dict[str, Any],
|
||||
) -> Tuple[Optional[datetime], Optional[datetime]]:
|
||||
return get_since_until(
|
||||
relative_start=extras.get(
|
||||
"relative_start", self._config["DEFAULT_RELATIVE_START_TIME"]
|
||||
),
|
||||
relative_end=extras.get(
|
||||
"relative_end", self._config["DEFAULT_RELATIVE_END_TIME"]
|
||||
),
|
||||
time_range=time_range,
|
||||
time_shift=time_shift,
|
||||
)
|
||||
|
||||
# light version of the view.utils.core
|
||||
# import view.utils require application context
|
||||
# Todo: move it and the view.utils.core to utils package
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
# 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.
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, cast, Dict, Optional, Tuple
|
||||
|
||||
from superset import app
|
||||
from superset.common.query_object import QueryObject
|
||||
from superset.utils.core import FilterOperator, get_xaxis_label
|
||||
from superset.utils.date_parser import get_since_until
|
||||
|
||||
|
||||
def get_since_until_from_time_range(
|
||||
time_range: Optional[str] = None,
|
||||
time_shift: Optional[str] = None,
|
||||
extras: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[Optional[datetime], Optional[datetime]]:
|
||||
return get_since_until(
|
||||
relative_start=(extras or {}).get(
|
||||
"relative_start", app.config["DEFAULT_RELATIVE_START_TIME"]
|
||||
),
|
||||
relative_end=(extras or {}).get(
|
||||
"relative_end", app.config["DEFAULT_RELATIVE_END_TIME"]
|
||||
),
|
||||
time_range=time_range,
|
||||
time_shift=time_shift,
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def get_since_until_from_query_object(
|
||||
query_object: QueryObject,
|
||||
) -> Tuple[Optional[datetime], Optional[datetime]]:
|
||||
"""
|
||||
this function will return since and until by tuple if
|
||||
1) the time_range is in the query object.
|
||||
2) the xaxis column is in the columns field
|
||||
and its corresponding `temporal_range` filter is in the adhoc filters.
|
||||
:param query_object: a valid query object
|
||||
:return: since and until by tuple
|
||||
"""
|
||||
if query_object.time_range:
|
||||
return get_since_until_from_time_range(
|
||||
time_range=query_object.time_range,
|
||||
time_shift=query_object.time_shift,
|
||||
extras=query_object.extras,
|
||||
)
|
||||
|
||||
time_range = None
|
||||
for flt in query_object.filter:
|
||||
if (
|
||||
flt.get("op") == FilterOperator.TEMPORAL_RANGE.value
|
||||
and flt.get("col") == get_xaxis_label(query_object.columns)
|
||||
and isinstance(flt.get("val"), str)
|
||||
):
|
||||
time_range = cast(str, flt.get("val"))
|
||||
|
||||
return get_since_until_from_time_range(
|
||||
time_range=time_range,
|
||||
time_shift=query_object.time_shift,
|
||||
extras=query_object.extras,
|
||||
)
|
||||
|
|
@ -80,6 +80,7 @@ from superset import app, db, is_feature_enabled, security_manager
|
|||
from superset.advanced_data_type.types import AdvancedDataTypeResponse
|
||||
from superset.columns.models import Column as NewColumn, UNKOWN_TYPE
|
||||
from superset.common.db_query_status import QueryStatus
|
||||
from superset.common.utils.time_range_utils import get_since_until_from_time_range
|
||||
from superset.connectors.base.models import BaseColumn, BaseDatasource, BaseMetric
|
||||
from superset.connectors.sqla.utils import (
|
||||
find_cached_objects_in_session,
|
||||
|
|
@ -334,10 +335,11 @@ class TableColumn(Model, BaseColumn, CertificationMixin):
|
|||
|
||||
def get_time_filter(
|
||||
self,
|
||||
start_dttm: DateTime,
|
||||
end_dttm: DateTime,
|
||||
start_dttm: Optional[DateTime] = None,
|
||||
end_dttm: Optional[DateTime] = None,
|
||||
label: Optional[str] = "__time",
|
||||
) -> ColumnElement:
|
||||
col = self.get_sqla_col(label="__time")
|
||||
col = self.get_sqla_col(label=label)
|
||||
l = []
|
||||
if start_dttm:
|
||||
l.append(col >= self.table.text(self.dttm_sql_literal(start_dttm)))
|
||||
|
|
@ -1267,6 +1269,7 @@ class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-metho
|
|||
row_offset: Optional[int] = None,
|
||||
timeseries_limit: Optional[int] = None,
|
||||
timeseries_limit_metric: Optional[Metric] = None,
|
||||
time_shift: Optional[str] = None,
|
||||
) -> SqlaQuery:
|
||||
"""Querying any sqla table from this common interface"""
|
||||
if granularity not in self.dttm_cols and granularity is not None:
|
||||
|
|
@ -1656,6 +1659,23 @@ class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-metho
|
|||
where_clause_and.append(sqla_col.like(eq))
|
||||
elif op == utils.FilterOperator.ILIKE.value:
|
||||
where_clause_and.append(sqla_col.ilike(eq))
|
||||
elif (
|
||||
op == utils.FilterOperator.TEMPORAL_RANGE.value
|
||||
and isinstance(eq, str)
|
||||
and col_obj is not None
|
||||
):
|
||||
_since, _until = get_since_until_from_time_range(
|
||||
time_range=eq,
|
||||
time_shift=time_shift,
|
||||
extras=extras,
|
||||
)
|
||||
where_clause_and.append(
|
||||
col_obj.get_time_filter(
|
||||
start_dttm=_since,
|
||||
end_dttm=_until,
|
||||
label=sqla_col.key,
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise QueryObjectValidationError(
|
||||
_("Invalid filter operation type: %(op)s", op=op)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ EXAMPLES_DB_UUID = "a2dc77af-e654-49bb-b321-40f6b559a1ee"
|
|||
|
||||
PASSWORD_MASK = "X" * 10
|
||||
|
||||
NO_TIME_RANGE = "No filter"
|
||||
|
||||
|
||||
class RouteMethod: # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -77,7 +77,11 @@ class SqliteEngineSpec(BaseEngineSpec):
|
|||
cls, target_type: str, dttm: datetime, db_extra: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[str]:
|
||||
tt = target_type.upper()
|
||||
if tt in (utils.TemporalType.TEXT, utils.TemporalType.DATETIME):
|
||||
if tt in (
|
||||
utils.TemporalType.TEXT,
|
||||
utils.TemporalType.DATETIME,
|
||||
utils.TemporalType.TIMESTAMP,
|
||||
):
|
||||
return f"""'{dttm.isoformat(sep=" ", timespec="seconds")}'"""
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -1322,7 +1322,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
|||
col = sa.column(label, type_=col_type)
|
||||
return self.make_sqla_column_compatible(col, label)
|
||||
|
||||
def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-many-branches,too-many-statements
|
||||
def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-many-branches,too-many-statements,unused-argument
|
||||
self,
|
||||
apply_fetch_values_predicate: bool = False,
|
||||
columns: Optional[List[Column]] = None,
|
||||
|
|
@ -1348,6 +1348,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
|||
row_offset: Optional[int] = None,
|
||||
timeseries_limit: Optional[int] = None,
|
||||
timeseries_limit_metric: Optional[Metric] = None,
|
||||
time_shift: Optional[str] = None,
|
||||
) -> SqlaQuery:
|
||||
"""Querying any sqla table from this common interface"""
|
||||
if granularity not in self.dttm_cols and granularity is not None:
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ from superset.constants import (
|
|||
EXTRA_FORM_DATA_APPEND_KEYS,
|
||||
EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS,
|
||||
EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS,
|
||||
NO_TIME_RANGE,
|
||||
)
|
||||
from superset.errors import ErrorLevel, SupersetErrorType
|
||||
from superset.exceptions import (
|
||||
|
|
@ -115,6 +116,7 @@ from superset.superset_typing import (
|
|||
Metric,
|
||||
)
|
||||
from superset.utils.database import get_example_database
|
||||
from superset.utils.date_parser import parse_human_timedelta
|
||||
from superset.utils.dates import datetime_to_epoch, EPOCH
|
||||
from superset.utils.hashing import md5_sha_from_dict, md5_sha_from_str
|
||||
|
||||
|
|
@ -131,8 +133,6 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
DTTM_ALIAS = "__timestamp"
|
||||
|
||||
NO_TIME_RANGE = "No filter"
|
||||
|
||||
TIME_COMPARISON = "__"
|
||||
|
||||
JS_MAX_INTEGER = 9007199254740991 # Largest int Java Script can handle 2^53-1
|
||||
|
|
@ -256,6 +256,7 @@ class FilterOperator(str, Enum):
|
|||
REGEX = "REGEX"
|
||||
IS_TRUE = "IS TRUE"
|
||||
IS_FALSE = "IS FALSE"
|
||||
TEMPORAL_RANGE = "TEMPORAL_RANGE"
|
||||
|
||||
|
||||
class FilterStringOperators(str, Enum):
|
||||
|
|
@ -1284,6 +1285,11 @@ def get_base_axis_labels(columns: Optional[List[Column]]) -> Tuple[str, ...]:
|
|||
return tuple(get_column_name(col) for col in axis_cols)
|
||||
|
||||
|
||||
def get_xaxis_label(columns: Optional[List[Column]]) -> Optional[str]:
|
||||
labels = get_base_axis_labels(columns)
|
||||
return labels[0] if labels else None
|
||||
|
||||
|
||||
def get_column_name(
|
||||
column: Column, verbose_map: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
|
|
@ -1855,7 +1861,7 @@ class DateColumn:
|
|||
col_label: str
|
||||
timestamp_format: Optional[str] = None
|
||||
offset: Optional[int] = None
|
||||
time_shift: Optional[timedelta] = None
|
||||
time_shift: Optional[str] = None
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.col_label)
|
||||
|
|
@ -1868,7 +1874,7 @@ class DateColumn:
|
|||
cls,
|
||||
timestamp_format: Optional[str],
|
||||
offset: Optional[int],
|
||||
time_shift: Optional[timedelta],
|
||||
time_shift: Optional[str],
|
||||
) -> DateColumn:
|
||||
return cls(
|
||||
timestamp_format=timestamp_format,
|
||||
|
|
@ -1907,7 +1913,7 @@ def normalize_dttm_col(
|
|||
if _col.offset:
|
||||
df[_col.col_label] += timedelta(hours=_col.offset)
|
||||
if _col.time_shift is not None:
|
||||
df[_col.col_label] += _col.time_shift
|
||||
df[_col.col_label] += parse_human_timedelta(_col.time_shift)
|
||||
|
||||
|
||||
def parse_boolean_string(bool_str: Optional[str]) -> bool:
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ from superset.charts.commands.exceptions import (
|
|||
TimeRangeAmbiguousError,
|
||||
TimeRangeParseFailError,
|
||||
)
|
||||
from superset.utils.core import NO_TIME_RANGE
|
||||
from superset.constants import NO_TIME_RANGE
|
||||
from superset.utils.memoized import memoized
|
||||
|
||||
ParserElement.enablePackrat()
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ class BaseViz: # pylint: disable=too-many-public-methods
|
|||
DateColumn.get_legacy_time_column(
|
||||
timestamp_format=timestamp_format,
|
||||
offset=self.datasource.offset,
|
||||
time_shift=self.time_shift,
|
||||
time_shift=self.form_data.get("time_shift"),
|
||||
)
|
||||
]
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1018,3 +1018,85 @@ def test_time_grain_and_time_offset_on_legacy_query(app_context, physical_datase
|
|||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_time_offset_with_temporal_range_filter(app_context, physical_dataset):
|
||||
qc = QueryContextFactory().create(
|
||||
datasource={
|
||||
"type": physical_dataset.type,
|
||||
"id": physical_dataset.id,
|
||||
},
|
||||
queries=[
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"label": "col6",
|
||||
"sqlExpression": "col6",
|
||||
"columnType": "BASE_AXIS",
|
||||
"timeGrain": "P3M",
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"label": "SUM(col1)",
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "SUM(col1)",
|
||||
}
|
||||
],
|
||||
"time_offsets": ["3 month ago"],
|
||||
"filters": [
|
||||
{
|
||||
"col": "col6",
|
||||
"op": "TEMPORAL_RANGE",
|
||||
"val": "2002-01 : 2003-01",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
result_type=ChartDataResultType.FULL,
|
||||
force=True,
|
||||
)
|
||||
query_payload = qc.get_df_payload(qc.queries[0])
|
||||
df = query_payload["df"]
|
||||
"""
|
||||
col6 SUM(col1) SUM(col1)__3 month ago
|
||||
0 2002-01-01 3 NaN
|
||||
1 2002-04-01 12 3.0
|
||||
2 2002-07-01 21 12.0
|
||||
3 2002-10-01 9 21.0
|
||||
"""
|
||||
assert df["SUM(col1)"].to_list() == [3, 12, 21, 9]
|
||||
# df["SUM(col1)__3 month ago"].dtype is object so have to convert to float first
|
||||
assert df["SUM(col1)__3 month ago"].astype("float").astype("Int64").to_list() == [
|
||||
pd.NA,
|
||||
3,
|
||||
12,
|
||||
21,
|
||||
]
|
||||
|
||||
sqls = query_payload["query"].split(";")
|
||||
"""
|
||||
SELECT DATE_TRUNC('quarter', col6) AS col6,
|
||||
SUM(col1) AS "SUM(col1)"
|
||||
FROM physical_dataset
|
||||
WHERE col6 >= TO_TIMESTAMP('2002-01-01 00:00:00.000000', 'YYYY-MM-DD HH24:MI:SS.US')
|
||||
AND col6 < TO_TIMESTAMP('2003-01-01 00:00:00.000000', 'YYYY-MM-DD HH24:MI:SS.US')
|
||||
GROUP BY DATE_TRUNC('quarter', col6)
|
||||
LIMIT 10000;
|
||||
|
||||
SELECT DATE_TRUNC('quarter', col6) AS col6,
|
||||
SUM(col1) AS "SUM(col1)"
|
||||
FROM physical_dataset
|
||||
WHERE col6 >= TO_TIMESTAMP('2001-10-01 00:00:00.000000', 'YYYY-MM-DD HH24:MI:SS.US')
|
||||
AND col6 < TO_TIMESTAMP('2002-10-01 00:00:00.000000', 'YYYY-MM-DD HH24:MI:SS.US')
|
||||
GROUP BY DATE_TRUNC('quarter', col6)
|
||||
LIMIT 10000;
|
||||
"""
|
||||
assert (
|
||||
re.search(r"WHERE col6 >= .*2002-01-01", sqls[0])
|
||||
and re.search(r"AND col6 < .*2003-01-01", sqls[0])
|
||||
) is not None
|
||||
assert (
|
||||
re.search(r"WHERE col6 >= .*2001-10-01", sqls[1])
|
||||
and re.search(r"AND col6 < .*2002-10-01", sqls[1])
|
||||
) is not None
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import pytest
|
|||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import sqlalchemy as sa
|
||||
from flask import Flask
|
||||
from pytest_mock import MockFixture
|
||||
from sqlalchemy.sql import text
|
||||
|
|
@ -41,7 +40,6 @@ from superset.utils.core import (
|
|||
FilterOperator,
|
||||
GenericDataType,
|
||||
TemporalType,
|
||||
backend,
|
||||
)
|
||||
from superset.utils.database import get_example_database
|
||||
from tests.integration_tests.fixtures.birth_names_dashboard import (
|
||||
|
|
@ -71,6 +69,7 @@ VIRTUAL_TABLE_STRING_TYPES: Dict[str, Pattern[str]] = {
|
|||
|
||||
|
||||
class FilterTestCase(NamedTuple):
|
||||
column: str
|
||||
operator: str
|
||||
value: Union[float, int, List[Any], str]
|
||||
expected: Union[str, List[str]]
|
||||
|
|
@ -271,19 +270,22 @@ class TestDatabaseModel(SupersetTestCase):
|
|||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_where_operators(self):
|
||||
filters: Tuple[FilterTestCase, ...] = (
|
||||
FilterTestCase(FilterOperator.IS_NULL, "", "IS NULL"),
|
||||
FilterTestCase(FilterOperator.IS_NOT_NULL, "", "IS NOT NULL"),
|
||||
FilterTestCase("num", FilterOperator.IS_NULL, "", "IS NULL"),
|
||||
FilterTestCase("num", FilterOperator.IS_NOT_NULL, "", "IS NOT NULL"),
|
||||
# Some db backends translate true/false to 1/0
|
||||
FilterTestCase(FilterOperator.IS_TRUE, "", ["IS 1", "IS true"]),
|
||||
FilterTestCase(FilterOperator.IS_FALSE, "", ["IS 0", "IS false"]),
|
||||
FilterTestCase(FilterOperator.GREATER_THAN, 0, "> 0"),
|
||||
FilterTestCase(FilterOperator.GREATER_THAN_OR_EQUALS, 0, ">= 0"),
|
||||
FilterTestCase(FilterOperator.LESS_THAN, 0, "< 0"),
|
||||
FilterTestCase(FilterOperator.LESS_THAN_OR_EQUALS, 0, "<= 0"),
|
||||
FilterTestCase(FilterOperator.EQUALS, 0, "= 0"),
|
||||
FilterTestCase(FilterOperator.NOT_EQUALS, 0, "!= 0"),
|
||||
FilterTestCase(FilterOperator.IN, ["1", "2"], "IN (1, 2)"),
|
||||
FilterTestCase(FilterOperator.NOT_IN, ["1", "2"], "NOT IN (1, 2)"),
|
||||
FilterTestCase("num", FilterOperator.IS_TRUE, "", ["IS 1", "IS true"]),
|
||||
FilterTestCase("num", FilterOperator.IS_FALSE, "", ["IS 0", "IS false"]),
|
||||
FilterTestCase("num", FilterOperator.GREATER_THAN, 0, "> 0"),
|
||||
FilterTestCase("num", FilterOperator.GREATER_THAN_OR_EQUALS, 0, ">= 0"),
|
||||
FilterTestCase("num", FilterOperator.LESS_THAN, 0, "< 0"),
|
||||
FilterTestCase("num", FilterOperator.LESS_THAN_OR_EQUALS, 0, "<= 0"),
|
||||
FilterTestCase("num", FilterOperator.EQUALS, 0, "= 0"),
|
||||
FilterTestCase("num", FilterOperator.NOT_EQUALS, 0, "!= 0"),
|
||||
FilterTestCase("num", FilterOperator.IN, ["1", "2"], "IN (1, 2)"),
|
||||
FilterTestCase("num", FilterOperator.NOT_IN, ["1", "2"], "NOT IN (1, 2)"),
|
||||
FilterTestCase(
|
||||
"ds", FilterOperator.TEMPORAL_RANGE, "2020 : 2021", "2020-01-01"
|
||||
),
|
||||
)
|
||||
table = self.get_table(name="birth_names")
|
||||
for filter_ in filters:
|
||||
|
|
@ -295,7 +297,11 @@ class TestDatabaseModel(SupersetTestCase):
|
|||
"metrics": ["count"],
|
||||
"is_timeseries": False,
|
||||
"filter": [
|
||||
{"col": "num", "op": filter_.operator, "val": filter_.value}
|
||||
{
|
||||
"col": filter_.column,
|
||||
"op": filter_.operator,
|
||||
"val": filter_.value,
|
||||
}
|
||||
],
|
||||
"extras": {},
|
||||
}
|
||||
|
|
@ -835,3 +841,26 @@ def test__normalize_prequery_result_type(
|
|||
assert str(normalized) == str(result)
|
||||
else:
|
||||
assert normalized == result
|
||||
|
||||
|
||||
def test__temporal_range_operator_in_adhoc_filter(app_context, physical_dataset):
|
||||
result = physical_dataset.query(
|
||||
{
|
||||
"columns": ["col1", "col2"],
|
||||
"filter": [
|
||||
{
|
||||
"col": "col5",
|
||||
"val": "2000-01-05 : 2000-01-06",
|
||||
"op": FilterOperator.TEMPORAL_RANGE.value,
|
||||
},
|
||||
{
|
||||
"col": "col6",
|
||||
"val": "2002-05-11 : 2002-05-12",
|
||||
"op": FilterOperator.TEMPORAL_RANGE.value,
|
||||
},
|
||||
],
|
||||
"is_timeseries": False,
|
||||
}
|
||||
)
|
||||
df = pd.DataFrame(index=[0], data={"col1": 4, "col2": "e"})
|
||||
assert df.equals(result.df)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from sqlalchemy.exc import ArgumentError
|
|||
|
||||
import tests.integration_tests.test_app
|
||||
from superset import app, db, security_manager
|
||||
from superset.constants import NO_TIME_RANGE
|
||||
from superset.exceptions import CertificateException, SupersetException
|
||||
from superset.models.core import Database, Log
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
|
@ -62,7 +63,6 @@ from superset.utils.core import (
|
|||
merge_extra_filters,
|
||||
merge_extra_form_data,
|
||||
merge_request_params,
|
||||
NO_TIME_RANGE,
|
||||
normalize_dttm_col,
|
||||
parse_ssl_cert,
|
||||
parse_js_uri_path_item,
|
||||
|
|
@ -1060,7 +1060,7 @@ class TestUtils(SupersetTestCase):
|
|||
df: pd.DataFrame,
|
||||
timestamp_format: Optional[str],
|
||||
offset: int,
|
||||
time_shift: Optional[timedelta],
|
||||
time_shift: Optional[str],
|
||||
) -> pd.DataFrame:
|
||||
df = df.copy()
|
||||
normalize_dttm_col(
|
||||
|
|
@ -1091,9 +1091,9 @@ class TestUtils(SupersetTestCase):
|
|||
)
|
||||
|
||||
# test offset and timedelta
|
||||
assert normalize_col(df, None, 1, timedelta(minutes=30))[DTTM_ALIAS][
|
||||
0
|
||||
] == pd.Timestamp(2021, 2, 15, 20, 30, 0, 0)
|
||||
assert normalize_col(df, None, 1, "30 minutes")[DTTM_ALIAS][0] == pd.Timestamp(
|
||||
2021, 2, 15, 20, 30, 0, 0
|
||||
)
|
||||
|
||||
# test numeric epoch_s format
|
||||
df = pd.DataFrame([{"__timestamp": ts.timestamp(), "a": 1}])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
# 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.
|
||||
from datetime import datetime
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.common.utils.time_range_utils import (
|
||||
get_since_until_from_query_object,
|
||||
get_since_until_from_time_range,
|
||||
)
|
||||
|
||||
|
||||
def test__get_since_until_from_time_range():
|
||||
assert get_since_until_from_time_range(time_range="2001 : 2002") == (
|
||||
datetime(2001, 1, 1),
|
||||
datetime(2002, 1, 1),
|
||||
)
|
||||
assert get_since_until_from_time_range(
|
||||
time_range="2001 : 2002", time_shift="8 hours ago"
|
||||
) == (
|
||||
datetime(2000, 12, 31, 16, 0, 0),
|
||||
datetime(2001, 12, 31, 16, 0, 0),
|
||||
)
|
||||
with mock.patch(
|
||||
"superset.utils.date_parser.EvalDateTruncFunc.eval",
|
||||
return_value=datetime(2000, 1, 1, 0, 0, 0),
|
||||
):
|
||||
assert (
|
||||
get_since_until_from_time_range(
|
||||
time_range="Last year",
|
||||
extras={
|
||||
"relative_end": "2100",
|
||||
},
|
||||
)
|
||||
)[1] == datetime(2100, 1, 1, 0, 0)
|
||||
with mock.patch(
|
||||
"superset.utils.date_parser.EvalDateTruncFunc.eval",
|
||||
return_value=datetime(2000, 1, 1, 0, 0, 0),
|
||||
):
|
||||
assert (
|
||||
get_since_until_from_time_range(
|
||||
time_range="Next year",
|
||||
extras={
|
||||
"relative_start": "2000",
|
||||
},
|
||||
)
|
||||
)[0] == datetime(2000, 1, 1, 0, 0)
|
||||
|
||||
|
||||
@pytest.mark.query_object(
|
||||
{
|
||||
"time_range": "2001 : 2002",
|
||||
"time_shift": "8 hours ago",
|
||||
}
|
||||
)
|
||||
def test__since_until_from_time_range(dummy_query_object):
|
||||
assert get_since_until_from_query_object(dummy_query_object) == (
|
||||
datetime(2000, 12, 31, 16, 0, 0),
|
||||
datetime(2001, 12, 31, 16, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.query_object(
|
||||
{
|
||||
"filters": [{"col": "dttm", "op": "TEMPORAL_RANGE", "val": "2001 : 2002"}],
|
||||
"columns": [
|
||||
{
|
||||
"columnType": "BASE_AXIS",
|
||||
"label": "dttm",
|
||||
"sqlExpression": "dttm",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
def test__since_until_from_adhoc_filters(dummy_query_object):
|
||||
assert get_since_until_from_query_object(dummy_query_object) == (
|
||||
datetime(2001, 1, 1, 0, 0, 0),
|
||||
datetime(2002, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
|
||||
import importlib
|
||||
import os
|
||||
import unittest.mock
|
||||
from typing import Any, Callable, Iterator
|
||||
|
||||
import pytest
|
||||
|
|
@ -29,6 +30,8 @@ from sqlalchemy.orm.session import Session
|
|||
|
||||
from superset import security_manager
|
||||
from superset.app import SupersetApp
|
||||
from superset.common.chart_data import ChartDataResultType
|
||||
from superset.common.query_object_factory import QueryObjectFactory
|
||||
from superset.extensions import appbuilder
|
||||
from superset.initialization import SupersetAppInitializer
|
||||
|
||||
|
|
@ -136,3 +139,27 @@ def full_api_access(mocker: MockFixture) -> Iterator[None]:
|
|||
mocker.patch.object(security_manager, "can_access_all_databases", return_value=True)
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_query_object(request, app_context):
|
||||
query_obj_marker = request.node.get_closest_marker("query_object")
|
||||
result_type_marker = request.node.get_closest_marker("result_type")
|
||||
|
||||
if query_obj_marker is None:
|
||||
query_object = {}
|
||||
else:
|
||||
query_object = query_obj_marker.args[0]
|
||||
|
||||
if result_type_marker is None:
|
||||
result_type = ChartDataResultType.FULL
|
||||
else:
|
||||
result_type = result_type_marker.args[0]
|
||||
|
||||
yield QueryObjectFactory(
|
||||
app_configurations={
|
||||
"ROW_LIMIT": 100,
|
||||
},
|
||||
_datasource_dao=unittest.mock.Mock(),
|
||||
session_maker=unittest.mock.Mock(),
|
||||
).create(parent_result_type=result_type, **query_object)
|
||||
|
|
|
|||
Loading…
Reference in New Issue