mirror of
https://github.com/apache/superset.git
synced 2026-08-14 12:01:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a029736be | ||
|
|
a188e9473a |
@@ -45,6 +45,13 @@ export interface ContextMenuFilters {
|
||||
filters: BinaryQueryObjectFilterClause[];
|
||||
groupbyFieldName: string;
|
||||
adhocFilterFieldName?: string;
|
||||
/**
|
||||
* Filters scoped to the clicked x-axis value (category or time bucket),
|
||||
* as opposed to `filters`, which are scoped to the clicked series.
|
||||
* When both are present, the Drill By UI lets the user choose which
|
||||
* of the two (or both) to apply to the drilled chart.
|
||||
*/
|
||||
xAxisFilters?: BinaryQueryObjectFilterClause[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+98
-1
@@ -21,7 +21,8 @@ import {
|
||||
waitFor,
|
||||
cleanup,
|
||||
} from '../../../../spec/helpers/testing-library';
|
||||
import { AxisType } from '@superset-ui/core';
|
||||
import { AxisType, TimeGranularity } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import type { ECElementEvent } from 'echarts/types/src/util/types';
|
||||
import type { ReactNode } from 'react';
|
||||
@@ -655,3 +656,99 @@ test('context menu cross-filter uses the category value for a horizontal categor
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// A category axis can still sit on a temporal column when the axis is
|
||||
// forced categorical (xAxisForceCategorical); the drillBy x-axis filter must
|
||||
// then bucket by the configured time grain rather than doing an exact match.
|
||||
test('drillBy filters by time bucket when a categorical axis is forced onto a temporal column', async () => {
|
||||
const onContextMenuMock = jest.fn();
|
||||
|
||||
const propsWithForcedCategoricalTemporalAxis: TimeseriesChartTransformedProps =
|
||||
{
|
||||
...defaultProps,
|
||||
onContextMenu: onContextMenuMock,
|
||||
formData: {
|
||||
...defaultFormData,
|
||||
xAxisForceCategorical: true,
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
},
|
||||
coltypeMapping: { order_date: GenericDataType.Temporal },
|
||||
xAxis: {
|
||||
label: 'order_date',
|
||||
type: AxisType.Category,
|
||||
},
|
||||
};
|
||||
|
||||
render(<EchartsTimeseries {...propsWithForcedCategoricalTemporalAxis} />);
|
||||
|
||||
const contextMenuHandler = getLatestEchartProps().eventHandlers?.contextmenu;
|
||||
expect(contextMenuHandler).toBeDefined();
|
||||
await contextMenuHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: ['2021-02-01T00:00:00', 100],
|
||||
name: '2021-02-01T00:00:00',
|
||||
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onContextMenuMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const { drillBy } = onContextMenuMock.mock.calls[0][2];
|
||||
expect(drillBy.xAxisFilters).toEqual([
|
||||
{
|
||||
col: 'order_date',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-02-01T00:00:00 : 2021-03-01T00:00:00',
|
||||
formattedVal: '2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// For horizontal orientation the [x, value] pair reported by ECharts is
|
||||
// swapped, so the drillBy x-axis filter must read the clicked time value
|
||||
// from the second element of the data tuple rather than the first.
|
||||
test('drillBy uses the swapped data index for a horizontal time-based axis', async () => {
|
||||
const onContextMenuMock = jest.fn();
|
||||
|
||||
const propsWithHorizontalTimeAxis: TimeseriesChartTransformedProps = {
|
||||
...defaultProps,
|
||||
onContextMenu: onContextMenuMock,
|
||||
formData: {
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
},
|
||||
xAxis: {
|
||||
label: 'order_date',
|
||||
type: AxisType.Time,
|
||||
},
|
||||
};
|
||||
|
||||
render(<EchartsTimeseries {...propsWithHorizontalTimeAxis} />);
|
||||
|
||||
const contextMenuHandler = getLatestEchartProps().eventHandlers?.contextmenu;
|
||||
expect(contextMenuHandler).toBeDefined();
|
||||
await contextMenuHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
// Horizontal: value first, x (time) value second
|
||||
data: [100, '2021-02-01T00:00:00'],
|
||||
name: '2021-02-01T00:00:00',
|
||||
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onContextMenuMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const { drillBy } = onContextMenuMock.mock.calls[0][2];
|
||||
expect(drillBy.xAxisFilters).toEqual([
|
||||
{
|
||||
col: 'order_date',
|
||||
op: '==',
|
||||
val: '2021-02-01T00:00:00',
|
||||
formattedVal: '2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
+56
-1
@@ -28,6 +28,7 @@ import {
|
||||
ensureIsArray,
|
||||
} from '@superset-ui/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import type {
|
||||
ECElementEvent,
|
||||
ViewRootGroup,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
} from './percentChange';
|
||||
import { OrientationType, TimeseriesChartTransformedProps } from './types';
|
||||
import { formatSeriesName } from '../utils/series';
|
||||
import { getTemporalXAxisDrillByFilter } from '../utils/xAxisDrillByFilter';
|
||||
import { ExtraControls } from '../components/ExtraControls';
|
||||
|
||||
const TIMER_DURATION = 300;
|
||||
@@ -507,6 +509,55 @@ export default function EchartsTimeseries({
|
||||
});
|
||||
});
|
||||
|
||||
// Filters for the clicked x-axis value, so Drill By can subset the
|
||||
// drilled data to the clicked bar/point rather than only the series
|
||||
const xAxisFilters: BinaryQueryObjectFilterClause[] = [];
|
||||
const xAxisCol =
|
||||
// if the xAxis is '__timestamp', granularity_sqla will be the column of filter
|
||||
xAxis.label === DTTM_ALIAS ? formData.granularitySqla : xAxis.label;
|
||||
if (data && xAxis.type === AxisType.Time && xAxisCol) {
|
||||
// For horizontal orientation the [x, value] pair is swapped
|
||||
const xValue = Array.isArray(data)
|
||||
? data[categoryAxisValueIndex]
|
||||
: data;
|
||||
const xAxisFilter = getTemporalXAxisDrillByFilter(
|
||||
xAxisCol,
|
||||
xValue,
|
||||
formData.timeGrainSqla,
|
||||
String(xValueFormatter(xValue as number)),
|
||||
);
|
||||
if (xAxisFilter) {
|
||||
xAxisFilters.push(xAxisFilter);
|
||||
}
|
||||
} else if (xAxis.type === AxisType.Category && xAxisCol) {
|
||||
const categoryAxisValue = getCategoryAxisValue(
|
||||
data,
|
||||
eventParams.name,
|
||||
);
|
||||
if (categoryAxisValue !== undefined) {
|
||||
// A category axis can still sit on a temporal column when the
|
||||
// axis is forced categorical; filter by time bucket in that case
|
||||
const xAxisFilter =
|
||||
coltypeMapping?.[getColumnLabel(xAxis.label)] ===
|
||||
GenericDataType.Temporal
|
||||
? getTemporalXAxisDrillByFilter(
|
||||
xAxisCol,
|
||||
categoryAxisValue,
|
||||
formData.timeGrainSqla,
|
||||
String(eventParams.name ?? categoryAxisValue),
|
||||
)
|
||||
: {
|
||||
col: xAxisCol,
|
||||
op: '==' as const,
|
||||
val: categoryAxisValue,
|
||||
formattedVal: String(categoryAxisValue),
|
||||
};
|
||||
if (xAxisFilter) {
|
||||
xAxisFilters.push(xAxisFilter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Provide cross-filter for dimensions OR categorical X-axis (issue #25334)
|
||||
let crossFilter;
|
||||
if (hasDimensions) {
|
||||
@@ -526,7 +577,11 @@ export default function EchartsTimeseries({
|
||||
|
||||
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
||||
drillToDetail: drillToDetailFilters,
|
||||
drillBy: { filters: drillByFilters, groupbyFieldName: 'groupby' },
|
||||
drillBy: {
|
||||
filters: drillByFilters,
|
||||
groupbyFieldName: 'groupby',
|
||||
...(xAxisFilters.length > 0 && { xAxisFilters }),
|
||||
},
|
||||
crossFilter,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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 {
|
||||
BinaryQueryObjectFilterClause,
|
||||
QueryFormColumn,
|
||||
TimeGranularity,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Format a Date as a naive ISO datetime string (UTC, no timezone suffix),
|
||||
* the format Superset's time range parser expects, e.g. "2021-01-01T00:00:00".
|
||||
* Sub-second precision is preserved when present (e.g.
|
||||
* "2021-01-01T00:00:00.123") so exact-match filters on high-frequency
|
||||
* timestamps don't get truncated to the containing second.
|
||||
*/
|
||||
export const formatNaiveDateTime = (date: Date): string => {
|
||||
const iso = date.toISOString();
|
||||
return date.getUTCMilliseconds() === 0 ? iso.slice(0, 19) : iso.slice(0, 23);
|
||||
};
|
||||
|
||||
/**
|
||||
* Given the start (label) of a time bucket and its time grain, return the
|
||||
* [since, until) range covering the bucket, using calendar-aware UTC
|
||||
* arithmetic. Week-ending grains are labeled by the last day of the bucket,
|
||||
* so their range extends backwards from the label. Returns undefined for
|
||||
* unknown grains.
|
||||
*/
|
||||
export const getTimeBucketRange = (
|
||||
bucketLabel: Date,
|
||||
grain: TimeGranularity,
|
||||
): { since: Date; until: Date } | undefined => {
|
||||
const until = new Date(bucketLabel.getTime());
|
||||
switch (grain) {
|
||||
case TimeGranularity.SECOND:
|
||||
until.setUTCSeconds(until.getUTCSeconds() + 1);
|
||||
break;
|
||||
case TimeGranularity.MINUTE:
|
||||
until.setUTCMinutes(until.getUTCMinutes() + 1);
|
||||
break;
|
||||
case TimeGranularity.FIVE_MINUTES:
|
||||
until.setUTCMinutes(until.getUTCMinutes() + 5);
|
||||
break;
|
||||
case TimeGranularity.TEN_MINUTES:
|
||||
until.setUTCMinutes(until.getUTCMinutes() + 10);
|
||||
break;
|
||||
case TimeGranularity.FIFTEEN_MINUTES:
|
||||
until.setUTCMinutes(until.getUTCMinutes() + 15);
|
||||
break;
|
||||
case TimeGranularity.THIRTY_MINUTES:
|
||||
until.setUTCMinutes(until.getUTCMinutes() + 30);
|
||||
break;
|
||||
case TimeGranularity.HOUR:
|
||||
until.setUTCHours(until.getUTCHours() + 1);
|
||||
break;
|
||||
case TimeGranularity.DATE:
|
||||
case TimeGranularity.DAY:
|
||||
until.setUTCDate(until.getUTCDate() + 1);
|
||||
break;
|
||||
case TimeGranularity.WEEK:
|
||||
case TimeGranularity.WEEK_STARTING_SUNDAY:
|
||||
case TimeGranularity.WEEK_STARTING_MONDAY:
|
||||
until.setUTCDate(until.getUTCDate() + 7);
|
||||
break;
|
||||
case TimeGranularity.WEEK_ENDING_SATURDAY:
|
||||
case TimeGranularity.WEEK_ENDING_SUNDAY:
|
||||
// These buckets are labeled with their last day: the bucket spans
|
||||
// the 6 days before the label plus the label day itself.
|
||||
until.setUTCDate(until.getUTCDate() + 1);
|
||||
return { since: new Date(bucketLabel.getTime() - 6 * DAY_MS), until };
|
||||
case TimeGranularity.MONTH:
|
||||
until.setUTCMonth(until.getUTCMonth() + 1);
|
||||
break;
|
||||
case TimeGranularity.QUARTER:
|
||||
until.setUTCMonth(until.getUTCMonth() + 3);
|
||||
break;
|
||||
case TimeGranularity.YEAR:
|
||||
until.setUTCFullYear(until.getUTCFullYear() + 1);
|
||||
break;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
return { since: new Date(bucketLabel.getTime()), until };
|
||||
};
|
||||
|
||||
// Matches an explicit timezone designator (Z or ±HH:MM/±HHMM) at the end of
|
||||
// a datetime string.
|
||||
const TIMEZONE_SUFFIX_RE = /(Z|[+-]\d{2}:?\d{2})$/;
|
||||
|
||||
/**
|
||||
* Parse a datetime string the same way regardless of the host's local
|
||||
* timezone. Superset's backend returns naive datetime strings (no timezone
|
||||
* designator) that represent UTC instants; passing those directly to `new
|
||||
* Date()` would interpret them as local wall-clock time and shift the
|
||||
* result by the browser's UTC offset. Strings that already carry an
|
||||
* explicit timezone designator are parsed as-is.
|
||||
*/
|
||||
const parseAsUtc = (value: string): Date =>
|
||||
new Date(TIMEZONE_SUFFIX_RE.test(value) ? value : `${value}Z`);
|
||||
|
||||
/**
|
||||
* Build a drill-by filter clause matching the clicked value on a temporal
|
||||
* x-axis. When a known time grain is active, the clause is a TEMPORAL_RANGE
|
||||
* covering the clicked bucket; otherwise it falls back to an exact match on
|
||||
* the timestamp.
|
||||
*/
|
||||
export const getTemporalXAxisDrillByFilter = (
|
||||
col: QueryFormColumn,
|
||||
value: unknown,
|
||||
grain?: TimeGranularity,
|
||||
formattedVal?: string,
|
||||
): BinaryQueryObjectFilterClause | undefined => {
|
||||
if (
|
||||
!col ||
|
||||
(typeof value !== 'number' &&
|
||||
typeof value !== 'string' &&
|
||||
!(value instanceof Date))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
let bucketLabel: Date;
|
||||
if (value instanceof Date) {
|
||||
bucketLabel = value;
|
||||
} else if (typeof value === 'string') {
|
||||
bucketLabel = parseAsUtc(value);
|
||||
} else {
|
||||
bucketLabel = new Date(value);
|
||||
}
|
||||
if (Number.isNaN(bucketLabel.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
const range = grain ? getTimeBucketRange(bucketLabel, grain) : undefined;
|
||||
if (!range) {
|
||||
return {
|
||||
col,
|
||||
op: '==',
|
||||
val: formatNaiveDateTime(bucketLabel),
|
||||
formattedVal,
|
||||
};
|
||||
}
|
||||
return {
|
||||
col,
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: `${formatNaiveDateTime(range.since)} : ${formatNaiveDateTime(range.until)}`,
|
||||
formattedVal,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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 { TimeGranularity } from '@superset-ui/core';
|
||||
import {
|
||||
formatNaiveDateTime,
|
||||
getTemporalXAxisDrillByFilter,
|
||||
getTimeBucketRange,
|
||||
} from '../../src/utils/xAxisDrillByFilter';
|
||||
|
||||
const utc = (dateString: string) => new Date(`${dateString}Z`);
|
||||
|
||||
const expectRange = (
|
||||
bucketLabel: string,
|
||||
grain: TimeGranularity,
|
||||
since: string,
|
||||
until: string,
|
||||
) => {
|
||||
const range = getTimeBucketRange(utc(bucketLabel), grain);
|
||||
expect(range).toBeDefined();
|
||||
expect(formatNaiveDateTime(range!.since)).toEqual(since);
|
||||
expect(formatNaiveDateTime(range!.until)).toEqual(until);
|
||||
};
|
||||
|
||||
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
|
||||
|
||||
test('getTimeBucketRange computes sub-daily buckets', () => {
|
||||
expectRange(
|
||||
'2021-03-14T01:59:00',
|
||||
TimeGranularity.MINUTE,
|
||||
'2021-03-14T01:59:00',
|
||||
'2021-03-14T02:00:00',
|
||||
);
|
||||
expectRange(
|
||||
'2021-03-14T01:30:00',
|
||||
TimeGranularity.THIRTY_MINUTES,
|
||||
'2021-03-14T01:30:00',
|
||||
'2021-03-14T02:00:00',
|
||||
);
|
||||
expectRange(
|
||||
'2021-03-14T23:00:00',
|
||||
TimeGranularity.HOUR,
|
||||
'2021-03-14T23:00:00',
|
||||
'2021-03-15T00:00:00',
|
||||
);
|
||||
});
|
||||
|
||||
test('getTimeBucketRange computes daily and weekly buckets', () => {
|
||||
expectRange(
|
||||
'2021-12-31T00:00:00',
|
||||
TimeGranularity.DAY,
|
||||
'2021-12-31T00:00:00',
|
||||
'2022-01-01T00:00:00',
|
||||
);
|
||||
expectRange(
|
||||
'2021-04-26T00:00:00',
|
||||
TimeGranularity.WEEK,
|
||||
'2021-04-26T00:00:00',
|
||||
'2021-05-03T00:00:00',
|
||||
);
|
||||
expectRange(
|
||||
'2021-04-25T00:00:00',
|
||||
TimeGranularity.WEEK_STARTING_SUNDAY,
|
||||
'2021-04-25T00:00:00',
|
||||
'2021-05-02T00:00:00',
|
||||
);
|
||||
});
|
||||
|
||||
test('getTimeBucketRange extends week-ending buckets backwards from their label', () => {
|
||||
expectRange(
|
||||
'2021-05-01T00:00:00',
|
||||
TimeGranularity.WEEK_ENDING_SATURDAY,
|
||||
'2021-04-25T00:00:00',
|
||||
'2021-05-02T00:00:00',
|
||||
);
|
||||
expectRange(
|
||||
'2021-05-02T00:00:00',
|
||||
TimeGranularity.WEEK_ENDING_SUNDAY,
|
||||
'2021-04-26T00:00:00',
|
||||
'2021-05-03T00:00:00',
|
||||
);
|
||||
});
|
||||
|
||||
test('getTimeBucketRange respects calendar month lengths', () => {
|
||||
expectRange(
|
||||
'2021-01-01T00:00:00',
|
||||
TimeGranularity.MONTH,
|
||||
'2021-01-01T00:00:00',
|
||||
'2021-02-01T00:00:00',
|
||||
);
|
||||
expectRange(
|
||||
'2021-02-01T00:00:00',
|
||||
TimeGranularity.MONTH,
|
||||
'2021-02-01T00:00:00',
|
||||
'2021-03-01T00:00:00',
|
||||
);
|
||||
expectRange(
|
||||
'2024-02-01T00:00:00',
|
||||
TimeGranularity.MONTH,
|
||||
'2024-02-01T00:00:00',
|
||||
'2024-03-01T00:00:00',
|
||||
);
|
||||
expectRange(
|
||||
'2021-12-01T00:00:00',
|
||||
TimeGranularity.MONTH,
|
||||
'2021-12-01T00:00:00',
|
||||
'2022-01-01T00:00:00',
|
||||
);
|
||||
});
|
||||
|
||||
test('getTimeBucketRange computes quarter and year buckets', () => {
|
||||
expectRange(
|
||||
'2021-10-01T00:00:00',
|
||||
TimeGranularity.QUARTER,
|
||||
'2021-10-01T00:00:00',
|
||||
'2022-01-01T00:00:00',
|
||||
);
|
||||
expectRange(
|
||||
'2024-01-01T00:00:00',
|
||||
TimeGranularity.YEAR,
|
||||
'2024-01-01T00:00:00',
|
||||
'2025-01-01T00:00:00',
|
||||
);
|
||||
});
|
||||
|
||||
test('getTimeBucketRange returns undefined for unknown grains', () => {
|
||||
expect(
|
||||
getTimeBucketRange(utc('2021-01-01T00:00:00'), 'P1D2H' as TimeGranularity),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getTemporalXAxisDrillByFilter builds a temporal range for known grains', () => {
|
||||
expect(
|
||||
getTemporalXAxisDrillByFilter(
|
||||
'ds',
|
||||
utc('2021-01-01T00:00:00').getTime(),
|
||||
TimeGranularity.MONTH,
|
||||
'Jan 2021',
|
||||
),
|
||||
).toEqual({
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
formattedVal: 'Jan 2021',
|
||||
});
|
||||
});
|
||||
|
||||
test('getTemporalXAxisDrillByFilter falls back to exact match without a grain', () => {
|
||||
expect(
|
||||
getTemporalXAxisDrillByFilter(
|
||||
'ds',
|
||||
utc('2021-01-01T12:34:56').getTime(),
|
||||
undefined,
|
||||
'2021-01-01 12:34:56',
|
||||
),
|
||||
).toEqual({
|
||||
col: 'ds',
|
||||
op: '==',
|
||||
val: '2021-01-01T12:34:56',
|
||||
formattedVal: '2021-01-01 12:34:56',
|
||||
});
|
||||
});
|
||||
|
||||
test('getTemporalXAxisDrillByFilter preserves sub-second precision in the exact-match fallback', () => {
|
||||
expect(
|
||||
getTemporalXAxisDrillByFilter(
|
||||
'ds',
|
||||
new Date('2021-01-01T12:34:56.123Z').getTime(),
|
||||
undefined,
|
||||
'2021-01-01 12:34:56.123',
|
||||
),
|
||||
).toEqual({
|
||||
col: 'ds',
|
||||
op: '==',
|
||||
val: '2021-01-01T12:34:56.123',
|
||||
formattedVal: '2021-01-01 12:34:56.123',
|
||||
});
|
||||
});
|
||||
|
||||
test('getTemporalXAxisDrillByFilter falls back to exact match for unknown grains', () => {
|
||||
expect(
|
||||
getTemporalXAxisDrillByFilter(
|
||||
'ds',
|
||||
utc('2021-01-01T00:00:00').getTime(),
|
||||
'P1D2H' as TimeGranularity,
|
||||
),
|
||||
).toMatchObject({ op: '==', val: '2021-01-01T00:00:00' });
|
||||
});
|
||||
|
||||
test('getTemporalXAxisDrillByFilter accepts parseable date strings and Dates', () => {
|
||||
expect(
|
||||
getTemporalXAxisDrillByFilter(
|
||||
'ds',
|
||||
'2021-01-01T00:00:00Z',
|
||||
TimeGranularity.DAY,
|
||||
),
|
||||
).toMatchObject({
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
|
||||
});
|
||||
expect(
|
||||
getTemporalXAxisDrillByFilter(
|
||||
'ds',
|
||||
utc('2021-01-01T00:00:00'),
|
||||
TimeGranularity.DAY,
|
||||
),
|
||||
).toMatchObject({
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
|
||||
});
|
||||
});
|
||||
|
||||
test('getTemporalXAxisDrillByFilter returns undefined for unusable input', () => {
|
||||
expect(
|
||||
getTemporalXAxisDrillByFilter('ds', 'not a date', TimeGranularity.DAY),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getTemporalXAxisDrillByFilter('ds', null, TimeGranularity.DAY),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getTemporalXAxisDrillByFilter('', 1609459200000, TimeGranularity.DAY),
|
||||
).toBeUndefined();
|
||||
});
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useRef, useState } from 'react';
|
||||
import { FeatureFlag, VizType } from '@superset-ui/core';
|
||||
import { ContextMenuFilters, FeatureFlag, VizType } from '@superset-ui/core';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import mockState from 'spec/fixtures/mockState';
|
||||
@@ -30,6 +30,35 @@ import ChartContextMenu, {
|
||||
|
||||
jest.mock('src/utils/cachedSupersetGet');
|
||||
|
||||
// The scope-selector behavior within the submenu (which filters get built
|
||||
// for x-axis/series/both) is covered by DrillBySubmenu.test.tsx. Here we
|
||||
// only need a stand-in that lets us trigger onDrillBy with a distinguishable
|
||||
// config, so we can assert ChartContextMenu wires it into the modal.
|
||||
jest.mock('../DrillBy/DrillBySubmenu', () => ({
|
||||
DrillBySubmenu: ({ onDrillBy }: any) => (
|
||||
<button
|
||||
type="button"
|
||||
data-test="fake-drill-by-submenu"
|
||||
onClick={() =>
|
||||
onDrillBy(
|
||||
{ column_name: 'city', groupby: true },
|
||||
{ id: 1, columns: [], metrics: [] },
|
||||
{ filters: [{ col: 'selected_scope' }], groupbyFieldName: 'groupby' },
|
||||
)
|
||||
}
|
||||
>
|
||||
Fake Drill By
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('src/components/Chart/DrillBy/DrillByModal', () => ({
|
||||
__esModule: true,
|
||||
default: ({ drillByConfig }: any) => (
|
||||
<div data-test="drill-by-modal">{JSON.stringify(drillByConfig)}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockCachedSupersetGet = cachedSupersetGet as jest.MockedFunction<
|
||||
typeof cachedSupersetGet
|
||||
>;
|
||||
@@ -39,7 +68,11 @@ const defaultFormData = {
|
||||
viz_type: VizType.Pie,
|
||||
};
|
||||
|
||||
const TestWrapper = () => {
|
||||
const TestWrapper = ({
|
||||
openFilters = {},
|
||||
}: {
|
||||
openFilters?: ContextMenuFilters;
|
||||
}) => {
|
||||
const contextMenuRef = useRef<ChartContextMenuRef>(null);
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(true);
|
||||
|
||||
@@ -51,7 +84,7 @@ const TestWrapper = () => {
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => contextMenuRef.current?.open(100, 100, {})}
|
||||
onClick={() => contextMenuRef.current?.open(100, 100, openFilters)}
|
||||
data-test="open-context-menu"
|
||||
>
|
||||
Open Context Menu
|
||||
@@ -71,8 +104,8 @@ const TestWrapper = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const setup = () =>
|
||||
render(<TestWrapper />, {
|
||||
const setup = (openFilters?: ContextMenuFilters) =>
|
||||
render(<TestWrapper openFilters={openFilters} />, {
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
...mockState,
|
||||
@@ -150,3 +183,30 @@ test('tooltip is restored when user selects a menu item', async () => {
|
||||
expect(screen.getByTestId('tooltip-visible')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('drill by modal uses the scope selected in the submenu over the raw context filters', async () => {
|
||||
setup({
|
||||
drillBy: {
|
||||
filters: [{ col: 'raw_scope', op: '==', val: 'raw' }],
|
||||
groupbyFieldName: 'groupby',
|
||||
},
|
||||
});
|
||||
|
||||
userEvent.click(screen.getByTestId('open-context-menu'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chart-context-menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const submenuButton = await screen.findByTestId('fake-drill-by-submenu');
|
||||
userEvent.click(submenuButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drill-by-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const modalConfig = JSON.parse(
|
||||
screen.getByTestId('drill-by-modal').textContent || '{}',
|
||||
);
|
||||
expect(modalConfig.filters).toEqual([{ col: 'selected_scope' }]);
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ import { getMenuAdjustedY } from '../utils';
|
||||
import { DrillBySubmenu } from '../DrillBy/DrillBySubmenu';
|
||||
import DrillDetailModal from '../DrillDetail/DrillDetailModal';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
export enum ContextMenuItem {
|
||||
CrossFilter,
|
||||
@@ -155,6 +156,10 @@ const ChartContextMenu = (
|
||||
|
||||
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
|
||||
const [drillByColumn, setDrillByColumn] = useState<Column>();
|
||||
// Drill by config as selected in the submenu (e.g. with the chosen
|
||||
// x-axis/series filter scope applied), used over the raw context filters
|
||||
const [selectedDrillByConfig, setSelectedDrillByConfig] =
|
||||
useState<ContextMenuFilters['drillBy']>();
|
||||
const [showDrillByModal, setShowDrillByModal] = useState(false);
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
@@ -162,10 +167,18 @@ const ChartContextMenu = (
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleDrillBy = useCallback((column: Column) => {
|
||||
setDrillByColumn(column);
|
||||
setShowDrillByModal(true);
|
||||
}, []);
|
||||
const handleDrillBy = useCallback(
|
||||
(
|
||||
column: Column,
|
||||
_dataset: Dataset,
|
||||
drillByConfig?: ContextMenuFilters['drillBy'],
|
||||
) => {
|
||||
setDrillByColumn(column);
|
||||
setSelectedDrillByConfig(drillByConfig);
|
||||
setShowDrillByModal(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadDrillByOptionsExtension = getExtensionsRegistry().get(
|
||||
'load.drillby.options',
|
||||
@@ -175,6 +188,8 @@ const ChartContextMenu = (
|
||||
setShowDrillByModal(false);
|
||||
}, []);
|
||||
|
||||
const drillByModalConfig = selectedDrillByConfig ?? enhancedFilters?.drillBy;
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
const showDrillToDetail =
|
||||
@@ -459,10 +474,10 @@ const ChartContextMenu = (
|
||||
{showDrillByModal &&
|
||||
drillByColumn &&
|
||||
filteredDataset &&
|
||||
enhancedFilters?.drillBy && (
|
||||
drillByModalConfig && (
|
||||
<DrillByModal
|
||||
column={drillByColumn}
|
||||
drillByConfig={enhancedFilters?.drillBy}
|
||||
drillByConfig={drillByModalConfig}
|
||||
formData={formData}
|
||||
onHideModal={handleCloseDrillByModal}
|
||||
dataset={filteredDataset}
|
||||
|
||||
@@ -274,6 +274,140 @@ test('When menu item is clicked, call onSelection with clicked column and drill
|
||||
);
|
||||
});
|
||||
|
||||
const xAxisFilters = [
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE' as const,
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
formattedVal: 'Jan 2021',
|
||||
},
|
||||
];
|
||||
|
||||
test('do not display scope selector without x-axis filters', async () => {
|
||||
renderSubmenu({});
|
||||
await expectDrillByEnabled();
|
||||
await screen.findByText('col1');
|
||||
expect(
|
||||
screen.queryByTestId('drill-by-scope-selector'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('do not display scope selector with only x-axis filters', async () => {
|
||||
renderSubmenu({
|
||||
drillByConfig: { filters: [], xAxisFilters, groupbyFieldName: 'groupby' },
|
||||
});
|
||||
await expectDrillByEnabled();
|
||||
await screen.findByText('col1');
|
||||
expect(
|
||||
screen.queryByTestId('drill-by-scope-selector'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('display scope selector when x-axis and series filters are present', async () => {
|
||||
renderSubmenu({
|
||||
drillByConfig: {
|
||||
filters: defaultFilters,
|
||||
xAxisFilters,
|
||||
groupbyFieldName: 'groupby',
|
||||
},
|
||||
});
|
||||
await expectDrillByEnabled();
|
||||
await screen.findByText('col1');
|
||||
|
||||
const scopeSelector = screen.getByTestId('drill-by-scope-selector');
|
||||
expect(scopeSelector).toBeInTheDocument();
|
||||
expect(within(scopeSelector).getByText('Jan 2021')).toBeInTheDocument();
|
||||
expect(within(scopeSelector).getByText('val')).toBeInTheDocument();
|
||||
expect(within(scopeSelector).getByText('Both')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('apply both x-axis and series filters by default', async () => {
|
||||
const onSelectionMock = jest.fn();
|
||||
renderSubmenu({
|
||||
drillByConfig: {
|
||||
filters: defaultFilters,
|
||||
xAxisFilters,
|
||||
groupbyFieldName: 'groupby',
|
||||
},
|
||||
onSelection: onSelectionMock,
|
||||
});
|
||||
await expectDrillByEnabled();
|
||||
|
||||
const col1Element = await screen.findByText('col1');
|
||||
userEvent.click(col1Element);
|
||||
|
||||
expect(onSelectionMock).toHaveBeenCalledWith(
|
||||
{ column_name: 'col1', groupby: true },
|
||||
{
|
||||
filters: [...xAxisFilters, ...defaultFilters],
|
||||
groupbyFieldName: 'groupby',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('apply only x-axis filters when x-axis scope is selected', async () => {
|
||||
const onSelectionMock = jest.fn();
|
||||
renderSubmenu({
|
||||
drillByConfig: {
|
||||
filters: defaultFilters,
|
||||
xAxisFilters,
|
||||
groupbyFieldName: 'groupby',
|
||||
},
|
||||
onSelection: onSelectionMock,
|
||||
});
|
||||
await expectDrillByEnabled();
|
||||
await screen.findByText('col1');
|
||||
|
||||
const scopeSelector = screen.getByTestId('drill-by-scope-selector');
|
||||
userEvent.click(within(scopeSelector).getByText('Jan 2021'));
|
||||
userEvent.click(screen.getByText('col1'));
|
||||
|
||||
expect(onSelectionMock).toHaveBeenCalledWith(
|
||||
{ column_name: 'col1', groupby: true },
|
||||
{ filters: xAxisFilters, groupbyFieldName: 'groupby' },
|
||||
);
|
||||
});
|
||||
|
||||
test('apply only series filters when series scope is selected', async () => {
|
||||
const onSelectionMock = jest.fn();
|
||||
renderSubmenu({
|
||||
drillByConfig: {
|
||||
filters: defaultFilters,
|
||||
xAxisFilters,
|
||||
groupbyFieldName: 'groupby',
|
||||
},
|
||||
onSelection: onSelectionMock,
|
||||
});
|
||||
await expectDrillByEnabled();
|
||||
await screen.findByText('col1');
|
||||
|
||||
const scopeSelector = screen.getByTestId('drill-by-scope-selector');
|
||||
userEvent.click(within(scopeSelector).getByText('val'));
|
||||
userEvent.click(screen.getByText('col1'));
|
||||
|
||||
expect(onSelectionMock).toHaveBeenCalledWith(
|
||||
{ column_name: 'col1', groupby: true },
|
||||
{ filters: defaultFilters, groupbyFieldName: 'groupby' },
|
||||
);
|
||||
});
|
||||
|
||||
test('apply x-axis filters when only x-axis filters are present', async () => {
|
||||
const onSelectionMock = jest.fn();
|
||||
renderSubmenu({
|
||||
drillByConfig: { filters: [], xAxisFilters, groupbyFieldName: 'groupby' },
|
||||
onSelection: onSelectionMock,
|
||||
});
|
||||
await expectDrillByEnabled();
|
||||
|
||||
const col1Element = await screen.findByText('col1');
|
||||
userEvent.click(col1Element);
|
||||
|
||||
expect(onSelectionMock).toHaveBeenCalledWith(
|
||||
{ column_name: 'col1', groupby: true },
|
||||
{ filters: xAxisFilters, groupbyFieldName: 'groupby' },
|
||||
);
|
||||
});
|
||||
|
||||
test('matrixify_mode_rows enabled should not render component', () => {
|
||||
const { container } = renderSubmenu({
|
||||
formData: {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
BaseFormData,
|
||||
Behavior,
|
||||
BinaryQueryObjectFilterClause,
|
||||
Column,
|
||||
ContextMenuFilters,
|
||||
ensureIsArray,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
Popover,
|
||||
Icons,
|
||||
} from '@superset-ui/core/components';
|
||||
import { Radio } from '@superset-ui/core/components/Radio';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { List, type RowComponentProps } from 'react-window';
|
||||
import { InputRef } from 'antd';
|
||||
@@ -80,6 +82,15 @@ function DrillByColumnRow({
|
||||
);
|
||||
}
|
||||
|
||||
enum DrillByFilterScope {
|
||||
XAxis = 'x-axis',
|
||||
Series = 'series',
|
||||
All = 'all',
|
||||
}
|
||||
|
||||
const formatFilterValues = (filters: BinaryQueryObjectFilterClause[]) =>
|
||||
filters.map(filter => filter.formattedVal ?? String(filter.val)).join(', ');
|
||||
|
||||
export interface DrillBySubmenuProps {
|
||||
drillByConfig?: ContextMenuFilters['drillBy'];
|
||||
formData: BaseFormData & { [key: string]: any };
|
||||
@@ -88,7 +99,11 @@ export interface DrillBySubmenuProps {
|
||||
onCloseMenu?: () => void;
|
||||
openNewModal?: boolean;
|
||||
excludedColumns?: Column[];
|
||||
onDrillBy?: (column: Column, dataset: Dataset) => void;
|
||||
onDrillBy?: (
|
||||
column: Column,
|
||||
dataset: Dataset,
|
||||
drillByConfig?: ContextMenuFilters['drillBy'],
|
||||
) => void;
|
||||
dataset?: Dataset;
|
||||
isLoadingDataset?: boolean;
|
||||
}
|
||||
@@ -110,6 +125,7 @@ export const DrillBySubmenu = ({
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [filterScope, setFilterScope] = useState(DrillByFilterScope.All);
|
||||
const ref = useRef<InputRef>(null);
|
||||
const menuItemRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -119,18 +135,54 @@ export const DrillBySubmenu = ({
|
||||
);
|
||||
const showSearch = columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
|
||||
|
||||
const seriesFilters = useMemo(
|
||||
() => ensureIsArray(drillByConfig?.filters),
|
||||
[drillByConfig?.filters],
|
||||
);
|
||||
const xAxisFilters = useMemo(
|
||||
() => ensureIsArray(drillByConfig?.xAxisFilters),
|
||||
[drillByConfig?.xAxisFilters],
|
||||
);
|
||||
// Both the clicked x-axis value and the clicked series can scope the
|
||||
// drilled data; when both are available the user picks which to apply
|
||||
const showScopeSelector = seriesFilters.length > 0 && xAxisFilters.length > 0;
|
||||
|
||||
const effectiveDrillByConfig = useMemo(():
|
||||
| ContextMenuFilters['drillBy']
|
||||
| undefined => {
|
||||
if (!drillByConfig) {
|
||||
return undefined;
|
||||
}
|
||||
let filters = [...xAxisFilters, ...seriesFilters];
|
||||
if (showScopeSelector && filterScope === DrillByFilterScope.XAxis) {
|
||||
filters = xAxisFilters;
|
||||
} else if (showScopeSelector && filterScope === DrillByFilterScope.Series) {
|
||||
filters = seriesFilters;
|
||||
}
|
||||
const config = { ...drillByConfig, filters };
|
||||
// the x-axis filters have been folded into `filters` above
|
||||
delete config.xAxisFilters;
|
||||
return config;
|
||||
}, [
|
||||
drillByConfig,
|
||||
filterScope,
|
||||
seriesFilters,
|
||||
showScopeSelector,
|
||||
xAxisFilters,
|
||||
]);
|
||||
|
||||
const handleSelection = useCallback(
|
||||
(event: React.MouseEvent, column: Column) => {
|
||||
onClick(event);
|
||||
onSelection(column, drillByConfig);
|
||||
onSelection(column, effectiveDrillByConfig);
|
||||
if (openNewModal && onDrillBy && dataset) {
|
||||
onDrillBy(column, dataset);
|
||||
onDrillBy(column, dataset, effectiveDrillByConfig);
|
||||
}
|
||||
setPopoverOpen(false);
|
||||
onCloseMenu();
|
||||
},
|
||||
[
|
||||
drillByConfig,
|
||||
effectiveDrillByConfig,
|
||||
onClick,
|
||||
onSelection,
|
||||
openNewModal,
|
||||
@@ -148,9 +200,10 @@ export const DrillBySubmenu = ({
|
||||
ref.current?.input?.focus({ preventScroll: true });
|
||||
}, 100);
|
||||
} else {
|
||||
// Reset search input when menu is closed
|
||||
// Reset search input and filter scope when menu is closed
|
||||
setSearchInput('');
|
||||
setDebouncedSearchInput('');
|
||||
setFilterScope(DrillByFilterScope.All);
|
||||
}
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
@@ -237,6 +290,55 @@ export const DrillBySubmenu = ({
|
||||
`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{showScopeSelector && (
|
||||
<div
|
||||
data-test="drill-by-scope-selector"
|
||||
css={css`
|
||||
margin-bottom: ${theme.sizeUnit * 2}px;
|
||||
padding-bottom: ${theme.sizeUnit * 2}px;
|
||||
border-bottom: 1px solid ${theme.colorSplit};
|
||||
`}
|
||||
>
|
||||
<div
|
||||
css={css`
|
||||
color: ${theme.colorTextSecondary};
|
||||
margin-bottom: ${theme.sizeUnit}px;
|
||||
`}
|
||||
>
|
||||
{t('Filter by')}
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={filterScope}
|
||||
onChange={e => setFilterScope(e.target.value)}
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.ant-radio-wrapper {
|
||||
margin-inline-end: 0;
|
||||
span:last-of-type {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Radio
|
||||
value={DrillByFilterScope.XAxis}
|
||||
title={formatFilterValues(xAxisFilters)}
|
||||
>
|
||||
{formatFilterValues(xAxisFilters)}
|
||||
</Radio>
|
||||
<Radio
|
||||
value={DrillByFilterScope.Series}
|
||||
title={formatFilterValues(seriesFilters)}
|
||||
>
|
||||
{formatFilterValues(seriesFilters)}
|
||||
</Radio>
|
||||
<Radio value={DrillByFilterScope.All}>{t('Both')}</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
{showSearch && (
|
||||
<Input
|
||||
ref={ref}
|
||||
|
||||
@@ -41,27 +41,44 @@ params:
|
||||
filterOptionName: 2745eae5
|
||||
operator: NOT IN
|
||||
subject: country_code
|
||||
compare_lag: '10'
|
||||
compare_suffix: o10Y
|
||||
country_fieldtype: cca3
|
||||
color_scheme: supersetColors
|
||||
entity: country_name
|
||||
granularity_sqla: year
|
||||
groupby: []
|
||||
limit: 0
|
||||
markup_type: markdown
|
||||
legendOrientation: top
|
||||
legendType: scroll
|
||||
max_bubble_size: '50'
|
||||
row_limit: 50000
|
||||
opacity: 0.6
|
||||
order_desc: true
|
||||
row_limit: 500
|
||||
series: region
|
||||
show_bubbles: true
|
||||
since: '2011-01-01'
|
||||
size: sum__SP_POP_TOTL
|
||||
show_legend: true
|
||||
size:
|
||||
aggregate: SUM
|
||||
column:
|
||||
column_name: SP_POP_TOTL
|
||||
expressionType: SIMPLE
|
||||
label: SUM(SP_POP_TOTL)
|
||||
optionName: metric_size_life_expectancy_vs_rural
|
||||
time_range: '2014-01-01 : 2014-01-02'
|
||||
until: '2011-01-02'
|
||||
viz_type: bubble
|
||||
x: sum__SP_RUR_TOTL_ZS
|
||||
y: sum__SP_DYN_LE00_IN
|
||||
tooltipSizeFormat: SMART_NUMBER
|
||||
truncateXAxis: true
|
||||
viz_type: bubble_v2
|
||||
x:
|
||||
aggregate: SUM
|
||||
column:
|
||||
column_name: SP_RUR_TOTL_ZS
|
||||
expressionType: SIMPLE
|
||||
label: SUM(SP_RUR_TOTL_ZS)
|
||||
optionName: metric_x_life_expectancy_vs_rural
|
||||
y:
|
||||
aggregate: SUM
|
||||
column:
|
||||
column_name: SP_DYN_LE00_IN
|
||||
expressionType: SIMPLE
|
||||
label: SUM(SP_DYN_LE00_IN)
|
||||
optionName: metric_y_life_expectancy_vs_rural
|
||||
query_context: null
|
||||
slice_name: Life Expectancy VS Rural %
|
||||
uuid: c18faec9-ec43-4d36-8b66-4c8b1372020f
|
||||
version: 1.0.0
|
||||
viz_type: bubble
|
||||
viz_type: bubble_v2
|
||||
|
||||
Reference in New Issue
Block a user