diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx index 40c71ba3cb6..1cbca5a8649 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx @@ -29,6 +29,7 @@ export const TITLE_POSITION_OPTIONS: [string, string][] = [ ['Left', t('Left')], ['Top', t('Top')], ]; + export const titleControls: ControlPanelSectionConfig = { label: t('Chart Title'), tabOverride: 'customize', diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx index 130478893ef..fc079e98e71 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx @@ -17,7 +17,12 @@ * specific language governing permissions and limitations * under the License. */ -import { QueryColumn, t, validateNonEmpty } from '@superset-ui/core'; +import { + GenericDataType, + QueryColumn, + t, + validateNonEmpty, +} from '@superset-ui/core'; import { ExtraControlProps, SharedControlConfig, @@ -52,6 +57,19 @@ type Control = { * feature flags are set and when they're checked. */ +function filterOptions( + options: (ColumnMeta | QueryColumn)[], + allowedDataTypes?: GenericDataType[], +) { + if (!allowedDataTypes) { + return options; + } + return options.filter( + o => + o.type_generic !== undefined && allowedDataTypes.includes(o.type_generic), + ); +} + export const dndGroupByControl: SharedControlConfig< 'DndColumnSelect' | 'SelectControl', ColumnMeta @@ -81,14 +99,20 @@ export const dndGroupByControl: SharedControlConfig< const newState: ExtraControlProps = {}; const { datasource } = state; if (datasource?.columns[0]?.hasOwnProperty('groupby')) { - const options = (datasource as Dataset).columns.filter(c => c.groupby); + const options = filterOptions( + (datasource as Dataset).columns.filter(c => c.groupby), + controlState?.allowedDataTypes, + ); if (controlState?.includeTime) { options.unshift(DATASET_TIME_COLUMN_OPTION); } newState.options = options; newState.savedMetrics = (datasource as Dataset).metrics || []; } else { - const options = (datasource?.columns as QueryColumn[]) || []; + const options = filterOptions( + (datasource?.columns as QueryColumn[]) || [], + controlState?.allowedDataTypes, + ); if (controlState?.includeTime) { options.unshift(QUERY_TIME_COLUMN_OPTION); } @@ -177,6 +201,19 @@ export const dndAdhocMetricControl: typeof dndAdhocMetricsControl = { ), }; +export const dndTooltipColumnsControl: typeof dndColumnsControl = { + ...dndColumnsControl, + label: t('Tooltip (columns)'), + description: t('Columns to show in the tooltip.'), +}; + +export const dndTooltipMetricsControl: typeof dndAdhocMetricsControl = { + ...dndAdhocMetricsControl, + label: t('Tooltip (metrics)'), + description: t('Metrics to show in the tooltip.'), + validators: [], +}; + export const dndAdhocMetricControl2: typeof dndAdhocMetricControl = { ...dndAdhocMetricControl, label: t('Right Axis Metric'), diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx index 5baa8c688c9..6ceff6a444d 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx @@ -45,6 +45,7 @@ import { isDefined, NO_TIME_RANGE, validateMaxValue, + getColumnLabel, } from '@superset-ui/core'; import { @@ -82,6 +83,8 @@ import { dndSeriesControl, dndAdhocMetricControl2, dndXAxisControl, + dndTooltipColumnsControl, + dndTooltipMetricsControl, } from './dndControls'; const categoricalSchemeRegistry = getCategoricalSchemeRegistry(); @@ -373,6 +376,14 @@ const temporal_columns_lookup: SharedControlConfig<'HiddenControl'> = { ), }; +const zoomable: SharedControlConfig<'CheckboxControl'> = { + type: 'CheckboxControl', + label: t('Data Zoom'), + default: false, + renderTrigger: true, + description: t('Enable data zooming controls'), +}; + const sort_by_metric: SharedControlConfig<'CheckboxControl'> = { type: 'CheckboxControl', label: t('Sort by metric'), @@ -381,6 +392,26 @@ const sort_by_metric: SharedControlConfig<'CheckboxControl'> = { ), }; +const order_by_cols: SharedControlConfig<'SelectControl'> = { + type: 'SelectControl', + label: t('Ordering'), + description: t('Order results by selected columns'), + multi: true, + default: [], + shouldMapStateToProps: () => true, + mapStateToProps: ({ datasource }) => ({ + choices: (datasource?.columns || []) + .map(col => + [true, false].map(asc => [ + JSON.stringify([col.column_name, asc]), + `${getColumnLabel(col.column_name)} [${asc ? 'asc' : 'desc'}]`, + ]), + ) + .flat(), + }), + resetOnHide: false, +}; + export default { metrics: dndAdhocMetricsControl, metric: dndAdhocMetricControl, @@ -392,6 +423,8 @@ export default { secondary_metric: dndSecondaryMetricControl, groupby: dndGroupByControl, columns: dndColumnsControl, + tooltip_columns: dndTooltipColumnsControl, + tooltip_metrics: dndTooltipMetricsControl, granularity, granularity_sqla: dndGranularitySqlaControl, time_grain_sqla, @@ -417,8 +450,10 @@ export default { legacy_order_by: dndSortByControl, truncate_metric, x_axis: dndXAxisControl, + zoomable, show_empty_columns, temporal_columns_lookup, currency_format, sort_by_metric, + order_by_cols, }; diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts index 110d44f8fa2..3d341af70fa 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts @@ -31,6 +31,7 @@ export enum VizType { Compare = 'compare', CountryMap = 'country_map', Funnel = 'funnel', + Gantt = 'gantt_chart', Gauge = 'gauge_chart', Graph = 'graph_chart', Handlebars = 'handlebars', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/EchartsGantt.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/EchartsGantt.tsx new file mode 100644 index 00000000000..adf51b47b88 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/EchartsGantt.tsx @@ -0,0 +1,89 @@ +/** + * 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, useRef, useState } from 'react'; +import { sharedControlComponents } from '@superset-ui/chart-controls'; +import { t } from '@superset-ui/core'; +import Echart from '../components/Echart'; +import { EchartsGanttChartTransformedProps } from './types'; +import { EventHandlers } from '../types'; + +const { RadioButtonControl } = sharedControlComponents; + +export default function EchartsGantt(props: EchartsGanttChartTransformedProps) { + const { + height, + width, + echartOptions, + selectedValues, + refs, + formData, + setControlValue, + onLegendStateChanged, + } = props; + const extraControlRef = useRef(null); + const [extraHeight, setExtraHeight] = useState(0); + + useEffect(() => { + const updatedHeight = extraControlRef.current?.offsetHeight ?? 0; + setExtraHeight(updatedHeight); + }, [formData.showExtraControls]); + + const eventHandlers: EventHandlers = { + legendselectchanged: payload => { + requestAnimationFrame(() => { + onLegendStateChanged?.(payload.selected); + }); + }, + legendselectall: payload => { + requestAnimationFrame(() => { + onLegendStateChanged?.(payload.selected); + }); + }, + legendinverseselect: payload => { + requestAnimationFrame(() => { + onLegendStateChanged?.(payload.selected); + }); + }, + }; + + return ( + <> +
+ {formData.showExtraControls ? ( + setControlValue?.('subcategories', v)} + /> + ) : null} +
+ + + ); +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/buildQuery.ts new file mode 100644 index 00000000000..66153916fe8 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/buildQuery.ts @@ -0,0 +1,61 @@ +/** + * 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 { + QueryFormData, + QueryObject, + buildQueryContext, + ensureIsArray, +} from '@superset-ui/core'; + +export default function buildQuery(formData: QueryFormData) { + const { + start_time, + end_time, + y_axis, + series, + tooltip_columns, + tooltip_metrics, + order_by_cols, + } = formData; + + const groupBy = ensureIsArray(series); + const orderby = ensureIsArray(order_by_cols).map( + expr => JSON.parse(expr) as [string, boolean], + ); + const columns = Array.from( + new Set([ + start_time, + end_time, + y_axis, + ...groupBy, + ...ensureIsArray(tooltip_columns), + ...orderby.map(v => v[0]), + ]), + ); + + return buildQueryContext(formData, (baseQueryObject: QueryObject) => [ + { + ...baseQueryObject, + columns, + metrics: ensureIsArray(tooltip_metrics), + orderby, + series_columns: groupBy, + }, + ]); +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/constants.ts new file mode 100644 index 00000000000..e4023f22201 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/constants.ts @@ -0,0 +1,26 @@ +/** + * 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 const ELEMENT_HEIGHT_SCALE = 0.85 as const; + +export enum Dimension { + StartTime = 'startTime', + EndTime = 'endTime', + Index = 'index', + SeriesCount = 'seriesCount', +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/controlPanel.tsx new file mode 100644 index 00000000000..cdc406f48ff --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/controlPanel.tsx @@ -0,0 +1,136 @@ +/** + * 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 { + ControlPanelConfig, + ControlSubSectionHeader, + sections, + sharedControls, +} from '@superset-ui/chart-controls'; +import { GenericDataType, t } from '@superset-ui/core'; +import { + legendSection, + showExtraControls, + tooltipTimeFormatControl, + tooltipValuesFormatControl, +} from '../controls'; + +const config: ControlPanelConfig = { + controlPanelSections: [ + { + label: t('Query'), + expanded: true, + controlSetRows: [ + [ + { + name: 'start_time', + config: { + ...sharedControls.entity, + label: t('Start Time'), + description: undefined, + allowedDataTypes: [GenericDataType.Temporal], + }, + }, + ], + [ + { + name: 'end_time', + config: { + ...sharedControls.entity, + label: t('End Time'), + description: undefined, + allowedDataTypes: [GenericDataType.Temporal], + }, + }, + ], + [ + { + name: 'y_axis', + config: { + ...sharedControls.x_axis, + label: t('Y-axis'), + description: t('Dimension to use on y-axis.'), + initialValue: () => undefined, + }, + }, + ], + ['series'], + [ + { + name: 'subcategories', + config: { + type: 'CheckboxControl', + label: t('Subcategories'), + description: t( + 'Divides each category into subcategories based on the values in ' + + 'the dimension. It can be used to exclude intersections.', + ), + renderTrigger: true, + default: false, + visibility: ({ controls }) => !!controls?.series?.value, + }, + }, + ], + ['tooltip_metrics'], + ['tooltip_columns'], + ['adhoc_filters'], + ['order_by_cols'], + ['row_limit'], + ], + }, + { + ...sections.titleControls, + controlSetRows: [...sections.titleControls.controlSetRows.slice(0, -1)], + }, + { + label: t('Chart Options'), + expanded: true, + tabOverride: 'customize', + controlSetRows: [ + ['color_scheme'], + ...legendSection, + ['zoomable'], + [showExtraControls], + [{t('X Axis')}], + [ + { + name: 'x_axis_time_bounds', + config: { + type: 'TimeRangeControl', + label: t('Bounds'), + description: t( + 'Bounds for the X-axis. Selected time merges with ' + + 'min/max date of the data. When left empty, bounds ' + + 'dynamically defined based on the min/max of the data.', + ), + renderTrigger: true, + allowClear: true, + allowEmpty: [true, true], + }, + }, + ], + ['x_axis_time_format'], + [{t('Tooltip')}], + [tooltipTimeFormatControl], + [tooltipValuesFormatControl], + ], + }, + ], +}; + +export default config; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/images/example1.png b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/images/example1.png new file mode 100644 index 00000000000..d147bdc8558 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/images/example1.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/images/example2.png b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/images/example2.png new file mode 100644 index 00000000000..a92a82825ba Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/images/example2.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/images/thumbnail.png b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/images/thumbnail.png new file mode 100644 index 00000000000..cc7313ce39b Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/images/thumbnail.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/index.ts new file mode 100644 index 00000000000..a474ceb35cb --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/index.ts @@ -0,0 +1,54 @@ +/** + * 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 { Behavior, t } from '@superset-ui/core'; +import transformProps from './transformProps'; +import controlPanel from './controlPanel'; +import buildQuery from './buildQuery'; +import { EchartsChartPlugin } from '../types'; +import thumbnail from './images/thumbnail.png'; +import example1 from './images/example1.png'; +import example2 from './images/example2.png'; + +export default class EchartsGanttChartPlugin extends EchartsChartPlugin { + constructor() { + super({ + buildQuery, + controlPanel, + loadChart: () => import('./EchartsGantt'), + metadata: { + behaviors: [ + Behavior.InteractiveChart, + Behavior.DrillToDetail, + Behavior.DrillBy, + ], + credits: ['https://echarts.apache.org'], + name: t('Gantt Chart'), + description: t( + 'Gantt chart visualizes important events over a time span. ' + + 'Every data point displayed as a separate event along a ' + + 'horizontal line.', + ), + tags: [t('ECharts'), t('Featured'), t('Timeline'), t('Time')], + thumbnail, + exampleGallery: [{ url: example1 }, { url: example2 }], + }, + transformProps, + }); + } +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/transformProps.ts new file mode 100644 index 00000000000..907f83f441c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/transformProps.ts @@ -0,0 +1,440 @@ +/** + * 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 { + CustomSeriesOption, + CustomSeriesRenderItem, + EChartsCoreOption, + LineSeriesOption, +} from 'echarts'; +import { + AxisType, + CategoricalColorNamespace, + DataRecord, + DataRecordValue, + GenericDataType, + getColumnLabel, + getNumberFormatter, + t, + tooltipHtml, +} from '@superset-ui/core'; +import { CallbackDataParams } from 'echarts/types/src/util/types'; +import dayjs from 'dayjs'; +import { + Cartesian2dCoordSys, + EchartsGanttChartProps, + EchartsGanttFormData, +} from './types'; +import { DEFAULT_FORM_DATA, TIMESERIES_CONSTANTS } from '../constants'; +import { Refs } from '../types'; +import { getLegendProps, groupData } from '../utils/series'; +import { + getTooltipTimeFormatter, + getXAxisFormatter, +} from '../utils/formatters'; +import { defaultGrid } from '../defaults'; +import { getPadding } from '../Timeseries/transformers'; +import { convertInteger } from '../utils/convertInteger'; +import { getTooltipLabels } from '../utils/tooltip'; +import { Dimension, ELEMENT_HEIGHT_SCALE } from './constants'; + +const renderItem: CustomSeriesRenderItem = (params, api) => { + const startX = api.value(Dimension.StartTime); + const endX = api.value(Dimension.EndTime); + const index = Number(api.value(Dimension.Index)); + const seriesCount = Number(api.value(Dimension.SeriesCount)); + + if (Number.isNaN(index)) { + return null; + } + + const startY = seriesCount - 1 - index; + const endY = startY - 1; + + const startCoord = api.coord([startX, startY]); + const endCoord = api.coord([endX, endY]); + + const baseHeight = endCoord[1] - startCoord[1]; + const height = baseHeight * ELEMENT_HEIGHT_SCALE; + + const coordSys = params.coordSys as Cartesian2dCoordSys; + const bounds = [coordSys.x, coordSys.x + coordSys.width]; + + // left bound + startCoord[0] = Math.max(startCoord[0], bounds[0]); + endCoord[0] = Math.max(startCoord[0], endCoord[0]); + // right bound + startCoord[0] = Math.min(startCoord[0], bounds[1]); + endCoord[0] = Math.min(endCoord[0], bounds[1]); + + const width = endCoord[0] - startCoord[0]; + + if (width <= 0 || height <= 0) { + return null; + } + + return { + type: 'rect', + transition: ['shape'], + shape: { + x: startCoord[0], + y: startCoord[1] - height - (baseHeight - height) / 2, + width, + height, + }, + style: api.style(), + }; +}; + +export default function transformProps(chartProps: EchartsGanttChartProps) { + const { + formData, + queriesData, + height, + hooks, + filterState, + width, + theme, + emitCrossFilters, + datasource, + legendState, + } = chartProps; + + const { + startTime, + endTime, + yAxis, + series: dimension, + tooltipMetrics, + tooltipColumns, + xAxisTimeFormat, + tooltipTimeFormat, + tooltipValuesFormat, + colorScheme, + sliceId, + zoomable, + legendMargin, + legendOrientation, + legendType, + showLegend, + yAxisTitle, + yAxisTitleMargin, + xAxisTitle, + xAxisTitleMargin, + xAxisTimeBounds, + subcategories, + }: EchartsGanttFormData = { + ...DEFAULT_FORM_DATA, + ...formData, + }; + + const { setControlValue, onLegendStateChanged } = hooks; + + const { data = [], colnames = [], coltypes = [] } = queriesData[0]; + const refs: Refs = {}; + + const startTimeLabel = getColumnLabel(startTime); + const endTimeLabel = getColumnLabel(endTime); + const yAxisLabel = getColumnLabel(yAxis); + const dimensionLabel = dimension ? getColumnLabel(dimension) : undefined; + const tooltipLabels = getTooltipLabels({ tooltipMetrics, tooltipColumns }); + + const seriesMap = groupData(data, dimensionLabel); + + const seriesInCategoriesMap = new Map< + DataRecordValue | undefined, + Map + >(); + data.forEach(datum => { + const category = datum[yAxisLabel]; + let dimensionValue: DataRecordValue | undefined; + if (dimensionLabel) { + if (legendState && !legendState[String(datum[dimensionLabel])]) { + return; + } + if (subcategories) { + dimensionValue = datum[dimensionLabel]; + } + } + const seriesMap = seriesInCategoriesMap.get(category); + if (seriesMap) { + const dimensionMapValue = seriesMap.get(dimensionValue); + if (dimensionMapValue === undefined) { + seriesMap.set(dimensionValue, seriesMap.size); + } + } else { + seriesInCategoriesMap.set(category, new Map([[dimensionValue, 0]])); + } + }); + + let seriesCount = 0; + const categoryAndSeriesToIndexMap: typeof seriesInCategoriesMap = new Map(); + Array.from(seriesInCategoriesMap.entries()).forEach(([key, map]) => { + categoryAndSeriesToIndexMap.set( + key, + new Map( + Array.from(map.entries()).map(([key2, idx]) => [ + key2, + seriesCount + idx, + ]), + ), + ); + seriesCount += map.size; + }); + + const borderLines: { yAxis: number }[] = []; + const categoryLines: { yAxis: number; name?: string }[] = []; + let sum = 0; + let prevSum = 0; + Array.from(seriesInCategoriesMap.entries()).forEach(([key, map]) => { + sum += map.size; + categoryLines.push({ + yAxis: seriesCount - (sum + prevSum) / 2, + name: key ? String(key) : undefined, + }); + borderLines.push({ yAxis: seriesCount - sum }); + prevSum = sum; + }); + + const xAxisFormatter = getXAxisFormatter(xAxisTimeFormat); + const tooltipTimeFormatter = getTooltipTimeFormatter(tooltipTimeFormat); + const tooltipValuesFormatter = getNumberFormatter(tooltipValuesFormat); + + const bounds: [number | undefined, number | undefined] = [ + undefined, + undefined, + ]; + if (xAxisTimeBounds?.[0]) { + const minDate = Math.min( + ...data.map(datum => Number(datum[startTimeLabel] ?? 0)), + ); + const time = dayjs(xAxisTimeBounds[0], 'HH:mm:ss'); + bounds[0] = +dayjs + .utc(minDate) + .hour(time.hour()) + .minute(time.minute()) + .second(time.second()); + } + if (xAxisTimeBounds?.[1]) { + const maxDate = Math.min( + ...data.map(datum => Number(datum[endTimeLabel] ?? 0)), + ); + const time = dayjs(xAxisTimeBounds[1], 'HH:mm:ss'); + bounds[1] = +dayjs + .utc(maxDate) + .hour(time.hour()) + .minute(time.minute()) + .second(time.second()); + } + + const padding = getPadding( + showLegend && seriesMap.size > 1, + legendOrientation, + false, + zoomable, + legendMargin, + !!xAxisTitle, + 'Left', + convertInteger(yAxisTitleMargin), + convertInteger(xAxisTitleMargin), + ); + + const colorScale = CategoricalColorNamespace.getScale(colorScheme as string); + + const getIndex = (datum: DataRecord) => { + const seriesMap = categoryAndSeriesToIndexMap.get(datum[yAxisLabel]); + const series = + subcategories && dimensionLabel ? datum[dimensionLabel] : undefined; + return seriesMap ? seriesMap.get(series) : undefined; + }; + + const series: (CustomSeriesOption | LineSeriesOption)[] = Array.from( + seriesMap.entries(), + ) + .map(([key, data], idx) => ({ + name: key as string | undefined, + // For some reason items can visually disappear if progressive enabled. + progressive: 0, + itemStyle: { + color: colorScale(String(key), sliceId ?? idx), + }, + type: 'custom' as const, + renderItem, + data: data.map(datum => ({ + value: [ + datum[startTimeLabel], + datum[endTimeLabel], + getIndex(datum), + seriesCount, + ...Object.values(datum), + ], + })), + dimensions: [...Object.values(Dimension), ...colnames], + encode: { + x: [0, 1], + }, + })) + .sort((a, b) => String(a.name).localeCompare(String(b.name))); + + series.push( + { + animation: false, + type: 'line' as const, + markLine: { + silent: true, + symbol: ['none', 'none'], + lineStyle: { + type: 'dashed', + // eslint-disable-next-line theme-colors/no-literal-colors + color: '#dbe0ea', + }, + label: { + show: false, + }, + data: borderLines, + }, + }, + { + animation: false, + type: 'line', + markLine: { + silent: true, + symbol: ['none', 'none'], + lineStyle: { + type: 'solid', + // eslint-disable-next-line theme-colors/no-literal-colors + color: '#00000000', + }, + label: { + show: true, + position: 'start', + formatter: '{b}', + }, + data: categoryLines, + }, + }, + ); + + const tooltipFormatterMap = { + [GenericDataType.Numeric]: tooltipValuesFormatter, + [GenericDataType.String]: undefined, + [GenericDataType.Temporal]: tooltipTimeFormatter, + [GenericDataType.Boolean]: undefined, + }; + + const echartOptions: EChartsCoreOption = { + useUTC: true, + tooltip: { + formatter: (params: CallbackDataParams) => + tooltipHtml( + tooltipLabels.map(label => { + const offset = Object.keys(Dimension).length; + const dimensionNames = params.dimensionNames!.slice(offset); + const data = (params.value as any[]).slice(offset); + + const idx = dimensionNames.findIndex(v => v === label)!; + const value = data[idx]; + const type = coltypes[idx]; + + return [label, tooltipFormatterMap[type]?.(value) ?? value]; + }), + dimensionLabel ? params.seriesName : undefined, + ), + }, + legend: { + ...getLegendProps( + legendType, + legendOrientation, + showLegend, + theme, + zoomable, + legendState, + ), + }, + grid: { + ...defaultGrid, + ...padding, + }, + dataZoom: zoomable && [ + { + type: 'slider', + filterMode: 'none', + start: TIMESERIES_CONSTANTS.dataZoomStart, + end: TIMESERIES_CONSTANTS.dataZoomEnd, + bottom: TIMESERIES_CONSTANTS.zoomBottom, + }, + ], + toolbox: { + show: zoomable, + top: TIMESERIES_CONSTANTS.toolboxTop, + right: TIMESERIES_CONSTANTS.toolboxRight, + feature: { + dataZoom: { + yAxisIndex: false, + title: { + zoom: t('zoom area'), + back: t('restore zoom'), + }, + }, + }, + }, + series, + xAxis: { + name: xAxisTitle, + nameLocation: 'middle', + type: AxisType.Time, + nameGap: convertInteger(xAxisTitleMargin), + axisLabel: { + formatter: xAxisFormatter, + hideOverlap: true, + }, + min: bounds[0], + max: bounds[1], + }, + yAxis: { + name: yAxisTitle, + nameGap: convertInteger(yAxisTitleMargin), + nameLocation: 'middle', + axisLabel: { + show: false, + }, + splitLine: { + show: false, + }, + type: AxisType.Value, + min: 0, + max: seriesCount, + }, + }; + + return { + formData, + queriesData, + echartOptions, + height, + filterState, + width, + theme, + hooks, + emitCrossFilters, + datasource, + refs, + setControlValue, + onLegendStateChanged, + }; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/types.ts new file mode 100644 index 00000000000..1ba593ba107 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/types.ts @@ -0,0 +1,71 @@ +/** + * 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 { + ChartDataResponseResult, + ChartProps, + QueryFormColumn, + QueryFormData, + QueryFormMetric, +} from '@superset-ui/core'; +import { + BaseTransformedProps, + CrossFilterTransformedProps, + LegendFormData, +} from '../types'; + +export type EchartsGanttChartTransformedProps = + BaseTransformedProps & CrossFilterTransformedProps; + +export type EchartsGanttFormData = QueryFormData & + LegendFormData & { + viz_type: 'gantt_chart'; + startTime: QueryFormColumn; + endTime: QueryFormColumn; + yAxis: QueryFormColumn; + tooltipMetrics: QueryFormMetric[]; + tooltipColumns: QueryFormColumn[]; + series?: QueryFormColumn; + xAxisTimeFormat?: string; + tooltipTimeFormat?: string; + tooltipValuesFormat?: string; + colorScheme?: string; + zoomable?: boolean; + xAxisTitle?: string; + xAxisTitleMargin?: number; + yAxisTitle?: string; + yAxisTitleMargin?: number; + yAxisTitlePosition?: string; + xAxisTimeBounds?: [string | null, string | null]; + subcategories?: boolean; + showExtraControls?: boolean; + }; + +export interface EchartsGanttChartProps + extends ChartProps { + formData: EchartsGanttFormData; + queriesData: ChartDataResponseResult[]; +} + +export interface Cartesian2dCoordSys { + type: 'cartesian2d'; + x: number; + y: number; + width: number; + height: number; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx index c45d85d92c6..6bc7643c84f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx @@ -58,7 +58,6 @@ const { stack, truncateYAxis, yAxisBounds, - zoomable, yAxisIndex, } = DEFAULT_FORM_DATA; @@ -352,18 +351,7 @@ const config: ControlPanelConfig = { ['time_shift_color'], ...createCustomizeSection(t('Query A'), ''), ...createCustomizeSection(t('Query B'), 'B'), - [ - { - name: 'zoomable', - config: { - type: 'CheckboxControl', - label: t('Data Zoom'), - default: zoomable, - renderTrigger: true, - description: t('Enable data zooming controls'), - }, - }, - ], + ['zoomable'], [minorTicks], ...legendSection, [{t('X Axis')}], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx index aafdc860371..f3e40cb1dc7 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx @@ -54,7 +54,6 @@ const { seriesType, truncateYAxis, yAxisBounds, - zoomable, } = DEFAULT_FORM_DATA; const config: ControlPanelConfig = { controlPanelSections: [ @@ -171,18 +170,7 @@ const config: ControlPanelConfig = { }, ], [minorTicks], - [ - { - name: 'zoomable', - config: { - type: 'CheckboxControl', - label: t('Data Zoom'), - default: zoomable, - renderTrigger: true, - description: t('Enable data zooming controls'), - }, - }, - ], + ['zoomable'], ...legendSection, [{t('X Axis')}], [ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx index 39080b651c8..b83844abfe0 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx @@ -48,14 +48,8 @@ import { } from '../../constants'; import { StackControlsValue } from '../../../constants'; -const { - logAxis, - minorSplitLine, - truncateYAxis, - yAxisBounds, - zoomable, - orientation, -} = DEFAULT_FORM_DATA; +const { logAxis, minorSplitLine, truncateYAxis, yAxisBounds, orientation } = + DEFAULT_FORM_DATA; function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] { const isXAxis = axis === 'x'; @@ -363,18 +357,7 @@ const config: ControlPanelConfig = { }, ], [minorTicks], - [ - { - name: 'zoomable', - config: { - type: 'CheckboxControl', - label: t('Data Zoom'), - default: zoomable, - renderTrigger: true, - description: t('Enable data zooming controls'), - }, - }, - ], + ['zoomable'], ...legendSection, [{t('X Axis')}], ...createAxisControl('x'), diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx index 847b1645a3f..2553fe48d39 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx @@ -55,7 +55,6 @@ const { seriesType, truncateYAxis, yAxisBounds, - zoomable, } = DEFAULT_FORM_DATA; const config: ControlPanelConfig = { controlPanelSections: [ @@ -158,18 +157,7 @@ const config: ControlPanelConfig = { }, }, ], - [ - { - name: 'zoomable', - config: { - type: 'CheckboxControl', - label: t('Data Zoom'), - default: zoomable, - renderTrigger: true, - description: t('Enable data zooming controls'), - }, - }, - ], + ['zoomable'], [minorTicks], ...legendSection, [{t('X Axis')}], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx index 6991196cdf0..b9bca7ca5c5 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx @@ -51,7 +51,6 @@ const { rowLimit, truncateYAxis, yAxisBounds, - zoomable, } = DEFAULT_FORM_DATA; const config: ControlPanelConfig = { controlPanelSections: [ @@ -100,18 +99,7 @@ const config: ControlPanelConfig = { }, }, ], - [ - { - name: 'zoomable', - config: { - type: 'CheckboxControl', - label: t('Data Zoom'), - default: zoomable, - renderTrigger: true, - description: t('Enable data zooming controls'), - }, - }, - ], + ['zoomable'], [minorTicks], ...legendSection, [{t('X Axis')}], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx index 676b7278873..d25fbbd5669 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx @@ -51,7 +51,6 @@ const { rowLimit, truncateYAxis, yAxisBounds, - zoomable, } = DEFAULT_FORM_DATA; const config: ControlPanelConfig = { controlPanelSections: [ @@ -100,18 +99,7 @@ const config: ControlPanelConfig = { }, }, ], - [ - { - name: 'zoomable', - config: { - type: 'CheckboxControl', - label: t('Data Zoom'), - default: zoomable, - renderTrigger: true, - description: t('Enable data zooming controls'), - }, - }, - ], + ['zoomable'], [minorTicks], ...legendSection, [{t('X Axis')}], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx index 6146db2572b..a59ec06b34d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx @@ -51,7 +51,6 @@ const { rowLimit, truncateYAxis, yAxisBounds, - zoomable, } = DEFAULT_FORM_DATA; const config: ControlPanelConfig = { controlPanelSections: [ @@ -152,18 +151,7 @@ const config: ControlPanelConfig = { }, }, ], - [ - { - name: 'zoomable', - config: { - type: 'CheckboxControl', - label: t('Data Zoom'), - default: zoomable, - renderTrigger: true, - description: t('Enable data zooming controls'), - }, - }, - ], + ['zoomable'], [minorTicks], ...legendSection, [{t('X Axis')}], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx index 8af1aa061fb..e05975ac497 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx @@ -48,10 +48,12 @@ import { TreemapChart, HeatmapChart, SunburstChart, + CustomChart, } from 'echarts/charts'; import { CanvasRenderer } from 'echarts/renderers'; import { TooltipComponent, + TitleComponent, GridComponent, VisualMapComponent, LegendComponent, @@ -83,6 +85,7 @@ use([ CanvasRenderer, BarChart, BoxplotChart, + CustomChart, FunnelChart, GaugeChart, GraphChart, @@ -104,6 +107,7 @@ use([ LegendComponent, ToolboxComponent, TooltipComponent, + TitleComponent, VisualMapComponent, LabelLayout, ]); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx index b6055d8688e..088d5b7ba06 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx @@ -22,6 +22,7 @@ import { ControlSetItem, ControlSetRow, ControlSubSectionHeader, + CustomControlItem, DEFAULT_SORT_SERIES_DATA, SORT_SERIES_CHOICES, sharedControls, @@ -185,7 +186,7 @@ const richTooltipControl: ControlSetItem = { }, }; -const tooltipTimeFormatControl: ControlSetItem = { +export const tooltipTimeFormatControl: ControlSetItem = { name: 'tooltipTimeFormat', config: { ...sharedControls.x_axis_time_format, @@ -195,6 +196,15 @@ const tooltipTimeFormatControl: ControlSetItem = { }, }; +export const tooltipValuesFormatControl: CustomControlItem = { + name: 'tooltipValuesFormat', + config: { + ...sharedControls.y_axis_format, + label: t('Number format'), + clearable: false, + }, +}; + const tooltipSortByMetricControl: ControlSetItem = { name: 'tooltipSortByMetric', config: { @@ -367,3 +377,13 @@ export const forceCategorical: ControlSetItem = { description: t('Make the x-axis categorical'), }, }; + +export const showExtraControls: CustomControlItem = { + name: 'show_extra_controls', + config: { + type: 'CheckboxControl', + label: t('Extra Controls'), + renderTrigger: true, + default: false, + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts index 5c3c3e4c7f1..14fcb1eb450 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts @@ -43,6 +43,7 @@ export { default as EchartsSunburstChartPlugin } from './Sunburst'; export { default as EchartsBubbleChartPlugin } from './Bubble'; export { default as EchartsSankeyChartPlugin } from './Sankey'; export { default as EchartsWaterfallChartPlugin } from './Waterfall'; +export { default as EchartsGanttChartPlugin } from './Gantt'; export { default as BoxPlotTransformProps } from './BoxPlot/transformProps'; export { default as FunnelTransformProps } from './Funnel/transformProps'; @@ -60,6 +61,7 @@ export { default as BubbleTransformProps } from './Bubble/transformProps'; export { default as WaterfallTransformProps } from './Waterfall/transformProps'; export { default as HistogramTransformProps } from './Histogram/transformProps'; export { default as SankeyTransformProps } from './Sankey/transformProps'; +export { default as GanttTransformProps } from './Gantt/transformProps'; export { DEFAULT_FORM_DATA as TimeseriesDefaultFormData } from './Timeseries/constants'; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts index 3015010d05f..4e3a1bd74e2 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -685,3 +685,20 @@ export function extractTooltipKeys( } return [forecastValue[0][TOOLTIP_SERIES_KEY]]; } + +export function groupData(data: DataRecord[], by?: string | null) { + const seriesMap: Map = new Map(); + if (by) { + data.forEach(datum => { + const value = seriesMap.get(datum[by]); + if (value) { + value.push(datum); + } else { + seriesMap.set(datum[by], [datum]); + } + }); + } else { + seriesMap.set(undefined, data); + } + return seriesMap; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/tooltip.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/tooltip.ts index e630f1142ff..f3de48b16e9 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/tooltip.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/tooltip.ts @@ -18,6 +18,12 @@ */ import type { CallbackDataParams } from 'echarts/types/src/util/types'; +import { + QueryFormColumn, + QueryFormMetric, + getColumnLabel, + getMetricLabel, +} from '@superset-ui/core'; import { TOOLTIP_OVERFLOW_MARGIN, TOOLTIP_POINTER_MARGIN } from '../constants'; import { Refs } from '../types'; @@ -80,3 +86,16 @@ export function getDefaultTooltip(refs: Refs) { }, }; } + +export function getTooltipLabels({ + tooltipMetrics, + tooltipColumns, +}: { + tooltipMetrics?: QueryFormMetric[]; + tooltipColumns?: QueryFormColumn[]; +}) { + return [ + ...(tooltipMetrics ?? []).map(v => getMetricLabel(v)), + ...(tooltipColumns ?? []).map(v => getColumnLabel(v)), + ]; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Gantt/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Gantt/buildQuery.test.ts new file mode 100644 index 00000000000..cae9de79bb8 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Gantt/buildQuery.test.ts @@ -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 { QueryFormData } from '@superset-ui/core'; +import buildQuery from '../../src/Gantt/buildQuery'; + +describe('Gantt buildQuery', () => { + const formData: QueryFormData = { + datasource: '1__table', + viz_type: 'gantt_chart', + start_time: 'start_time', + end_time: 'end_time', + y_axis: { + label: 'Y Axis', + sqlExpression: 'SELECT 1', + expressionType: 'SQL', + }, + series: 'series', + tooltip_metrics: ['tooltip_metric'], + tooltip_columns: ['tooltip_column'], + order_by_cols: [ + JSON.stringify(['start_time', true]), + JSON.stringify(['order_col', false]), + ], + }; + + it('should build query', () => { + const queryContext = buildQuery(formData); + const [query] = queryContext.queries; + expect(query.metrics).toStrictEqual(['tooltip_metric']); + expect(query.columns).toStrictEqual([ + 'start_time', + 'end_time', + { + label: 'Y Axis', + sqlExpression: 'SELECT 1', + expressionType: 'SQL', + }, + 'series', + 'tooltip_column', + 'order_col', + ]); + expect(query.series_columns).toStrictEqual(['series']); + expect(query.orderby).toStrictEqual([ + ['start_time', true], + ['order_col', false], + ]); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Gantt/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Gantt/transformProps.test.ts new file mode 100644 index 00000000000..47f929f15a2 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Gantt/transformProps.test.ts @@ -0,0 +1,269 @@ +/** + * 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 { AxisType, ChartProps, supersetTheme } from '@superset-ui/core'; +import { + LegendOrientation, + LegendType, +} from '@superset-ui/plugin-chart-echarts'; +import transformProps from '../../src/Gantt/transformProps'; +import { + EchartsGanttChartProps, + EchartsGanttFormData, +} from '../../src/Gantt/types'; + +describe('Gantt transformProps', () => { + const formData: EchartsGanttFormData = { + viz_type: 'gantt_chart', + datasource: '1__table', + + startTime: 'startTime', + endTime: 'endTime', + yAxis: { + label: 'Y Axis', + sqlExpression: 'y_axis', + expressionType: 'SQL', + }, + tooltipMetrics: ['tooltip_metric'], + tooltipColumns: ['tooltip_column'], + series: 'series', + xAxisTimeFormat: '%H:%M', + tooltipTimeFormat: '%H:%M', + tooltipValuesFormat: 'DURATION_SEC', + colorScheme: 'bnbColors', + zoomable: true, + xAxisTitleMargin: undefined, + yAxisTitleMargin: undefined, + xAxisTimeBounds: [null, '19:00:00'], + subcategories: true, + legendMargin: 0, + legendOrientation: LegendOrientation.Top, + legendType: LegendType.Scroll, + showLegend: true, + sortSeriesAscending: true, + }; + const queriesData = [ + { + data: [ + { + startTime: Date.UTC(2025, 1, 1, 13, 0, 0), + endTime: Date.UTC(2025, 1, 1, 14, 0, 0), + 'Y Axis': 'first', + tooltip_column: 'tooltip value 1', + series: 'series value 1', + }, + { + startTime: Date.UTC(2025, 1, 1, 18, 0, 0), + endTime: Date.UTC(2025, 1, 1, 20, 0, 0), + 'Y Axis': 'second', + tooltip_column: 'tooltip value 2', + series: 'series value 2', + }, + ], + colnames: ['startTime', 'endTime', 'Y Axis', 'tooltip_column', 'series'], + }, + ]; + const chartPropsConfig = { + formData, + queriesData, + theme: supersetTheme, + }; + + it('should transform chart props', () => { + const chartProps = new ChartProps(chartPropsConfig); + const transformedProps = transformProps( + chartProps as EchartsGanttChartProps, + ); + + expect(transformedProps.echartOptions.series).toHaveLength(4); + const series = transformedProps.echartOptions.series as any[]; + const series0 = series[0]; + const series1 = series[1]; + + // exclude renderItem because it can't be serialized + expect(typeof series0.renderItem).toBe('function'); + delete series0.renderItem; + expect(typeof series1.renderItem).toBe('function'); + delete series1.renderItem; + delete transformedProps.echartOptions.series; + + expect(transformedProps).toEqual( + expect.objectContaining({ + echartOptions: expect.objectContaining({ + useUTC: true, + xAxis: { + name: '', + nameGap: 0, + nameLocation: 'middle', + max: Date.UTC(2025, 1, 1, 19, 0, 0), + min: undefined, + type: AxisType.Time, + axisLabel: { + hideOverlap: true, + formatter: expect.anything(), + }, + }, + yAxis: { + name: '', + nameGap: 0, + nameLocation: 'middle', + type: AxisType.Value, + // always 0 + min: 0, + // equals unique categories count + max: 2, + axisLabel: { + show: false, + }, + splitLine: { + show: false, + }, + }, + legend: expect.objectContaining({ + show: true, + type: 'scroll', + selector: ['all', 'inverse'], + }), + tooltip: { + formatter: expect.anything(), + }, + dataZoom: [ + expect.objectContaining({ + type: 'slider', + filterMode: 'none', + }), + ], + }), + }), + ); + + expect(series0).toEqual({ + name: 'series value 1', + type: 'custom', + progressive: 0, + itemStyle: { + color: expect.anything(), + }, + data: [ + { + value: [ + Date.UTC(2025, 1, 1, 13, 0, 0), + Date.UTC(2025, 1, 1, 14, 0, 0), + 0, + 2, + Date.UTC(2025, 1, 1, 13, 0, 0), + Date.UTC(2025, 1, 1, 14, 0, 0), + 'first', + 'tooltip value 1', + 'series value 1', + ], + }, + ], + dimensions: [ + 'startTime', + 'endTime', + 'index', + 'seriesCount', + 'startTime', + 'endTime', + 'Y Axis', + 'tooltip_column', + 'series', + ], + encode: { + x: [0, 1], + }, + }); + + expect(series1).toEqual({ + name: 'series value 2', + type: 'custom', + progressive: 0, + itemStyle: { + color: expect.anything(), + }, + data: [ + { + value: [ + Date.UTC(2025, 1, 1, 18, 0, 0), + Date.UTC(2025, 1, 1, 20, 0, 0), + 1, + 2, + Date.UTC(2025, 1, 1, 18, 0, 0), + Date.UTC(2025, 1, 1, 20, 0, 0), + 'second', + 'tooltip value 2', + 'series value 2', + ], + }, + ], + dimensions: [ + 'startTime', + 'endTime', + 'index', + 'seriesCount', + 'startTime', + 'endTime', + 'Y Axis', + 'tooltip_column', + 'series', + ], + encode: { + x: [0, 1], + }, + }); + expect(series[2]).toEqual({ + // just for markLines + type: 'line', + animation: false, + markLine: { + data: [{ yAxis: 1 }, { yAxis: 0 }], + label: { + show: false, + }, + silent: true, + symbol: ['none', 'none'], + lineStyle: { + type: 'dashed', + color: '#dbe0ea', + }, + }, + }); + expect(series[3]).toEqual({ + type: 'line', + animation: false, + markLine: { + data: [ + { yAxis: 1.5, name: 'first' }, + { yAxis: 0.5, name: 'second' }, + ], + label: { + show: true, + position: 'start', + formatter: '{b}', + }, + lineStyle: expect.objectContaining({ + color: '#00000000', + type: 'solid', + }), + silent: true, + symbol: ['none', 'none'], + }, + }); + }); +}); diff --git a/superset-frontend/src/components/Chart/ChartRenderer.jsx b/superset-frontend/src/components/Chart/ChartRenderer.jsx index 2ed3eded179..5fd83affa14 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.jsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.jsx @@ -164,6 +164,8 @@ class ChartRenderer extends Component { nextProps.labelsColorMap !== this.props.labelsColorMap || nextProps.formData.color_scheme !== this.props.formData.color_scheme || nextProps.formData.stack !== this.props.formData.stack || + nextProps.formData.subcategories !== + this.props.formData.subcategories || nextProps.cacheBusterProp !== this.props.cacheBusterProp || nextProps.emitCrossFilters !== this.props.emitCrossFilters ); diff --git a/superset-frontend/src/components/TimePicker/index.tsx b/superset-frontend/src/components/TimePicker/index.tsx new file mode 100644 index 00000000000..811a5442be9 --- /dev/null +++ b/superset-frontend/src/components/TimePicker/index.tsx @@ -0,0 +1,33 @@ +/** + * 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 { + TimePicker as AntdTimePicker, + TimePickerProps, + TimeRangePickerProps, +} from 'antd'; + +const commonCss = { width: '100%' }; + +export const TimePicker = (props: TimePickerProps) => ( + +); + +export const TimeRangePicker = (props: TimeRangePickerProps) => ( + +); diff --git a/superset-frontend/src/explore/components/controls/TimeRangeControl/index.tsx b/superset-frontend/src/explore/components/controls/TimeRangeControl/index.tsx new file mode 100644 index 00000000000..8956d3d4dc2 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/TimeRangeControl/index.tsx @@ -0,0 +1,58 @@ +/** + * 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 dayjs from 'dayjs'; +import { TimeRangePicker } from 'src/components/TimePicker'; +import ControlHeader, { ControlHeaderProps } from '../../ControlHeader'; + +type TimeRangeValueType = [string, string]; + +export interface TimeRangeControlProps extends ControlHeaderProps { + value?: TimeRangeValueType; + onChange?: (value: TimeRangeValueType, errors: any) => void; + allowClear?: boolean; + showNow?: boolean; + allowEmpty?: [boolean, boolean]; +} + +export default function TimeRangeControl({ + value: stringValue, + onChange, + allowClear, + showNow, + allowEmpty, + ...rest +}: TimeRangeControlProps) { + const dayjsValue: [dayjs.Dayjs | null, dayjs.Dayjs | null] = [ + stringValue?.[0] ? dayjs.utc(stringValue[0], 'HH:mm:ss') : null, + stringValue?.[1] ? dayjs.utc(stringValue[1], 'HH:mm:ss') : null, + ]; + + return ( +
+ + onChange?.(stringValue, null)} + allowClear={allowClear} + showNow={showNow} + allowEmpty={allowEmpty} + /> +
+ ); +} diff --git a/superset-frontend/src/explore/components/controls/index.js b/superset-frontend/src/explore/components/controls/index.js index 52a35fbadb2..a36a73ab2a2 100644 --- a/superset-frontend/src/explore/components/controls/index.js +++ b/superset-frontend/src/explore/components/controls/index.js @@ -55,6 +55,7 @@ import LayerConfigsControl from './LayerConfigsControl/LayerConfigsControl'; import MapViewControl from './MapViewControl/MapViewControl'; import ZoomConfigControl from './ZoomConfigControl/ZoomConfigControl'; import NumberControl from './NumberControl'; +import TimeRangeControl from './TimeRangeControl'; const extensionsRegistry = getExtensionsRegistry(); const DateFilterControlExtension = extensionsRegistry.get( @@ -99,6 +100,7 @@ const controlMap = { TimeOffsetControl, ZoomConfigControl, NumberControl, + TimeRangeControl, ...sharedControlComponents, }; export default controlMap; diff --git a/superset-frontend/src/visualizations/presets/MainPreset.js b/superset-frontend/src/visualizations/presets/MainPreset.js index b79de8f2623..387570a3262 100644 --- a/superset-frontend/src/visualizations/presets/MainPreset.js +++ b/superset-frontend/src/visualizations/presets/MainPreset.js @@ -68,6 +68,7 @@ import { EchartsWaterfallChartPlugin, BigNumberPeriodOverPeriodChartPlugin, EchartsHeatmapChartPlugin, + EchartsGanttChartPlugin, } from '@superset-ui/plugin-chart-echarts'; import { SelectFilterPlugin, @@ -111,6 +112,7 @@ export default class MainPreset extends Preset { new EchartsFunnelChartPlugin().configure({ key: VizType.Funnel }), new EchartsSankeyChartPlugin().configure({ key: VizType.Sankey }), new EchartsTreemapChartPlugin().configure({ key: VizType.Treemap }), + new EchartsGanttChartPlugin().configure({ key: VizType.Gantt }), new EchartsGaugeChartPlugin().configure({ key: VizType.Gauge }), new EchartsGraphChartPlugin().configure({ key: VizType.Graph }), new EchartsRadarChartPlugin().configure({ key: VizType.Radar }), diff --git a/superset/examples/configs/charts/Featured Charts/Gantt.yaml b/superset/examples/configs/charts/Featured Charts/Gantt.yaml new file mode 100644 index 00000000000..6a564d07a08 --- /dev/null +++ b/superset/examples/configs/charts/Featured Charts/Gantt.yaml @@ -0,0 +1,65 @@ +# 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. +slice_name: Gantt +description: null +certified_by: null +certification_details: null +viz_type: gantt_chart +params: + datasource: 61__table + viz_type: gantt_chart + slice_id: 1495 + start_time: start_time + end_time: end_time + y_axis: status + series: priority + subcategories: true + tooltip_columns: + - project + - phase + adhoc_filters: + - clause: WHERE + comparator: No filter + expressionType: SIMPLE + operator: TEMPORAL_RANGE + subject: start_time + order_by_cols: + - '["status",false]' + row_limit: 10000 + x_axis_title_margin: 15 + y_axis_title_margin: 50 + color_scheme: supersetAndPresetColors + show_legend: true + legendType: plain + legendOrientation: right + legendMargin: 100 + zoomable: false + show_extra_controls: false + x_axis_time_bounds: + - '08:00:00' + - '19:00:00' + x_axis_time_format: smart_date + tooltipTimeFormat: smart_date + tooltipValuesFormat: SMART_NUMBER + extra_form_data: {} + dashboards: + - 9 +query_context: null +cache_timeout: null +uuid: c91c242e-ec16-43e4-84fd-1c69336e0a99 +version: 1.0.0 +dataset_uuid: d638a239-f255-44fc-b0c1-c3f3b7f00ee0 diff --git a/superset/examples/configs/dashboards/Featured_Charts.yaml b/superset/examples/configs/dashboards/Featured_Charts.yaml index 20155692bd4..a0e3fe9d3f4 100644 --- a/superset/examples/configs/dashboards/Featured_Charts.yaml +++ b/superset/examples/configs/dashboards/Featured_Charts.yaml @@ -135,6 +135,20 @@ position: - GRID_ID - ROW-cUv-aKn4Yt type: CHART + CHART-Df3UIo8Y9s: + children: [] + id: CHART-Df3UIo8Y9s + meta: + chartId: 1495 + height: 50 + sliceName: Gantt + uuid: c91c242e-ec16-43e4-84fd-1c69336e0a99 + width: 4 + parents: + - ROOT_ID + - GRID_ID + - ROW-W7YILGiS0- + type: CHART CHART-DqaJJ8Fse6: children: [] id: CHART-DqaJJ8Fse6 @@ -370,6 +384,7 @@ position: - ROW-Jq9auQfs6- - ROW-3XARWMYOfz - ROW-ux6j1ePT8I + - ROW-FHBKXbZT5Z id: GRID_ID parents: - ROOT_ID @@ -386,9 +401,9 @@ position: type: ROOT ROW-3XARWMYOfz: children: + - CHART-Yi0u5d9otw - CHART-33vjmwrGX1 - CHART-3tEC_8e-uS - - CHART-A4qrvR24Ne id: ROW-3XARWMYOfz meta: background: BACKGROUND_TRANSPARENT @@ -396,11 +411,21 @@ position: - ROOT_ID - GRID_ID type: ROW + ROW-FHBKXbZT5Z: + children: + - CHART-KE7lk61Tbt + id: ROW-FHBKXbZT5Z + meta: + background: BACKGROUND_TRANSPARENT + parents: + - ROOT_ID + - GRID_ID + type: ROW ROW-Jq9auQfs6-: children: + - CHART-jzyy9Sa3pS - CHART-qZh51tuuRH - CHART-j2o9aZo4HY - - CHART-Yi0u5d9otw id: ROW-Jq9auQfs6- meta: background: BACKGROUND_TRANSPARENT @@ -434,9 +459,9 @@ position: type: ROW ROW-UxgGmS9gb3: children: + - CHART-EpsTnvUMuW - CHART-4Zm6Q1VGY5 - CHART-AFzv0kyWG_ - - CHART-jzyy9Sa3pS id: ROW-UxgGmS9gb3 meta: background: BACKGROUND_TRANSPARENT @@ -447,8 +472,8 @@ position: ROW-W7YILGiS0-: children: - CHART-gfrGP3BD76 + - CHART-Df3UIo8Y9s - CHART-jC2_mEgWeL - - CHART-SjTqfJNmup id: ROW-W7YILGiS0- meta: background: BACKGROUND_TRANSPARENT @@ -458,9 +483,9 @@ position: type: ROW ROW-cUv-aKn4Yt: children: + - CHART-SjTqfJNmup - CHART-CR0-igYucm - CHART-t5t_tQe43g - - CHART-EpsTnvUMuW id: ROW-cUv-aKn4Yt meta: background: BACKGROUND_TRANSPARENT @@ -470,9 +495,9 @@ position: type: ROW ROW-ux6j1ePT8I: children: + - CHART-A4qrvR24Ne - CHART-DqaJJ8Fse6 - CHART-XwFZukVv8E - - CHART-KE7lk61Tbt id: ROW-ux6j1ePT8I meta: background: BACKGROUND_TRANSPARENT diff --git a/superset/examples/configs/datasets/examples/project_management.yaml b/superset/examples/configs/datasets/examples/project_management.yaml new file mode 100644 index 00000000000..f5bb8f4a407 --- /dev/null +++ b/superset/examples/configs/datasets/examples/project_management.yaml @@ -0,0 +1,293 @@ +# 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. +table_name: project_management +main_dttm_col: start_time +description: null +default_endpoint: null +offset: 0 +cache_timeout: null +catalog: examples +schema: public +sql: |- + SELECT + 1718870400000 AS start_time, + 1718874000000 AS end_time, + 'Project Alpha' AS project, + 'Design Phase' AS phase, + 'Initial design and architecture planning for Alpha.' AS description, + 'Completed' AS status, + 'High' AS priority + UNION ALL + SELECT + 1718872800000, + 1718877200000, + 'Project Alpha', + 'Development Phase', + 'Core feature development for Alpha project.', + 'In Progress', + 'High' + UNION ALL + SELECT + 1718876400000, + 1718880000000, + 'Project Alpha', + 'Testing Phase', + 'Internal testing and bug fixing for Alpha features.', + 'Planned', + 'Medium' + UNION ALL + SELECT + 1718878800000, + 1718882400000, + 'Project Alpha', + 'Deployment Phase', + 'Preparation and execution of Alpha deployment.', + 'On Hold', + 'High' + UNION ALL + SELECT + 1718880000000, + 1718883600000, + 'Project Beta', + 'Design Phase', + 'Gathering requirements and conceptual design for Beta.', + 'Completed', + 'Medium' + UNION ALL + SELECT + 1718882400000, + 1718886000000, + 'Project Beta', + 'Development Phase', + 'Module-wise development for Beta project.', + 'In Progress', + 'Medium' + UNION ALL + SELECT + 1718884800000, + 1718888400000, + 'Project Beta', + 'Testing Phase', + 'User acceptance testing for Beta release.', + 'Planned', + 'High' + UNION ALL + SELECT + 1718887200000, + 1718890800000, + 'Project Beta', + 'Deployment Phase', + 'Final checks and release of Beta version.', + 'Planned', + 'Medium' + UNION ALL + SELECT + 1718889600000, + 1718893200000, + 'Project Gamma', + 'Design Phase', + 'System design and database schema for Gamma.', + 'Completed', + 'Low' + UNION ALL + SELECT + 1718892000000, + 1718895600000, + 'Project Gamma', + 'Development Phase', + 'Backend API and frontend integration for Gamma.', + 'In Progress', + 'High' + UNION ALL + SELECT + 1718894400000, + 1718898000000, + 'Project Gamma', + 'Testing Phase', + 'Automated test suite execution for Gamma.', + 'Planned', + 'Medium' + UNION ALL + SELECT + 1718896800000, + 1718900400000, + 'Project Gamma', + 'Deployment Phase', + 'Handover and post-deployment support for Gamma.', + 'Planned', + 'Low' + UNION ALL + SELECT + 1718900000000, + 1718904000000, + 'Project Alpha', + 'Risk Assessment', + 'Analyzing potential risks and mitigation strategies.', + 'Completed', + 'High' + UNION ALL + SELECT + 1718902000000, + 1718906000000, + 'Project Beta', + 'Client Review', + 'Review meeting with key stakeholders for Beta.', + 'In Progress', + 'High' + UNION ALL + SELECT + 1718904000000, + 1718908000000, + 'Project Gamma', + 'Documentation', + 'Creating technical and user documentation.', + 'Planned', + 'Low' + UNION ALL + SELECT + 1718906000000, + 1718910000000, + 'Project Alpha', + 'Feature Implementation', + 'Implementing new requested features for Alpha.', + 'In Progress', + 'High' + UNION ALL + SELECT + 1718908000000, + 1718912000000, + 'Project Beta', + 'User Acceptance Testing', + 'Final UAT before production release.', + 'Planned', + 'High' + UNION ALL + SELECT + 1718910000000, + 1718914000000, + 'Project Gamma', + 'Bug Fixing', + 'Addressing critical bugs reported post-release.', + 'In Progress', + 'Medium'; +params: null +template_params: null +filter_select_enabled: true +fetch_values_predicate: null +extra: null +normalize_columns: false +always_filter_main_dttm: false +folders: null +uuid: d638a239-f255-44fc-b0c1-c3f3b7f00ee0 +metrics: +- metric_name: count + verbose_name: COUNT(*) + metric_type: count + expression: COUNT(*) + description: null + d3format: null + currency: null + extra: + warning_markdown: '' + warning_text: null +columns: +- column_name: start_time + verbose_name: null + is_dttm: true + is_active: true + type: LONGINTEGER + advanced_data_type: null + groupby: true + filterable: true + expression: null + description: null + python_date_format: null + extra: {} +- column_name: end_time + verbose_name: null + is_dttm: true + is_active: true + type: LONGINTEGER + advanced_data_type: null + groupby: true + filterable: true + expression: null + description: null + python_date_format: null + extra: {} +- column_name: phase + verbose_name: null + is_dttm: false + is_active: true + type: STRING + advanced_data_type: null + groupby: true + filterable: true + expression: null + description: null + python_date_format: null + extra: {} +- column_name: status + verbose_name: null + is_dttm: false + is_active: true + type: STRING + advanced_data_type: null + groupby: true + filterable: true + expression: null + description: null + python_date_format: null + extra: {} +- column_name: description + verbose_name: null + is_dttm: false + is_active: true + type: STRING + advanced_data_type: null + groupby: true + filterable: true + expression: null + description: null + python_date_format: null + extra: {} +- column_name: project + verbose_name: null + is_dttm: false + is_active: true + type: STRING + advanced_data_type: null + groupby: true + filterable: true + expression: null + description: null + python_date_format: null + extra: {} +- column_name: priority + verbose_name: null + is_dttm: false + is_active: true + type: STRING + advanced_data_type: null + groupby: true + filterable: true + expression: null + description: null + python_date_format: null + extra: {} +version: 1.0.0 +database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee