diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts b/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts index c6a308393e8..cee2dab5b51 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts @@ -124,10 +124,13 @@ describe('VizType control', () => { }); it('Can change vizType', () => { - cy.visitChartByName('Daily Totals'); + cy.visitChartByName('Daily Totals').then(() => { + cy.get('.slice_container').should('be.visible'); + }); + cy.verifySliceSuccess({ waitAlias: '@tableChartData' }); - cy.contains('View all charts').click(); + cy.contains('View all charts').should('be.visible').click(); cy.get('.ant-modal-content').within(() => { cy.get('button').contains('KPI').click(); // change categories @@ -176,8 +179,12 @@ describe('Groupby control', () => { .contains('Drop columns here or click') .click(); cy.get('[id="adhoc-metric-edit-tabs-tab-simple"]').click(); - cy.get('input[aria-label="Column"]').click(); - cy.get('input[aria-label="Column"]').type('state{enter}'); + + cy.get('input[aria-label="Columns and metrics"]', { timeout: 10000 }) + .should('be.visible') + .click(); + cy.get('input[aria-label="Columns and metrics"]').type('state{enter}'); + cy.get('[data-test="ColumnEdit#save"]').contains('Save').click(); cy.get('button[data-test="run-query-button"]').click(); diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 0bd0e3231f5..7df6870bbfb 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -63323,8 +63323,7 @@ "dependencies": { "@react-icons/all-files": "^4.1.0", "@types/react": "*", - "lodash": "^4.17.21", - "prop-types": "^15.8.1" + "lodash": "^4.17.21" }, "peerDependencies": { "@ant-design/icons": "^5.2.6", @@ -65227,6 +65226,8 @@ "d3-array": "^1.2.4", "d3-color": "^1.4.1", "d3-scale": "^3.0.0", + "dayjs": "^1.11.13", + "handlebars": "^4.7.8", "lodash": "^4.17.21", "mousetrap": "^1.6.5", "ngeohash": "^0.6.3", diff --git a/superset-frontend/packages/superset-ui-chart-controls/package.json b/superset-frontend/packages/superset-ui-chart-controls/package.json index 1ba1d2a35f0..0c368ce6fdd 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/package.json +++ b/superset-frontend/packages/superset-ui-chart-controls/package.json @@ -26,8 +26,7 @@ "dependencies": { "@react-icons/all-files": "^4.1.0", "@types/react": "*", - "lodash": "^4.17.21", - "prop-types": "^15.8.1" + "lodash": "^4.17.21" }, "peerDependencies": { "@ant-design/icons": "^5.2.6", diff --git a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx index 22146a9e53e..c759a2bbd5d 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx @@ -123,21 +123,33 @@ export function AsyncAceEditor( const cssWorkerUrlPromise = import( 'ace-builds/src-min-noconflict/worker-css' ); + const javascriptWorkerUrlPromise = import( + 'ace-builds/src-min-noconflict/worker-javascript' + ); + const htmlWorkerUrlPromise = import( + 'ace-builds/src-min-noconflict/worker-html' + ); const acequirePromise = import('ace-builds/src-min-noconflict/ace'); const [ { default: ReactAceEditor }, { config }, { default: cssWorkerUrl }, + { default: javascriptWorkerUrl }, + { default: htmlWorkerUrl }, { require: acequire }, ] = await Promise.all([ reactAcePromise, aceBuildsConfigPromise, cssWorkerUrlPromise, + javascriptWorkerUrlPromise, + htmlWorkerUrlPromise, acequirePromise, ]); config.setModuleUrl('ace/mode/css_worker', cssWorkerUrl); + config.setModuleUrl('ace/mode/javascript_worker', javascriptWorkerUrl); + config.setModuleUrl('ace/mode/html_worker', htmlWorkerUrl); await Promise.all(aceModules.map(x => aceModuleLoaders[x]())); diff --git a/superset-frontend/packages/superset-ui-core/src/components/CodeEditor/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/CodeEditor/index.tsx new file mode 100644 index 00000000000..b2721953e38 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/CodeEditor/index.tsx @@ -0,0 +1,106 @@ +/* eslint-disable import/first */ +/** + * 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 { FC } from 'react'; +import AceEditor, { IAceEditorProps } from 'react-ace'; +import ace from 'ace-builds/src-noconflict/ace'; + +// Disable workers to avoid localhost loading issues +ace.config.set('useWorker', false); + +// Import required modes and themes after ace is loaded +import 'ace-builds/src-min-noconflict/mode-handlebars'; +import 'ace-builds/src-min-noconflict/mode-css'; +import 'ace-builds/src-min-noconflict/mode-json'; +import 'ace-builds/src-min-noconflict/mode-sql'; +import 'ace-builds/src-min-noconflict/mode-markdown'; +import 'ace-builds/src-min-noconflict/mode-javascript'; +import 'ace-builds/src-min-noconflict/mode-html'; +import 'ace-builds/src-noconflict/theme-github'; +import 'ace-builds/src-noconflict/theme-monokai'; + +export type CodeEditorMode = + | 'handlebars' + | 'css' + | 'json' + | 'sql' + | 'markdown' + | 'javascript' + | 'html'; + +export type CodeEditorTheme = 'light' | 'dark'; + +export interface CodeEditorProps + extends Omit { + mode?: CodeEditorMode; + theme?: CodeEditorTheme; + name?: string; +} + +export const CodeEditor: FC = ({ + mode = 'handlebars', + theme = 'dark', + name, + width = '100%', + height = '300px', + value, + fontSize = 14, + showPrintMargin = true, + focus = true, + wrapEnabled = true, + highlightActiveLine = true, + editorProps = { $blockScrolling: true }, + setOptions, + ...rest +}: CodeEditorProps) => { + const editorName = name || Math.random().toString(36).substring(7); + const aceTheme = theme === 'light' ? 'github' : 'monokai'; + + return ( + + ); +}; + +export default CodeEditor; diff --git a/superset-frontend/packages/superset-ui-core/src/components/index.ts b/superset-frontend/packages/superset-ui-core/src/components/index.ts index 8cb4a3af9a8..6590688076f 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts @@ -181,3 +181,9 @@ export { type ThemedAgGridReactProps, setupAGGridModules, } from './ThemedAgGridReact'; +export { + CodeEditor, + type CodeEditorProps, + type CodeEditorMode, + type CodeEditorTheme, +} from './CodeEditor'; diff --git a/superset-frontend/packages/superset-ui-core/types/ace-builds.d.ts b/superset-frontend/packages/superset-ui-core/types/ace-builds.d.ts index 347b9889a40..63890d0e61a 100644 --- a/superset-frontend/packages/superset-ui-core/types/ace-builds.d.ts +++ b/superset-frontend/packages/superset-ui-core/types/ace-builds.d.ts @@ -17,4 +17,6 @@ * under the License. */ declare module 'ace-builds/src-min-noconflict/worker-css'; +declare module 'ace-builds/src-min-noconflict/worker-javascript'; +declare module 'ace-builds/src-min-noconflict/worker-html'; declare module 'ace-builds/src-min-noconflict/ace'; diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/package.json b/superset-frontend/plugins/legacy-preset-chart-deckgl/package.json index d094c87cc47..e026b10f4c6 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/package.json +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/package.json @@ -43,6 +43,8 @@ "d3-array": "^1.2.4", "d3-color": "^1.4.1", "d3-scale": "^3.0.0", + "dayjs": "^1.11.13", + "handlebars": "^4.7.8", "lodash": "^4.17.21", "mousetrap": "^1.6.5", "ngeohash": "^0.6.3", diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.tsx index 98376660274..ad1ed4953ab 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.tsx @@ -29,6 +29,7 @@ import { useEffect, useImperativeHandle, useState, + isValidElement, useRef, } from 'react'; import { isEqual } from 'lodash'; @@ -110,7 +111,9 @@ export const DeckGLContainer = memo( const layers = useCallback(() => { if ( (props.mapStyle?.startsWith(TILE_LAYER_PREFIX) || - OSM_LAYER_KEYWORDS.some(tilek => props.mapStyle?.includes(tilek))) && + OSM_LAYER_KEYWORDS.some((tilek: string) => + props.mapStyle?.includes(tilek), + )) && props.layers.some( l => typeof l !== 'function' && l?.id === 'tile-layer', ) === false @@ -132,6 +135,20 @@ export const DeckGLContainer = memo( return props.layers as Layer[]; }, [props.layers, props.mapStyle]); + const isCustomTooltip = (content: ReactNode): boolean => + isValidElement(content) && + content.props?.['data-tooltip-type'] === 'custom'; + + const renderTooltip = (tooltipState: TooltipProps['tooltip']) => { + if (!tooltipState) return null; + + if (isCustomTooltip(tooltipState.content)) { + return ; + } + + return ; + }; + const { children = null, height, width } = props; return ( @@ -150,7 +167,7 @@ export const DeckGLContainer = memo( layers={layers()} viewState={viewState} onViewStateChange={onViewStateChange} - onAfterRender={context => { + onAfterRender={(context: any) => { glContextRef.current = context.gl; }} > @@ -164,7 +181,7 @@ export const DeckGLContainer = memo( {children} - + {renderTooltip(tooltip)} ); }), diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Legend.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Legend.tsx index 137da24fd37..07c979e4210 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Legend.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Legend.tsx @@ -34,6 +34,8 @@ const StyledLegend = styled.div` outline: none; overflow-y: scroll; max-height: 200px; + border: 1px solid ${theme.colorBorder}; + border-radius: ${theme.borderRadius}px; & ul { list-style: none; diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Tooltip.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Tooltip.tsx index 096d2edcd62..670e44e81d5 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Tooltip.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Tooltip.tsx @@ -29,26 +29,42 @@ export type TooltipProps = { } | null | undefined; + variant?: 'default' | 'custom'; }; -const StyledDiv = styled.div<{ top: number; left: number }>` - ${({ theme, top, left }) => ` +const StyledDiv = styled.div<{ + top: number; + left: number; + variant: 'default' | 'custom'; +}>` + ${({ theme, top, left, variant }) => ` position: absolute; top: ${top}px; left: ${left}px; - padding: ${theme.sizeUnit * 2}px; - margin: ${theme.sizeUnit * 2}px; - background: ${theme.colorBgElevated}; - color: ${theme.colorText}; - maxWidth: 300px; - fontSize: ${theme.fontSizeSM}px; zIndex: 9; pointerEvents: none; + ${ + variant === 'default' + ? ` + padding: ${theme.sizeUnit * 2}px; + margin: ${theme.sizeUnit * 2}px; + background: ${theme.colorBgElevated}; + color: ${theme.colorText}; + maxWidth: 300px; + fontSize: ${theme.fontSizeSM}px; + border: 1px solid ${theme.colorBorder}; + border-radius: ${theme.borderRadius}px; + box-shadow: ${theme.boxShadowSecondary}; + ` + : ` + margin: ${theme.sizeUnit * 3}px; + ` + } `} `; export default function Tooltip(props: TooltipProps) { - const { tooltip } = props; + const { tooltip, variant = 'default' } = props; if (typeof tooltip === 'undefined' || tooltip === null) { return null; } @@ -58,7 +74,7 @@ export default function Tooltip(props: TooltipProps) { typeof content === 'string' ? safeHtmlSpan(content) : content; return ( - + {safeContent} ); diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/Arc.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/Arc.tsx index dc468bc5873..f8a6f84a925 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/Arc.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/Arc.tsx @@ -17,14 +17,26 @@ * under the License. */ import { ArcLayer } from '@deck.gl/layers'; -import { JsonObject, QueryFormData, t } from '@superset-ui/core'; +import { JsonObject, QueryFormData } from '@superset-ui/core'; import { COLOR_SCHEME_TYPES } from '../../utilities/utils'; import { commonLayerProps } from '../common'; import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory'; -import TooltipRow from '../../TooltipRow'; import { Point } from '../../types'; +import { + createTooltipContent, + CommonTooltipRows, +} from '../../utilities/tooltipUtils'; import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils'; +interface ArcDataItem { + sourceColor?: number[]; + targetColor?: number[]; + color?: number[]; + sourcePosition: number[]; + targetPosition: number[]; + [key: string]: unknown; +} + export function getPoints(data: JsonObject[]) { const points: Point[] = []; data.forEach(d => { @@ -36,24 +48,14 @@ export function getPoints(data: JsonObject[]) { } function setTooltipContent(formData: QueryFormData) { - return (o: JsonObject) => ( + const defaultTooltipGenerator = (o: JsonObject) => (
- - - {formData.dimension && ( - - )} + {CommonTooltipRows.arcPositions(o)} + {CommonTooltipRows.category(o)}
); + + return createTooltipContent(formData, defaultTooltipGenerator); } export const getLayer: GetLayerType = function ({ @@ -74,19 +76,27 @@ export const getLayer: GetLayerType = function ({ return new ArcLayer({ data, - getSourceColor: (d: JsonObject) => { + getSourceColor: (d: ArcDataItem): [number, number, number, number] => { if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) { return [sc.r, sc.g, sc.b, 255 * sc.a]; } - - return d.targetColor || d.color; + return (d.sourceColor || d.color || [sc.r, sc.g, sc.b, 255 * sc.a]) as [ + number, + number, + number, + number, + ]; }, - getTargetColor: (d: any) => { + getTargetColor: (d: ArcDataItem): [number, number, number, number] => { if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) { return [tc.r, tc.g, tc.b, 255 * tc.a]; } - - return d.targetColor || d.color; + return (d.targetColor || d.color || [tc.r, tc.g, tc.b, 255 * tc.a]) as [ + number, + number, + number, + number, + ]; }, id: `path-layer-${fd.slice_id}` as const, getWidth: fd.stroke_width ? fd.stroke_width : 3, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/controlPanel.ts index dc6d726148c..89d666dd934 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/controlPanel.ts @@ -38,6 +38,8 @@ import { legendPosition, viewport, mapboxStyle, + tooltipContents, + tooltipTemplate, deckGLCategoricalColor, deckGLCategoricalColorSchemeSelect, deckGLCategoricalColorSchemeTypeSelect, @@ -77,6 +79,8 @@ const config: ControlPanelConfig = { ], ['row_limit', filterNulls], ['adhoc_filters'], + [tooltipContents], + [tooltipTemplate], ], }, { diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/Contour.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/Contour.tsx index 18cad10d879..42af03b6865 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/Contour.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/Contour.tsx @@ -25,8 +25,24 @@ import sandboxedEval from '../../utils/sandbox'; import { GetLayerType, createDeckGLComponent } from '../../factory'; import { ColorType } from '../../types'; import TooltipRow from '../../TooltipRow'; +import { + createTooltipContent, + CommonTooltipRows, +} from '../../utilities/tooltipUtils'; import { HIGHLIGHT_COLOR_ARRAY } from '../../utils'; +function defaultTooltipGenerator(o: any) { + return ( +
+ {CommonTooltipRows.centroid(o)} + +
+ ); +} + function setTooltipContent(o: any) { return (
@@ -41,6 +57,7 @@ function setTooltipContent(o: any) {
); } + export const getLayer: GetLayerType = function ({ formData, payload, @@ -59,6 +76,18 @@ export const getLayer: GetLayerType = function ({ } = fd; let data = payload.data.features; + // Store original data for tooltip access + const originalDataMap = new Map(); + data.forEach((d: any) => { + if (d.position) { + const key = `${Math.floor(d.position[0] * 1000)},${Math.floor(d.position[1] * 1000)}`; + if (!originalDataMap.has(key)) { + originalDataMap.set(key, []); + } + originalDataMap.get(key)?.push(d.originalData || d); + } + }); + const contours = rawContours?.map( (contour: { color: ColorType; @@ -89,6 +118,47 @@ export const getLayer: GetLayerType = function ({ data = jsFnMutatorFunction(data); } + // Create wrapper for tooltip content that adds nearby points + const tooltipContentGenerator = (o: any) => { + // Find nearby points based on hover coordinate + const nearbyPoints: any[] = []; + if (o.coordinate) { + const searchKey = `${Math.floor(o.coordinate[0] * 1000)},${Math.floor(o.coordinate[1] * 1000)}`; + const points = originalDataMap.get(searchKey) || []; + nearbyPoints.push(...points); + + // Also check neighboring cells for better coverage + for (let dx = -1; dx <= 1; dx += 1) { + for (let dy = -1; dy <= 1; dy += 1) { + if (dx !== 0 || dy !== 0) { + const neighborKey = `${Math.floor(o.coordinate[0] * 1000) + dx},${Math.floor(o.coordinate[1] * 1000) + dy}`; + const neighborPoints = originalDataMap.get(neighborKey) || []; + nearbyPoints.push(...neighborPoints); + } + } + } + + // Enhance the object with nearby points data + if (nearbyPoints.length > 0) { + const enhancedObject = { + ...o.object, + nearbyPoints: nearbyPoints.slice(0, 5), // Limit to first 5 points + totalPoints: nearbyPoints.length, + // Add first point's data at top level for easy access + ...nearbyPoints[0], + }; + Object.assign(o, { object: enhancedObject }); + } + } + + // Use createTooltipContent with the enhanced object + const baseTooltipContent = createTooltipContent( + fd, + defaultTooltipGenerator, + ); + return baseTooltipContent(o); + }; + return new ContourLayer({ id: `contourLayer-${fd.slice_id}`, data, @@ -101,7 +171,7 @@ export const getLayer: GetLayerType = function ({ ...commonLayerProps({ formData: fd, setTooltip, - setTooltipContent, + setTooltipContent: tooltipContentGenerator, onContextMenu, setDataMask, filterState, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/controlPanel.ts index a154e187961..e920f07ed76 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/controlPanel.ts @@ -31,6 +31,8 @@ import { mapboxStyle, spatial, viewport, + tooltipContents, + tooltipTemplate, } from '../../utilities/Shared_DeckGL'; const config: ControlPanelConfig = { @@ -44,6 +46,8 @@ const config: ControlPanelConfig = { ['size'], [filterNulls], ['adhoc_filters'], + [tooltipContents], + [tooltipTemplate], ], }, { diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/controlPanel.ts index 079c3524802..568659e8746 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/controlPanel.ts @@ -34,6 +34,8 @@ import { mapboxStyle, autozoom, lineWidth, + tooltipContents, + tooltipTemplate, } from '../../utilities/Shared_DeckGL'; import { dndGeojsonColumn } from '../../utilities/sharedDndControls'; @@ -47,6 +49,8 @@ const config: ControlPanelConfig = { ['row_limit'], [filterNulls], ['adhoc_filters'], + [tooltipContents], + [tooltipTemplate], ], }, { diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/Grid.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/Grid.tsx index 7395fa1c107..da1367a189f 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/Grid.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/Grid.tsx @@ -17,7 +17,11 @@ * under the License. */ import { GridLayer } from '@deck.gl/aggregation-layers'; -import { t, CategoricalColorNamespace, JsonObject } from '@superset-ui/core'; +import { + CategoricalColorNamespace, + JsonObject, + QueryFormData, +} from '@superset-ui/core'; import { commonLayerProps, @@ -29,20 +33,21 @@ import sandboxedEval from '../../utils/sandbox'; import { createDeckGLComponent, GetLayerType } from '../../factory'; import TooltipRow from '../../TooltipRow'; import { COLOR_SCHEME_TYPES } from '../../utilities/utils'; +import { + createTooltipContent, + CommonTooltipRows, +} from '../../utilities/tooltipUtils'; import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils'; -function setTooltipContent(o: JsonObject) { +function defaultTooltipGenerator(o: JsonObject, formData: QueryFormData) { + const metricLabel = formData.size?.label || formData.size?.value || 'Height'; + return (
+ {CommonTooltipRows.centroid(o)} -
); @@ -63,7 +68,6 @@ export const getLayer: GetLayerType = function ({ let data = payload.data.features; if (fd.js_data_mutator) { - // Applying user defined data mutator if defined const jsFnMutator = sandboxedEval(fd.js_data_mutator); data = jsFnMutator(data); } @@ -81,6 +85,10 @@ export const getLayer: GetLayerType = function ({ const aggFunc = getAggFunc(fd.js_agg_function, p => p.weight); + const tooltipContent = createTooltipContent(fd, (o: JsonObject) => + defaultTooltipGenerator(o, fd), + ); + const colorAggFunc = colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints ? (p: number[]) => getColorForBreakpoints(aggFunc, p, colorBreakpoints) @@ -105,7 +113,7 @@ export const getLayer: GetLayerType = function ({ formData: fd, setDataMask, setTooltip, - setTooltipContent, + setTooltipContent: tooltipContent, filterState, onContextMenu, emitCrossFilters, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/controlPanel.ts index 02e0bc43412..fef74f6fe65 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/controlPanel.ts @@ -33,6 +33,8 @@ import { viewport, spatial, mapboxStyle, + tooltipContents, + tooltipTemplate, legendPosition, generateDeckGLColorSchemeControls, } from '../../utilities/Shared_DeckGL'; @@ -49,6 +51,8 @@ const config: ControlPanelConfig = { ['row_limit'], [filterNulls], ['adhoc_filters'], + [tooltipContents], + [tooltipTemplate], ], }, { diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/Heatmap.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/Heatmap.tsx index 22d21d364db..68bdfac85d6 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/Heatmap.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/Heatmap.tsx @@ -18,31 +18,91 @@ */ import { HeatmapLayer } from '@deck.gl/aggregation-layers'; import { Position } from '@deck.gl/core'; -import { t, getSequentialSchemeRegistry, JsonObject } from '@superset-ui/core'; +import { + t, + getSequentialSchemeRegistry, + JsonObject, + QueryFormData, +} from '@superset-ui/core'; import { isPointInBonds } from '../../utilities/utils'; import { commonLayerProps, getColorRange } from '../common'; import sandboxedEval from '../../utils/sandbox'; import { GetLayerType, createDeckGLComponent } from '../../factory'; import TooltipRow from '../../TooltipRow'; +import { createTooltipContent } from '../../utilities/tooltipUtils'; import { HIGHLIGHT_COLOR_ARRAY } from '../../utils'; -function setTooltipContent(o: JsonObject) { - return ( -
- -
- ); +function setTooltipContent(formData: QueryFormData) { + const defaultTooltipGenerator = (o: JsonObject) => { + const metricLabel = + formData.size?.label || formData.size?.value || 'Weight'; + const lon = o.coordinate?.[0]; + const lat = o.coordinate?.[1]; + + const hasCustomTooltip = + formData.tooltip_template || + (formData.tooltip_contents && formData.tooltip_contents.length > 0); + const hasObjectData = o.object && Object.keys(o.object).length > 0; + + return ( +
+ + + + + {hasCustomTooltip && !hasObjectData && ( + + )} +
+ ); + }; + + return (o: JsonObject) => { + // Try to find the closest data point to the hovered coordinate + let closestPoint = null; + if (o.coordinate && o.layer?.props?.data) { + const [hoveredLon, hoveredLat] = o.coordinate; + let minDistance = Infinity; + + for (const point of o.layer.props.data) { + if (point.position) { + const [pointLon, pointLat] = point.position; + const distance = Math.sqrt( + Math.pow(hoveredLon - pointLon, 2) + + Math.pow(hoveredLat - pointLat, 2), + ); + if (distance < minDistance) { + minDistance = distance; + closestPoint = point; + } + } + } + } + const modifiedO = { + ...o, + object: closestPoint || o.object, + }; + + return createTooltipContent(formData, defaultTooltipGenerator)(modifiedO); + }; } + export const getLayer: GetLayerType = ({ formData, + payload, + setTooltip, + setDataMask, onContextMenu, filterState, - setDataMask, - setTooltip, - payload, emitCrossFilters, }) => { const fd = formData; @@ -56,7 +116,6 @@ export const getLayer: GetLayerType = ({ let data = payload.data.features; if (jsFnMutator) { - // Applying user defined data mutator if defined const jsFnMutatorFunction = sandboxedEval(fd.js_data_mutator); data = jsFnMutatorFunction(data); } @@ -74,6 +133,8 @@ export const getLayer: GetLayerType = ({ colorScale, })?.reverse(); + const tooltipContent = setTooltipContent(fd); + return new HeatmapLayer({ id: `heatmap-layer-${fd.slice_id}` as const, data, @@ -84,10 +145,12 @@ export const getLayer: GetLayerType = ({ getPosition: (d: { position: Position; weight: number }) => d.position, getWeight: (d: { position: number[]; weight: number }) => d.weight ? d.weight : 1, + opacity: 0.8, + threshold: 0.03, ...commonLayerProps({ formData: fd, setTooltip, - setTooltipContent, + setTooltipContent: tooltipContent, setDataMask, filterState, onContextMenu, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/controlPanel.ts index 05a337d3a8b..fa30977f7ea 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/controlPanel.ts @@ -39,6 +39,8 @@ import { mapboxStyle, spatial, viewport, + tooltipContents, + tooltipTemplate, } from '../../utilities/Shared_DeckGL'; import { COLOR_SCHEME_TYPES } from '../../utilities/utils'; @@ -62,6 +64,8 @@ const config: ControlPanelConfig = { ['row_limit'], [filterNulls], ['adhoc_filters'], + [tooltipContents], + [tooltipTemplate], [ { name: 'intensity', diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/Hex.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/Hex.tsx index 216d22bb2d3..9377ee75b18 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/Hex.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/Hex.tsx @@ -17,7 +17,11 @@ * under the License. */ import { HexagonLayer } from '@deck.gl/aggregation-layers'; -import { t, CategoricalColorNamespace, JsonObject } from '@superset-ui/core'; +import { + CategoricalColorNamespace, + JsonObject, + QueryFormData, +} from '@superset-ui/core'; import { COLOR_SCHEME_TYPES } from '../../utilities/utils'; import { @@ -28,20 +32,22 @@ import { } from '../common'; import sandboxedEval from '../../utils/sandbox'; import { GetLayerType, createDeckGLComponent } from '../../factory'; +import { + createTooltipContent, + CommonTooltipRows, +} from '../../utilities/tooltipUtils'; import TooltipRow from '../../TooltipRow'; import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils'; -function setTooltipContent(o: JsonObject) { +function defaultTooltipGenerator(o: JsonObject, formData: QueryFormData) { + const metricLabel = formData.size?.label || formData.size?.value || 'Height'; + return (
+ {CommonTooltipRows.centroid(o)} -
); @@ -85,6 +91,10 @@ export const getLayer: GetLayerType = function ({ ? (p: number[]) => getColorForBreakpoints(aggFunc, p, colorBreakpoints) : aggFunc; + const tooltipContent = createTooltipContent(fd, (o: JsonObject) => + defaultTooltipGenerator(o, fd), + ); + return new HexagonLayer({ id: `hex-layer-${fd.slice_id}-${JSON.stringify(colorBreakpoints)}` as const, data, @@ -103,7 +113,7 @@ export const getLayer: GetLayerType = function ({ ...commonLayerProps({ formData: fd, setTooltip, - setTooltipContent, + setTooltipContent: tooltipContent, setDataMask, filterState, onContextMenu, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/controlPanel.ts index ebf8f9f7fc0..7779fa1c75d 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/controlPanel.ts @@ -34,6 +34,8 @@ import { mapboxStyle, spatial, viewport, + tooltipContents, + tooltipTemplate, } from '../../utilities/Shared_DeckGL'; import { COLOR_SCHEME_TYPES } from '../../utilities/utils'; @@ -48,6 +50,8 @@ const config: ControlPanelConfig = { ['row_limit'], [filterNulls], ['adhoc_filters'], + [tooltipContents], + [tooltipTemplate], ], }, { diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/Path.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/Path.tsx index 19bce632b5f..7408676df70 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/Path.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/Path.tsx @@ -18,28 +18,26 @@ * under the License. */ import { PathLayer } from '@deck.gl/layers'; -import { JsonObject } from '@superset-ui/core'; +import { JsonObject, QueryFormData } from '@superset-ui/core'; import { commonLayerProps } from '../common'; import sandboxedEval from '../../utils/sandbox'; import { GetLayerType, createDeckGLComponent } from '../../factory'; -import TooltipRow from '../../TooltipRow'; import { Point } from '../../types'; +import { + createTooltipContent, + CommonTooltipRows, +} from '../../utilities/tooltipUtils'; import { HIGHLIGHT_COLOR_ARRAY } from '../../utils'; -function setTooltipContent(o: JsonObject) { - return ( - o.object?.extraProps && ( -
- {Object.keys(o.object.extraProps).map((prop, index) => ( - - ))} -
- ) +function setTooltipContent(formData: QueryFormData) { + const defaultTooltipGenerator = (o: JsonObject) => ( +
+ {CommonTooltipRows.position(o)} + {CommonTooltipRows.category(o)} +
); + + return createTooltipContent(formData, defaultTooltipGenerator); } export const getLayer: GetLayerType = function ({ @@ -78,7 +76,7 @@ export const getLayer: GetLayerType = function ({ ...commonLayerProps({ formData: fd, setTooltip, - setTooltipContent, + setTooltipContent: setTooltipContent(fd), setDataMask, filterState, onContextMenu, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/controlPanel.ts index b3488d4ec72..658e7c014a1 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/controlPanel.ts @@ -30,6 +30,8 @@ import { lineType, reverseLongLat, mapboxStyle, + tooltipContents, + tooltipTemplate, } from '../../utilities/Shared_DeckGL'; import { dndLineColumn } from '../../utilities/sharedDndControls'; @@ -55,6 +57,8 @@ const config: ControlPanelConfig = { ['row_limit'], [filterNulls], ['adhoc_filters'], + [tooltipContents], + [tooltipTemplate], ], }, { diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/Polygon.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/Polygon.tsx index 48d1bc8fd50..912b531b402 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/Polygon.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/Polygon.tsx @@ -57,6 +57,10 @@ import { TooltipProps } from '../../components/Tooltip'; import { GetLayerType } from '../../factory'; import { COLOR_SCHEME_TYPES } from '../../utilities/utils'; import { DEFAULT_DECKGL_COLOR } from '../../utilities/Shared_DeckGL'; +import { + createTooltipContent, + CommonTooltipRows, +} from '../../utilities/tooltipUtils'; import { Point } from '../../types'; function getElevation( @@ -71,34 +75,32 @@ function getElevation( return colorScaler(d)[3] === 0 ? 0 : d.elevation; } -function setTooltipContent(formData: PolygonFormData) { - return (o: JsonObject) => { - const metricLabel = formData?.metric?.label || formData?.metric; - - return ( -
- {o.object?.name && ( - - )} - {o.object?.[formData?.line_column] && ( - - )} - {formData?.metric && ( - - )} -
- ); - }; +function defaultTooltipGenerator( + o: JsonObject, + fd: PolygonFormData, + metricLabel: string, +) { + return ( +
+ {o.object?.name && ( + + )} + {o.object?.[fd?.line_column] && ( + + )} + {CommonTooltipRows.centroid(o)} + {CommonTooltipRows.category(o)} + {fd?.metric && ( + + )} +
+ ); } export const getLayer: GetLayerType = function ({ @@ -198,12 +200,9 @@ export const getLayer: GetLayerType = function ({ return baseColor; }; - const tooltipContentGenerator = - fd.line_column && - fd.metric && - ['json', 'geohash', 'zipcode'].includes(fd.line_type) - ? setTooltipContent(fd) - : () => null; + const tooltipContentGenerator = createTooltipContent(fd, (o: JsonObject) => + defaultTooltipGenerator(o, fd, metricLabel), + ); return new PolygonLayer({ id: `path-layer-${fd.slice_id}` as const, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/controlPanel.ts index ba0cf4397b8..9aec653e05b 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/controlPanel.ts @@ -48,6 +48,8 @@ import { deckGLLinearColorSchemeSelect, deckGLColorBreakpointsSelect, breakpointsDefaultColor, + tooltipContents, + tooltipTemplate, } from '../../utilities/Shared_DeckGL'; import { dndLineColumn } from '../../utilities/sharedDndControls'; @@ -89,6 +91,8 @@ const config: ControlPanelConfig = { ['row_limit'], [reverseLongLat], [filterNulls], + [tooltipContents], + [tooltipTemplate], ], }, { diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/Scatter.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/Scatter.tsx index 18b9f308b31..3ecd3eac0cf 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/Scatter.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/Scatter.tsx @@ -17,42 +17,45 @@ * under the License. */ import { ScatterplotLayer } from '@deck.gl/layers'; -import { - getMetricLabel, - JsonObject, - QueryFormData, - t, -} from '@superset-ui/core'; +import { JsonObject, QueryFormData, t } from '@superset-ui/core'; import { isPointInBonds } from '../../utilities/utils'; import { commonLayerProps } from '../common'; import { createCategoricalDeckGLComponent, GetLayerType } from '../../factory'; +import { createTooltipContent } from '../../utilities/tooltipUtils'; import TooltipRow from '../../TooltipRow'; import { unitToRadius } from '../../utils/geo'; import { HIGHLIGHT_COLOR_ARRAY } from '../../utils'; -export function getPoints(data: JsonObject[]) { - return data.map(d => d.position); +function getMetricLabel(metric: any) { + if (typeof metric === 'string') { + return metric; + } + if (metric?.label) { + return metric.label; + } + if (metric?.verbose_name) { + return metric.verbose_name; + } + return metric?.value || 'Metric'; } function setTooltipContent( formData: QueryFormData, verboseMap?: Record, ) { - return (o: JsonObject) => { + const defaultTooltipGenerator = (o: JsonObject) => { const label = verboseMap?.[formData.point_radius_fixed.value] || getMetricLabel(formData.point_radius_fixed?.value); return (
{o.object?.cat_color && ( )} @@ -62,6 +65,19 @@ function setTooltipContent(
); }; + + return createTooltipContent(formData, defaultTooltipGenerator); +} + +interface ScatterDataItem { + color: number[]; + radius: number; + position: number[]; + [key: string]: unknown; +} + +export function getPoints(data: JsonObject[]) { + return data.map(d => d.position); } export const getLayer: GetLayerType = function ({ @@ -93,8 +109,9 @@ export const getLayer: GetLayerType = function ({ id: `scatter-layer-${fd.slice_id}` as const, data: dataWithRadius, fp64: true, - getFillColor: (d: any) => d.color, - getRadius: (d: any) => d.radius, + getFillColor: (d: ScatterDataItem): [number, number, number, number] => + d.color as [number, number, number, number], + getRadius: (d: ScatterDataItem): number => d.radius, radiusMinPixels: Number(fd.min_radius) || undefined, radiusMaxPixels: Number(fd.max_radius) || undefined, stroked: false, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/controlPanel.ts index 997b65b3ea3..9545dcd1be4 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/controlPanel.ts @@ -34,6 +34,8 @@ import { multiplier, mapboxStyle, generateDeckGLColorSchemeControls, + tooltipContents, + tooltipTemplate, } from '../../utilities/Shared_DeckGL'; import { COLOR_SCHEME_TYPES } from '../../utilities/utils'; @@ -57,6 +59,8 @@ const config: ControlPanelConfig = { [spatial, null], ['row_limit', filterNulls], ['adhoc_filters'], + [tooltipContents], + [tooltipTemplate], ], }, { diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.tsx index 8abddb1dab8..9ea46cd453e 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.tsx @@ -1,5 +1,3 @@ -/* eslint-disable react/sort-prop-types */ -/* eslint-disable react/jsx-handler-names */ /** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -20,8 +18,14 @@ */ import { ScreenGridLayer } from '@deck.gl/aggregation-layers'; -import { CategoricalColorNamespace, JsonObject, t } from '@superset-ui/core'; import { Color } from '@deck.gl/core'; +import { + JsonObject, + QueryFormData, + styled, + CategoricalColorNamespace, + t, +} from '@superset-ui/core'; import { COLOR_SCHEME_TYPES, ColorSchemeType, @@ -32,14 +36,29 @@ import { commonLayerProps, getColorRange } from '../common'; import TooltipRow from '../../TooltipRow'; import { GetLayerType, createDeckGLComponent } from '../../factory'; import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils'; +import { + createTooltipContent, + CommonTooltipRows, +} from '../../utilities/tooltipUtils'; + +const MoreRecordsIndicator = styled.div` + margin-top: ${({ theme }) => theme.sizeUnit}px; + font-size: ${({ theme }) => theme.fontSizeSM}px; + color: ${({ theme }) => theme.colorTextSecondary}; +`; export function getPoints(data: JsonObject[]) { return data.map(d => d.position); } -function setTooltipContent(o: JsonObject) { +function defaultTooltipGenerator(o: JsonObject, formData: QueryFormData) { + const metricLabel = formData.size?.label || formData.size?.value || 'Weight'; + const points = o.points || []; + const pointCount = points.length || 0; + return (
+ {CommonTooltipRows.centroid(o)} + + + {points.length > 0 && points.length <= 3 && ( +
+ Records: + {points.slice(0, 3).map((point: JsonObject, index: number) => ( +
+ {Object.entries(point).map(([key, value]) => + key !== 'position' && + key !== 'weight' && + key !== '__timestamp' && + key !== 'points' ? ( + + {key}: {String(value)} + + ) : null, + )} +
+ ))} +
+ )} + {points.length > 3 && ( + + ... and {points.length - 3} more records + + )}
); } @@ -69,7 +117,6 @@ export const getLayer: GetLayerType = function ({ let data = payload.data.features; if (fd.js_data_mutator) { - // Applying user defined data mutator if defined const jsFnMutator = sandboxedEval(fd.js_data_mutator); data = jsFnMutator(data); } @@ -94,8 +141,54 @@ export const getLayer: GetLayerType = function ({ [189, 0, 38], ] as Color[]; - // Passing a layer creator function instead of a layer since the - // layer needs to be regenerated at each render + const cellSize = fd.grid_size || 50; + const cellToPointsMap = new Map(); + + data.forEach((point: JsonObject) => { + const { position } = point; + if (position) { + const cellX = Math.floor(position[0] / (cellSize * 0.01)); + const cellY = Math.floor(position[1] / (cellSize * 0.01)); + const cellKey = `${cellX},${cellY}`; + + if (!cellToPointsMap.has(cellKey)) { + cellToPointsMap.set(cellKey, []); + } + cellToPointsMap.get(cellKey).push(point); + } + }); + + const tooltipContent = createTooltipContent(fd, (o: JsonObject) => + defaultTooltipGenerator(o, fd), + ); + + const customOnHover = (info: JsonObject) => { + if (info.picked) { + const cellCenter = info.coordinate; + const cellX = Math.floor(cellCenter[0] / (cellSize * 0.01)); + const cellY = Math.floor(cellCenter[1] / (cellSize * 0.01)); + const cellKey = `${cellX},${cellY}`; + + const pointsInCell = cellToPointsMap.get(cellKey) || []; + const enhancedInfo = { + ...info, + object: { + ...info.object, + points: pointsInCell, + }, + }; + + setTooltip({ + content: tooltipContent(enhancedInfo), + x: info.x, + y: info.y, + }); + } else { + setTooltip(null); + } + return true; + }; + return new ScreenGridLayer({ id: `screengrid-layer-${fd.slice_id}` as const, data, @@ -111,13 +204,15 @@ export const getLayer: GetLayerType = function ({ formData: fd, setDataMask, setTooltip, - setTooltipContent, + setTooltipContent: tooltipContent, filterState, onContextMenu, emitCrossFilters, }), getWeight: aggFunc, colorScaleType: colorSchemeType === 'default' ? 'linear' : 'quantize', + onHover: customOnHover, + pickable: true, opacity: filterState?.value ? 0.3 : 1, }); }; diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/controlPanel.ts index c7a637f55a4..32cc050305c 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/controlPanel.ts @@ -36,6 +36,8 @@ import { deckGLFixedColor, deckGLCategoricalColorSchemeSelect, deckGLCategoricalColorSchemeTypeSelect, + tooltipContents, + tooltipTemplate, } from '../../utilities/Shared_DeckGL'; import { COLOR_SCHEME_TYPES } from '../../utilities/utils'; @@ -50,6 +52,8 @@ const config: ControlPanelConfig = { ['row_limit'], [filterNulls], ['adhoc_filters'], + [tooltipContents], + [tooltipTemplate], ], }, { diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/common.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/common.tsx index 7ae3932f77d..10064b64b14 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/common.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/common.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { ReactNode } from 'react'; +import { ReactNode, isValidElement } from 'react'; import { ascending as d3ascending, quantile as d3quantile, @@ -73,15 +73,29 @@ export function commonLayerProps({ tooltipContentGenerator = sandboxedEval(fd.js_tooltip); } if (tooltipContentGenerator) { + let currentTooltipContent: ReactNode = null; + + const isCustomTooltip = (content: ReactNode): boolean => + isValidElement(content) && + content.props?.['data-tooltip-type'] === 'custom'; + onHover = (o: JsonObject) => { if (o.picked) { + currentTooltipContent = tooltipContentGenerator(o); + } + + if ( + currentTooltipContent && + (o.picked || isCustomTooltip(currentTooltipContent)) + ) { setTooltip({ - content: tooltipContentGenerator(o), + content: currentTooltipContent, x: o.x, y: o.y, }); } else { setTooltip(null); + currentTooltipContent = null; } return true; }; diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/HandlebarsRenderer.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/HandlebarsRenderer.tsx new file mode 100644 index 00000000000..1965eae717b --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/HandlebarsRenderer.tsx @@ -0,0 +1,231 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useEffect, useState, memo } from 'react'; +import { styled, t } from '@superset-ui/core'; +import { SafeMarkdown } from '@superset-ui/core/components'; +import Handlebars from 'handlebars'; +import dayjs from 'dayjs'; +import { isPlainObject } from 'lodash'; + +export interface HandlebarsRendererProps { + templateSource: string; + data: any; +} + +const ErrorContainer = styled.pre` + white-space: pre-wrap; + color: ${({ theme }) => theme.colorError}; + background-color: ${({ theme }) => theme.colorErrorBg}; + padding: ${({ theme }) => theme.sizeUnit * 2}px; + border-radius: ${({ theme }) => theme.borderRadius}px; +`; + +export const HandlebarsRenderer: React.FC = memo( + ({ templateSource, data }) => { + const [renderedTemplate, setRenderedTemplate] = useState(''); + const [error, setError] = useState(''); + const appContainer = document.getElementById('app'); + const { common } = JSON.parse( + appContainer?.getAttribute('data-bootstrap') || '{}', + ); + const htmlSanitization = common?.conf?.HTML_SANITIZATION ?? true; + const htmlSchemaOverrides = + common?.conf?.HTML_SANITIZATION_SCHEMA_EXTENSIONS || {}; + + useEffect(() => { + try { + const template = Handlebars.compile(templateSource); + const result = template(data); + setRenderedTemplate(result); + setError(''); + } catch (error) { + setRenderedTemplate(''); + setError(error.message || 'Unknown template error'); + } + }, [templateSource, data]); + + if (error) { + return {error}; + } + + if (renderedTemplate || renderedTemplate === '') { + return ( +
+ +
+ ); + } + + return

{t('Loading...')}

; + }, +); + +Handlebars.registerHelper('dateFormat', function (context, options) { + const format = options.hash.format || 'YYYY-MM-DD HH:mm:ss'; + if (!context) return ''; + + try { + if (typeof context === 'number') { + const timestamp = context > 1000000000000 ? context : context * 1000; + return dayjs(timestamp).format(format); + } + return dayjs(context).format(format); + } catch (e) { + return String(context); + } +}); + +Handlebars.registerHelper('formatNumber', function (number, options) { + if (typeof number !== 'number') { + return number; + } + + const locale = options.hash.locale || 'en-US'; + const { minimumFractionDigits } = options.hash; + const { maximumFractionDigits } = options.hash; + + const formatOptions: Intl.NumberFormatOptions = {}; + if (minimumFractionDigits !== undefined) { + formatOptions.minimumFractionDigits = minimumFractionDigits; + } + if (maximumFractionDigits !== undefined) { + formatOptions.maximumFractionDigits = maximumFractionDigits; + } + + return number.toLocaleString(locale, formatOptions); +}); + +Handlebars.registerHelper('stringify', function (obj) { + if (obj === undefined || obj === null) { + return ''; + } + + if (isPlainObject(obj)) { + try { + return JSON.stringify(obj, null, 2); + } catch (e) { + return String(obj); + } + } + + return String(obj); +}); + +Handlebars.registerHelper( + 'ifExists', + function (this: any, value: any, options: any) { + if (value !== null && value !== undefined && value !== '') { + return options.fn(this); + } + return options.inverse(this); + }, +); + +Handlebars.registerHelper('default', function (value, fallback) { + return value !== null && value !== undefined && value !== '' + ? value + : fallback; +}); + +Handlebars.registerHelper('truncate', function (text, length) { + if (typeof text !== 'string') { + return text; + } + + if (text.length <= length) { + return text; + } + + return `${text.substring(0, length)}...`; +}); + +Handlebars.registerHelper('formatCoordinate', function (longitude, latitude) { + if ( + longitude === null || + longitude === undefined || + latitude === null || + latitude === undefined + ) { + return ''; + } + + const lng = typeof longitude === 'number' ? longitude.toFixed(6) : longitude; + const lat = typeof latitude === 'number' ? latitude.toFixed(6) : latitude; + + return `${lng}, ${lat}`; +}); + +Handlebars.registerHelper('first', function (array) { + if (Array.isArray(array) && array.length > 0) { + return array[0]; + } + return null; +}); + +Handlebars.registerHelper('getField', function (array, fieldName) { + if (!Array.isArray(array) || array.length === 0) { + return ''; + } + + const values = array + .map(item => item[fieldName]) + .filter( + (value, index, self) => + value !== undefined && value !== null && self.indexOf(value) === index, + ); + + if (values.length === 0) return ''; + if (values.length === 1) return values[0]; + return values.slice(0, 3).join(', ') + (values.length > 3 ? '...' : ''); +}); + +Handlebars.registerHelper('limit', function (value, limit) { + if (!value) return ''; + + // Handle arrays + if (Array.isArray(value)) { + const limitedArray = value.slice(0, limit); + return limitedArray.join(', ') + (value.length > limit ? '...' : ''); + } + + // Handle strings (comma-separated values) + if (typeof value === 'string') { + const items = value.split(',').map(item => item.trim()); + if (items.length <= limit) return value; + + const limitedItems = items.slice(0, limit); + return `${limitedItems.join(', ')}...`; + } + + // For other types, return as-is + return value; +}); + +export default HandlebarsRenderer; diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx index e8b57f9f16b..be9359a6162 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx @@ -17,8 +17,6 @@ * under the License. */ -// These are control configurations that are shared ONLY within the DeckGL viz plugin repo. - import { FeatureFlag, isFeatureEnabled, @@ -42,6 +40,7 @@ import { ColorSchemeType, isColorSchemeTypeVisible, } from './utils'; +import { TooltipTemplateControl } from './TooltipTemplateControl'; const categoricalSchemeRegistry = getCategoricalSchemeRegistry(); const sequentialSchemeRegistry = getSequentialSchemeRegistry(); @@ -344,9 +343,7 @@ export const viewport = { label: t('Viewport'), renderTrigger: false, description: t('Parameters related to the view and perspective on the map'), - // default is whole world mostly centered default: DEFAULT_VIEWPORT, - // Viewport changes shouldn't prompt user to re-run query dontRefreshOnChange: true, }, }; @@ -446,6 +443,78 @@ export const geojsonColumn = { }, }; +const extractMetricsFromFormData = (formData: any) => { + const metrics = new Set(); + + if (formData.metrics) { + (Array.isArray(formData.metrics) + ? formData.metrics + : [formData.metrics] + ).forEach((metric: any) => metrics.add(metric)); + } + + if (formData.point_radius_fixed?.value) { + metrics.add(formData.point_radius_fixed.value); + } + + Object.entries(formData).forEach(([, value]) => { + if (!value || typeof value !== 'object') return; + if ((value as any).type === 'metric' && (value as any).value) { + metrics.add((value as any).value); + } + }); + + return Array.from(metrics).filter(metric => metric != null); +}; + +export const tooltipContents = { + name: 'tooltip_contents', + config: { + type: 'DndColumnMetricSelect', + label: t('Tooltip contents'), + multi: true, + freeForm: true, + clearable: true, + default: [], + description: t( + 'Drag columns and metrics here to customize tooltip content. Order matters - items will appear in the same order in tooltips. Click the button to manually select columns and metrics.', + ), + ghostButtonText: t('Drop columns/metrics here or click'), + disabledTabs: new Set(['saved', 'sqlExpression']), + mapStateToProps: (state: any) => { + const { datasource, form_data: formData } = state; + + const selectedMetrics = formData + ? extractMetricsFromFormData(formData) + : []; + + return { + columns: datasource?.columns || [], + savedMetrics: datasource?.metrics || [], + datasource, + selectedMetrics, + disabledTabs: new Set(['saved', 'sqlExpression']), + formData, + }; + }, + }, +}; + +export const tooltipTemplate = { + name: 'tooltip_template', + config: { + type: TooltipTemplateControl, + label: t('Customize tooltips template'), + debounceDelay: 30, + default: '', + description: '', + placeholder: '', + mapStateToProps: (state: any, control: any) => ({ + value: control.value, + }), + }, +}; + export const deckGLCategoricalColorSchemeTypeSelect: CustomControlItem = { name: 'color_scheme_type', config: { diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/TooltipTemplateControl.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/TooltipTemplateControl.tsx new file mode 100644 index 00000000000..163f078179b --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/TooltipTemplateControl.tsx @@ -0,0 +1,82 @@ +/** + * 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 { useCallback } from 'react'; +import { debounce } from 'lodash'; +import { t, useTheme } from '@superset-ui/core'; +import { InfoTooltip, Constants } from '@superset-ui/core/components'; +import { ControlHeader } from '@superset-ui/chart-controls'; +import { TooltipTemplateEditor } from './TooltipTemplateEditor'; + +interface TooltipTemplateControlProps { + value: string; + onChange: (value: string) => void; + label?: string; + name: string; + height?: number; +} + +const debounceFunc = debounce( + (func: (val: string) => void, source: string) => func(source), + Constants.SLOW_DEBOUNCE, +); + +export function TooltipTemplateControl({ + value, + onChange, + label, + name, +}: TooltipTemplateControlProps) { + const theme = useTheme(); + + const handleTemplateChange = useCallback( + (newValue: string) => { + debounceFunc(onChange, newValue || ''); + }, + [onChange], + ); + + const tooltipContent = t( + 'Use Handlebars syntax to create custom tooltips. Available variables are based on your tooltip contents selection above.', + ); + + return ( +
+ + {label || t('Customize tooltips template')} + + + } + /> + +
+ ); +} + +export default TooltipTemplateControl; diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/TooltipTemplateEditor.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/TooltipTemplateEditor.tsx new file mode 100644 index 00000000000..f7a1a166f12 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/TooltipTemplateEditor.tsx @@ -0,0 +1,76 @@ +/** + * 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 { useCallback, useEffect, useState } from 'react'; +import { styled, css, useThemeMode } from '@superset-ui/core'; +import { CodeEditor } from '@superset-ui/core/components'; + +const EditorContainer = styled.div` + ${({ theme }) => css` + min-height: ${theme.sizeUnit * 50}px; + width: 100%; + + .ace_editor { + font-family: ${theme.fontFamilyCode}; + } + `} +`; + +interface TooltipTemplateEditorProps { + value: string; + onChange: (value: string) => void; + name: string; +} + +export function TooltipTemplateEditor({ + value, + onChange, + name, +}: TooltipTemplateEditorProps) { + const [localValue, setLocalValue] = useState(value); + const isDarkMode = useThemeMode(); + + useEffect(() => { + setLocalValue(value); + }, [value]); + + const handleChange = useCallback( + (newValue: string) => { + setLocalValue(newValue); + onChange(newValue); + }, + [onChange], + ); + + return ( +
+ + + +
+ ); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/controlRegistry.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/controlRegistry.tsx new file mode 100644 index 00000000000..0e20c62e944 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/controlRegistry.tsx @@ -0,0 +1,57 @@ +/** + * 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 { ControlType } from '@superset-ui/chart-controls'; +import { TooltipTemplateControl } from './TooltipTemplateControl'; + +/** + * Registry for custom control components used in DeckGL charts + */ +export const deckGLControlRegistry = { + TooltipTemplateControl, +}; + +/** + * Expand control type to include local DeckGL controls + */ +export function expandDeckGLControlType(controlType: ControlType) { + if (typeof controlType === 'string' && controlType in deckGLControlRegistry) { + return deckGLControlRegistry[ + controlType as keyof typeof deckGLControlRegistry + ]; + } + return controlType; +} + +/** + * HOC to wrap control components with DeckGL-specific logic + */ +export function withDeckGLControls(Component: React.ComponentType) { + return function DeckGLControlWrapper(props: any) { + const { type, ...otherProps } = props; + const ExpandedComponent = expandDeckGLControlType(type) || Component; + if (typeof ExpandedComponent === 'string') { + // If it's a string, it's a built-in control type, use the original Component + return ; + } + return ; + }; +} + +export default deckGLControlRegistry; diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/multiValueUtils.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/multiValueUtils.ts new file mode 100644 index 00000000000..201c40ae384 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/multiValueUtils.ts @@ -0,0 +1,142 @@ +/** + * 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'; + +interface TooltipItem { + item_type?: string; + column_name?: string; + metric_name?: string; + label?: string; + verbose_name?: string; +} + +export const AGGREGATED_DECK_GL_CHART_TYPES = [ + 'deck_screengrid', + 'deck_heatmap', + 'deck_contour', + 'deck_hex', + 'deck_grid', +]; + +export const NON_AGGREGATED_DECK_GL_CHART_TYPES = [ + 'deck_scatter', + 'deck_arc', + 'deck_path', + 'deck_polygon', + 'deck_geojson', +]; + +export function isAggregatedDeckGLChart(vizType: string): boolean { + return AGGREGATED_DECK_GL_CHART_TYPES.includes(vizType); +} + +export function fieldHasMultipleValues( + item: TooltipItem | string, + formData: QueryFormData, +): boolean { + if (!isAggregatedDeckGLChart(formData.viz_type)) { + return false; + } + + if (typeof item === 'object' && item?.item_type === 'metric') { + return false; + } + + // TODO: Currently only screengrid supports multi-value fields. Support for other aggregated charts will be added in future releases + const supportsMultiValue = ['deck_screengrid'].includes(formData.viz_type); + + if (!supportsMultiValue) { + return false; + } + + if (typeof item === 'object' && item?.item_type === 'column') { + return true; + } + + if (typeof item === 'string') { + return true; + } + + return false; +} + +const getFieldName = (item: TooltipItem | string): string | null => { + if (typeof item === 'string') return item; + if (item?.item_type === 'column') return item.column_name ?? null; + if (item?.item_type === 'metric') + return item.metric_name ?? item.label ?? null; + return null; +}; + +const getFieldLabel = (item: TooltipItem | string): string => { + if (typeof item === 'string') return item; + if (item?.item_type === 'column') { + return item.verbose_name || item.column_name || 'Column'; + } + if (item?.item_type === 'metric') { + return item.verbose_name || item.metric_name || item.label || 'Metric'; + } + return 'Field'; +}; + +const createMultiValueTemplate = ( + fieldName: string, + fieldLabel: string, +): string => { + const pluralFieldName = `${fieldName}s`; + return `
${fieldLabel}: {{#if ${pluralFieldName}}}{{limit ${pluralFieldName} 10}}{{#if ${fieldName}_count}} ({{${fieldName}_count}} total){{/if}}{{else}}N/A{{/if}}
`; +}; + +const createSingleValueTemplate = ( + fieldName: string, + fieldLabel: string, +): string => + `
${fieldLabel}: {{#if ${fieldName}}}{{${fieldName}}}{{else}}N/A{{/if}}
`; + +export function createDefaultTemplateWithLimits( + tooltipContents: (TooltipItem | string)[], + formData: QueryFormData, +): string { + if (!tooltipContents?.length) { + return ''; + } + + const templateLines: string[] = []; + + tooltipContents.forEach(item => { + const fieldName = getFieldName(item); + const fieldLabel = getFieldLabel(item); + + if (!fieldName) return; + + const hasMultipleValues = fieldHasMultipleValues(item, formData); + + if (hasMultipleValues) { + templateLines.push(createMultiValueTemplate(fieldName, fieldLabel)); + } else { + templateLines.push(createSingleValueTemplate(fieldName, fieldLabel)); + } + }); + + return templateLines.join('\n'); +} + +export const MULTI_VALUE_WARNING_MESSAGE = + 'This metric or column contains many values, they may not be able to be all displayed in the tooltip'; diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/tooltipUtils.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/tooltipUtils.tsx new file mode 100644 index 00000000000..e2c1ffce232 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/tooltipUtils.tsx @@ -0,0 +1,384 @@ +/** + * 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 { t, JsonObject, QueryFormData } from '@superset-ui/core'; +import { useMemo, memo } from 'react'; +import { HandlebarsRenderer } from './HandlebarsRenderer'; +import TooltipRow from '../TooltipRow'; +import { createDefaultTemplateWithLimits } from './multiValueUtils'; + +const MemoizedHandlebarsRenderer = memo(HandlebarsRenderer); + +export const CommonTooltipRows = { + position: (o: JsonObject, position?: [number, number]) => ( + + ), + + arcPositions: (o: JsonObject) => ( + <> + + + + ), + + centroid: (o: JsonObject) => ( + + ), + + category: (o: JsonObject) => + o.object?.cat_color ? ( + + ) : null, + + metric: ( + o: JsonObject, + formData: QueryFormData, + verboseMap?: Record, + ) => { + const metricConfig = + formData.point_radius_fixed || formData.size || formData.metric; + if (!metricConfig) return null; + + const label = + verboseMap?.[metricConfig.value] || + metricConfig?.value || + metricConfig?.label || + 'Metric'; + return o.object?.metric ? ( + + ) : null; + }, +}; + +function extractValue( + o: JsonObject, + fieldName: string, + checkPoints = true, +): any { + let value = + o.object?.[fieldName] || + o.object?.properties?.[fieldName] || + o.object?.data?.[fieldName] || + ''; + + if (!value && checkPoints && Array.isArray(o.object?.points)) { + const allVals = o.object.points + .map((pt: any) => pt[fieldName]) + .filter((v: any) => v !== undefined && v !== null); + if (allVals.length > 0) { + value = allVals[0]; + return { value, allValues: allVals }; + } + } + + return { value, allValues: [] }; +} + +function formatValue(value: any): string { + if (value === '') return ''; + + if ( + typeof value === 'string' && + value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + ) { + return new Date(value).toLocaleString(); + } + + return `${value}`; +} + +function buildFieldBasedTooltipItems( + o: JsonObject, + formData: QueryFormData, +): JSX.Element[] { + const tooltipItems: JSX.Element[] = []; + + formData.tooltip_contents.forEach((item: any, index: number) => { + let label = ''; + let fieldName = ''; + + if (typeof item === 'string') { + label = item; + fieldName = item; + } else if (item.item_type === 'column') { + label = item.verbose_name || item.column_name || item.label; + fieldName = item.column_name; + } else if (item.item_type === 'metric') { + label = item.verbose_name || item.metric_name || item.label; + fieldName = item.metric_name || item.label; + } + + if (!label || !fieldName) return; + + let { value } = extractValue(o, fieldName); + if (!value && item.item_type === 'metric') { + value = o.object?.metric || ''; + } + + if ( + formData.viz_type === 'deck_screengrid' && + !value && + Array.isArray(o.object?.points) + ) { + const { allValues } = extractValue(o, fieldName); + if (allValues.length > 0) { + value = allValues.join(', '); + } + } + + if (value !== '') { + const formattedValue = formatValue(value); + tooltipItems.push( + , + ); + } + }); + + return tooltipItems; +} + +function createScreenGridData( + o: JsonObject, + fieldName: string, + extractResult: { value: any; allValues: any[] }, +): Record { + const result: Record = {}; + + if (extractResult.allValues.length > 0) { + result[fieldName] = extractResult.allValues; + result[`${fieldName}s`] = extractResult.allValues.join(', '); + result[`${fieldName}_count`] = extractResult.allValues.length; + } else { + const count = o.object?.count || 0; + const value = o.object?.value || 0; + const aggregatedValue = `Aggregated: ${count} points, total value: ${value}`; + result[fieldName] = aggregatedValue; + result[`${fieldName}_aggregated`] = aggregatedValue; + } + + return result; +} + +function processTooltipContentItem( + item: any, + o: JsonObject, + formData: QueryFormData, +): Record { + let fieldName = ''; + + if (typeof item === 'string') { + fieldName = item; + } else if (item?.item_type === 'column') { + fieldName = item.column_name; + } else if (item?.item_type === 'metric') { + fieldName = item.metric_name || item.label; + } + + if (!fieldName) return {}; + + const extractResult = extractValue(o, fieldName); + let { value } = extractResult; + + if (item?.item_type === 'metric' && !value) { + value = o.object?.metric || ''; + } + + if (formData.viz_type === 'deck_screengrid' && !value) { + return createScreenGridData(o, fieldName, extractResult); + } + + if (extractResult.allValues.length > 0) { + return { + [fieldName]: extractResult.allValues, + [`${fieldName}s`]: extractResult.allValues.join(', '), + [`${fieldName}_count`]: extractResult.allValues.length, + }; + } + + if (value !== '') { + return { [fieldName]: value }; + } + + return {}; +} + +export function createHandlebarsTooltipData( + o: JsonObject, + formData: QueryFormData, +): Record { + const initialData: Record = { + ...(o.object || {}), + coordinate: o.coordinate, + index: o.index, + picked: o.picked, + title: formData.viz_type || 'Chart', + coordinateString: o.coordinate + ? `${o.coordinate[0]}, ${o.coordinate[1]}` + : '', + positionString: o.object?.position + ? `${o.object.position[0]}, ${o.object.position[1]}` + : '', + threshold: o.object?.contour?.threshold, + contourThreshold: o.object?.contour?.threshold, + nearbyPoints: o.object?.nearbyPoints, + totalPoints: o.object?.totalPoints, + }; + + let data = { ...initialData }; + + if ( + formData.viz_type === 'deck_heatmap' || + formData.viz_type === 'deck_contour' + ) { + if (o.object?.position) { + data = { + ...data, + LON: o.object.position[0], + LAT: o.object.position[1], + }; + } + if (o.coordinate) { + data = { + ...data, + LON: o.coordinate[0], + LAT: o.coordinate[1], + }; + } + + if (!o.object && formData.viz_type === 'deck_heatmap') { + data = { + ...data, + aggregated: true, + note: 'Aggregated cell - individual point data not available', + }; + } + } + + if (formData.tooltip_contents && formData.tooltip_contents.length > 0) { + const tooltipData = formData.tooltip_contents.reduce( + (acc: any, item: any) => { + const itemData = processTooltipContentItem(item, o, formData); + return { ...acc, ...itemData }; + }, + {}, + ); + + data = { ...data, ...tooltipData }; + } + + return data; +} + +export function generateEnhancedDefaultTemplate( + tooltipContents: any[], + formData: QueryFormData, +): string { + return createDefaultTemplateWithLimits(tooltipContents, formData); +} + +export function useTooltipContent( + formData: QueryFormData, + defaultTooltipGenerator: (o: JsonObject) => JSX.Element, +) { + const tooltipContentGenerator = useMemo( + () => (o: JsonObject) => { + if ( + formData.tooltip_template?.trim() && + !formData.tooltip_template.includes( + 'Drop columns/metrics in "Tooltip contents" above', + ) + ) { + const tooltipData = createHandlebarsTooltipData(o, formData); + return ( +
+ +
+ ); + } + + if (formData.tooltip_contents && formData.tooltip_contents.length > 0) { + const tooltipItems = buildFieldBasedTooltipItems(o, formData); + return
{tooltipItems}
; + } + + return defaultTooltipGenerator(o); + }, + [ + formData.tooltip_template, + formData.tooltip_contents, + formData.viz_type, + defaultTooltipGenerator, + ], + ); + + return tooltipContentGenerator; +} + +export function createTooltipContent( + formData: QueryFormData, + defaultTooltipGenerator: (o: JsonObject) => JSX.Element, +) { + return (o: JsonObject) => { + if ( + formData.tooltip_template?.trim() && + !formData.tooltip_template.includes( + 'Drop columns/metrics in "Tooltip contents" above', + ) + ) { + const tooltipData = createHandlebarsTooltipData(o, formData); + return ( +
+ +
+ ); + } + + if (formData.tooltip_contents && formData.tooltip_contents.length > 0) { + const tooltipItems = buildFieldBasedTooltipItems(o, formData); + return
{tooltipItems}
; + } + + return defaultTooltipGenerator(o); + }; +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/utils.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/utils.ts index 107a6ac0578..bf84124838b 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/utils.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/utils.ts @@ -28,8 +28,6 @@ export const COLOR_SCHEME_TYPES = { export type ColorSchemeType = (typeof COLOR_SCHEME_TYPES)[keyof typeof COLOR_SCHEME_TYPES]; -/* eslint camelcase: 0 */ - export function formatSelectOptions(options: (string | number)[]) { return options.map(opt => [opt, opt.toString()]); } diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/tsconfig.json b/superset-frontend/plugins/legacy-preset-chart-deckgl/tsconfig.json index b3af9a1d193..b5b28fc6189 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/tsconfig.json +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/tsconfig.json @@ -4,7 +4,10 @@ "composite": true, "rootDir": "src", "outDir": "lib", - "baseUrl": "." + "baseUrl": ".", + "paths": { + "@superset-ui/core/components": ["../../packages/superset-ui-core/src/components"] + } }, "include": ["src/**/*", "types/**/*"], "exclude": ["lib", "test"], diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/components/CodeEditor/CodeEditor.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/components/CodeEditor/CodeEditor.tsx index ef37e5468ae..099f7c56503 100644 --- a/superset-frontend/plugins/plugin-chart-handlebars/src/components/CodeEditor/CodeEditor.tsx +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/components/CodeEditor/CodeEditor.tsx @@ -17,64 +17,9 @@ * under the License. */ -import { FC } from 'react'; -import AceEditor, { IAceEditorProps } from 'react-ace'; - -// must go after AceEditor import -import 'ace-builds/src-min-noconflict/mode-handlebars'; -import 'ace-builds/src-min-noconflict/mode-css'; -import 'ace-builds/src-noconflict/theme-github'; -import 'ace-builds/src-noconflict/theme-monokai'; - -export type CodeEditorMode = 'handlebars' | 'css'; -export type CodeEditorTheme = 'light' | 'dark'; - -export interface CodeEditorProps extends IAceEditorProps { - mode?: CodeEditorMode; - theme?: CodeEditorTheme; - name?: string; -} - -export const CodeEditor: FC = ({ - mode, - theme, - name, - width, - height, - value, - ...rest -}: CodeEditorProps) => { - const m_name = name || Math.random().toString(36).substring(7); - const m_theme = theme === 'light' ? 'github' : 'monokai'; - const m_mode = mode || 'handlebars'; - const m_height = height || '300px'; - const m_width = width || '100%'; - - return ( -
- -
- ); -}; +export { + CodeEditor, + type CodeEditorProps, + type CodeEditorMode, + type CodeEditorTheme, +} from '@superset-ui/core/components'; diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/components/Handlebars/HandlebarsViewer.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/components/Handlebars/HandlebarsViewer.tsx index 701d8949b67..c897605f570 100644 --- a/superset-frontend/plugins/plugin-chart-handlebars/src/components/Handlebars/HandlebarsViewer.tsx +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/components/Handlebars/HandlebarsViewer.tsx @@ -76,7 +76,7 @@ export const HandlebarsViewer = ({ return

{t('Loading...')}

; }; -// usage: {{dateFormat my_date format="MMMM YYYY"}} +// usage: {{ dateFormat my_date format="MMMM YYYY" }} Handlebars.registerHelper('dateFormat', function (context, block) { const f = block.hash.format || 'YYYY-MM-DD'; return dayjs(context).format(f); diff --git a/superset-frontend/spec/helpers/setup.ts b/superset-frontend/spec/helpers/setup.ts index 6af5a181ff1..9ec6d6661fe 100644 --- a/superset-frontend/spec/helpers/setup.ts +++ b/superset-frontend/spec/helpers/setup.ts @@ -32,3 +32,9 @@ expect.extend(matchers); // Allow JSX tests to have React import readily available global.React = React; + +// Mock ace-builds globally for tests +jest.mock('ace-builds/src-min-noconflict/mode-handlebars', () => ({})); +jest.mock('ace-builds/src-min-noconflict/mode-css', () => ({})); +jest.mock('ace-builds/src-noconflict/theme-github', () => ({})); +jest.mock('ace-builds/src-noconflict/theme-monokai', () => ({})); diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx index 8ab23684775..b39f433afcb 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx @@ -149,9 +149,35 @@ const Styles = styled.div` } .tab-content { - overflow: auto; + overflow: visible; flex: 1 1 100%; } + + // Ensure Ant Design tabs allow content to expand + .ant-tabs-content { + overflow: visible; + height: auto; + } + + .ant-tabs-content-holder { + overflow: visible; + height: auto; + } + + .ant-tabs-tabpane { + overflow: visible; + height: auto; + } + + // Ensure collapse components can expand + .ant-collapse-content { + overflow: visible; + } + + .ant-collapse-content-box { + overflow: visible; + } + .Select__menu { max-width: 100%; } diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index 7781f13045c..1fd22603de4 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -247,6 +247,28 @@ function setSidebarWidths(key, dimension) { setItem(key, newDimension); } +// Chart types that use aggregation and can have multiple values in tooltips +const AGGREGATED_CHART_TYPES = [ + // Deck.gl aggregated charts + 'deck_screengrid', + 'deck_heatmap', + 'deck_contour', + 'deck_hex', + 'deck_grid', + // Other aggregated chart types can be added here + 'heatmap', + 'treemap', + 'sunburst', + 'pie', + 'donut', + 'histogram', + 'table', +]; + +function isAggregatedChartType(vizType) { + return AGGREGATED_CHART_TYPES.includes(vizType); +} + function ExploreViewContainer(props) { const dynamicPluginContext = usePluginContext(); const dynamicPlugin = dynamicPluginContext.dynamicPlugins[props.vizType]; @@ -349,6 +371,15 @@ function ExploreViewContainer(props) { props.form_data, ]); + // Simple debounced auto-query for non-renderTrigger controls + const debouncedAutoQuery = useMemo( + () => + debounce(() => { + onQuery(); + }, 1000), // 1 second delay + [onQuery], + ); + const handleKeydown = useCallback( event => { const controlOrCommand = event.ctrlKey || event.metaKey; @@ -488,6 +519,53 @@ function ExploreViewContainer(props) { ), ); + if (changedControlKeys.includes('tooltip_contents')) { + const tooltipContents = props.controls.tooltip_contents?.value || []; + const currentTemplate = props.controls.tooltip_template?.value || ''; + + if (tooltipContents.length > 0) { + const getFieldName = item => { + if (typeof item === 'string') return item; + if (item?.item_type === 'column') return item.column_name; + if (item?.item_type === 'metric') { + return item.metric_name || item.label; + } + return null; + }; + + const vizType = props.form_data?.viz_type || ''; + const isAggregatedChart = isAggregatedChartType(vizType); + + const DEFAULT_TOOLTIP_LIMIT = 10; // Maximum number of values to show in aggregated tooltips + + const fieldNames = tooltipContents.map(getFieldName).filter(Boolean); + const missingVariables = fieldNames.filter( + fieldName => + !currentTemplate.includes(`{{ ${fieldName} }}`) && + !currentTemplate.includes(`{{ limit ${fieldName}`), + ); + + if (missingVariables.length > 0) { + const newVariables = missingVariables.map(fieldName => { + const item = tooltipContents[fieldNames.indexOf(fieldName)]; + const isColumn = + item?.item_type === 'column' || typeof item === 'string'; + + if (isAggregatedChart && isColumn) { + return `{{ limit ${fieldName} ${DEFAULT_TOOLTIP_LIMIT} }}`; + } + return `{{ ${fieldName} }}`; + }); + const updatedTemplate = + currentTemplate + + (currentTemplate ? ' ' : '') + + newVariables.join(' '); + + props.actions.setControlValue('tooltip_template', updatedTemplate); + } + } + } + // this should also be handled by the actions that are actually changing the controls const displayControlsChanged = changedControlKeys.filter( key => props.controls[key].renderTrigger, @@ -495,8 +573,25 @@ function ExploreViewContainer(props) { if (displayControlsChanged.length > 0) { reRenderChart(displayControlsChanged); } + + // Auto-update for non-renderTrigger controls + const queryControlsChanged = changedControlKeys.filter( + key => + !props.controls[key].renderTrigger && + !props.controls[key].dontRefreshOnChange, + ); + if (queryControlsChanged.length > 0) { + // Check if there are no validation errors before auto-updating + const hasErrors = Object.values(props.controls).some( + control => + control.validationErrors && control.validationErrors.length > 0, + ); + if (!hasErrors) { + debouncedAutoQuery(); + } + } } - }, [props.controls, props.ownState]); + }, [props.controls, props.ownState, debouncedAutoQuery]); const chartIsStale = useMemo(() => { if (lastQueriedControls) { @@ -790,7 +885,7 @@ function mapStateToProps(state) { saveModal, } = state; const { controls, slice, datasource, metadata, hiddenFormData } = explore; - const hasQueryMode = !!controls.query_mode?.value; + const hasQueryMode = !!controls?.query_mode?.value; const fieldsToOmit = hasQueryMode ? retainQueryModeRequirements(hiddenFormData) : Object.keys(hiddenFormData ?? {}); @@ -835,6 +930,7 @@ function mapStateToProps(state) { } if ( + controls && form_data.viz_type === 'big_number_total' && slice?.form_data?.subheader && (!controls.subtitle?.value || controls.subtitle.value === '') diff --git a/superset-frontend/src/explore/components/RunQueryButton/index.tsx b/superset-frontend/src/explore/components/RunQueryButton/index.tsx index c03cdc1eba7..d8b3c4c3e58 100644 --- a/superset-frontend/src/explore/components/RunQueryButton/index.tsx +++ b/superset-frontend/src/explore/components/RunQueryButton/index.tsx @@ -31,6 +31,7 @@ export type RunQueryButtonProps = { canStopQuery: boolean; chartIsStale: boolean; }; + export const RunQueryButton = ({ loading, onQuery, diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx index 3c61ab85329..2d8b19812e0 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx @@ -34,6 +34,9 @@ import { styled, css, DatasourceType, + Metric, + QueryFormMetric, + // useTheme, } from '@superset-ui/core'; import { ColumnMeta, isSavedExpression } from '@superset-ui/chart-controls'; import Tabs from '@superset-ui/core/components/Tabs'; @@ -74,10 +77,24 @@ const StyledSelect = styled(Select)` } `; +const MetricOptionContainer = styled.div` + display: flex; + align-items: center; +`; + +const MetricIcon = styled.span` + margin-right: ${({ theme }) => theme.sizeUnit * 2}px; + color: ${({ theme }) => theme.colorSuccess}; +`; + +const MetricLabel = styled.span` + color: ${({ theme }) => theme.colorText}; +`; + export interface ColumnSelectPopoverProps { columns: ColumnMeta[]; editedColumn?: ColumnMeta | AdhocColumn; - onChange: (column: ColumnMeta | AdhocColumn) => void; + onChange: (column: ColumnMeta | AdhocColumn | Metric) => void; onClose: () => void; hasCustomLabel: boolean; setLabel: (title: string) => void; @@ -86,6 +103,8 @@ export interface ColumnSelectPopoverProps { isTemporal?: boolean; setDatasetModal?: Dispatch>; disabledTabs?: Set; + metrics?: Metric[]; + selectedMetrics?: QueryFormMetric[]; datasource?: any; } @@ -116,8 +135,11 @@ const ColumnSelectPopover = ({ setDatasetModal, setLabel, disabledTabs = new Set<'saved' | 'simple' | 'sqlExpression'>(), + metrics = [], + selectedMetrics = [], datasource, }: ColumnSelectPopoverProps) => { + // const theme = useTheme(); // Unused variable const datasourceType = useSelector( state => state.explore.datasource.type, ); @@ -134,6 +156,9 @@ const ColumnSelectPopover = ({ const [selectedSimpleColumn, setSelectedSimpleColumn] = useState< ColumnMeta | undefined >(initialSimpleColumn); + const [selectedMetric, setSelectedMetric] = useState( + undefined, + ); const [selectedTab, setSelectedTab] = useState(null); const [resizeButton, width, height] = useResizeButton( @@ -159,11 +184,31 @@ const ColumnSelectPopover = ({ [columns], ); + // Filter metrics that are already selected in the chart + const availableMetrics = useMemo(() => { + if (!metrics?.length) return []; + const selectedMetricsSet = new Set(selectedMetrics); + return metrics.filter(metric => selectedMetricsSet.has(metric.metric_name)); + }, [metrics, selectedMetrics]); + + const columnMap = useMemo( + () => Object.fromEntries(simpleColumns.map(col => [col.column_name, col])), + [simpleColumns], + ); + const metricMap = useMemo( + () => + Object.fromEntries( + availableMetrics.map(metric => [metric.metric_name, metric]), + ), + [availableMetrics], + ); + const onSqlExpressionChange = useCallback( sqlExpression => { setAdhocColumn({ label, sqlExpression, expressionType: 'SQL' }); setSelectedSimpleColumn(undefined); setSelectedCalculatedColumn(undefined); + setSelectedMetric(undefined); }, [label], ); @@ -175,6 +220,7 @@ const ColumnSelectPopover = ({ ); setSelectedCalculatedColumn(selectedColumn); setSelectedSimpleColumn(undefined); + setSelectedMetric(undefined); setAdhocColumn(undefined); setLabel( selectedColumn?.verbose_name || selectedColumn?.column_name || '', @@ -190,6 +236,7 @@ const ColumnSelectPopover = ({ ); setSelectedCalculatedColumn(undefined); setSelectedSimpleColumn(selectedColumn); + setSelectedMetric(undefined); setAdhocColumn(undefined); setLabel( selectedColumn?.verbose_name || selectedColumn?.column_name || '', @@ -198,6 +245,38 @@ const ColumnSelectPopover = ({ [setLabel, simpleColumns], ); + const onSimpleMetricChange = useCallback( + selectedMetricName => { + const selectedMetric = availableMetrics.find( + metric => metric.metric_name === selectedMetricName, + ); + setSelectedCalculatedColumn(undefined); + setSelectedSimpleColumn(undefined); + setSelectedMetric(selectedMetric); + setAdhocColumn(undefined); + setLabel( + selectedMetric?.verbose_name || selectedMetric?.metric_name || '', + ); + }, + [setLabel, availableMetrics], + ); + + const onSimpleItemChange = useCallback( + selectedValue => { + const selectedColumn = columnMap[selectedValue]; + if (selectedColumn) { + onSimpleColumnChange(selectedValue); + return; + } + + const selectedMetric = metricMap[selectedValue]; + if (selectedMetric) { + onSimpleMetricChange(selectedValue); + } + }, + [columnMap, metricMap, onSimpleColumnChange, onSimpleMetricChange], + ); + const defaultActiveTabKey = initialAdhocColumn ? 'sqlExpression' : selectedCalculatedColumn @@ -241,10 +320,11 @@ const ColumnSelectPopover = ({ } const selectedColumn = adhocColumn || selectedCalculatedColumn || selectedSimpleColumn; - if (!selectedColumn) { + const selectedItem = selectedColumn || selectedMetric; + if (!selectedItem) { return; } - onChange(selectedColumn); + onChange(selectedItem); onClose(); }, [ adhocColumn, @@ -253,11 +333,13 @@ const ColumnSelectPopover = ({ onClose, selectedCalculatedColumn, selectedSimpleColumn, + selectedMetric, ]); const onResetStateAndClose = useCallback(() => { setSelectedCalculatedColumn(initialCalculatedColumn); setSelectedSimpleColumn(initialSimpleColumn); + setSelectedMetric(undefined); setAdhocColumn(initialAdhocColumn); onClose(); }, [ @@ -285,16 +367,20 @@ const ColumnSelectPopover = ({ }; const stateIsValid = - adhocColumn || selectedCalculatedColumn || selectedSimpleColumn; + adhocColumn || + selectedCalculatedColumn || + selectedSimpleColumn || + selectedMetric; const hasUnsavedChanges = initialLabel !== label || selectedCalculatedColumn?.column_name !== initialCalculatedColumn?.column_name || selectedSimpleColumn?.column_name !== initialSimpleColumn?.column_name || + selectedMetric?.metric_name !== undefined || adhocColumn?.sqlExpression !== initialAdhocColumn?.sqlExpression; const savedExpressionsLabel = t('Saved expressions'); - const simpleColumnsLabel = t('Column'); + const simpleColumnsLabel = t('Columns and metrics'); const keywords = useMemo( () => sqlKeywords.concat(getColumnKeywords(columns)), [columns], @@ -313,95 +399,103 @@ const ColumnSelectPopover = ({ width: ${width}px; `} items={[ - { - key: TABS_KEYS.SAVED, - label: t('Saved'), - disabled: disabledTabs.has('saved'), - children: ( - <> - {calculatedColumns.length > 0 ? ( - - ({ - value: calculatedColumn.column_name, - label: ( - + {calculatedColumns.length > 0 ? ( + + ({ + value: calculatedColumn.column_name, + label: ( + + ), + key: calculatedColumn.column_name, + }), + )} /> - ), - key: calculatedColumn.column_name, - }))} - /> - - ) : datasourceType === DatasourceType.Table ? ( - - ) : ( - - - {t('Create a dataset')} - {' '} - {t(' to mark a column as a time column')} - + + ) : datasourceType === DatasourceType.Table ? ( + ) : ( - <> - - {t('Create a dataset')} - {' '} - {t(' to add calculated columns')} - - ) - } - /> - )} - - ), - }, + + + {t('Create a dataset')} + {' '} + {t(' to mark a column as a time column')} + + ) : ( + <> + + {t('Create a dataset')} + {' '} + {t(' to add calculated columns')} + + ) + } + /> + )} + + ), + }, + ]), { key: TABS_KEYS.SIMPLE, label: t('Simple'), - disabled: disabledTabs.has('simple'), children: ( <> {isTemporal && simpleColumns.length === 0 ? ( @@ -432,18 +526,41 @@ const ColumnSelectPopover = ({