diff --git a/licenses.yaml b/licenses.yaml index c4e2fa52300b..e1e7fe2e8903 100644 --- a/licenses.yaml +++ b/licenses.yaml @@ -5089,24 +5089,6 @@ version: 0.22.22 --- -name: "@druid-toolkit/visuals-core" -license_category: binary -module: web-console -license_name: Apache License version 2.0 -copyright: Imply Data -version: 0.3.3 - ---- - -name: "@druid-toolkit/visuals-react" -license_category: binary -module: web-console -license_name: Apache License version 2.0 -copyright: Imply Data -version: 0.3.3 - ---- - name: "@emotion/cache" license_category: binary module: web-console @@ -5207,15 +5189,6 @@ license_file_path: licenses/bin/@fontsource-open-sans.OFL --- -name: "@juggle/resize-observer" -license_category: binary -module: web-console -license_name: Apache License version 2.0 -copyright: Juggle -version: 3.4.0 - ---- - name: "@popperjs/core" license_category: binary module: web-console @@ -5575,6 +5548,16 @@ license_file_path: licenses/bin/d3-interpolate.BSD3 --- +name: "d3-scale-chromatic" +license_category: binary +module: web-console +license_name: ISC License +copyright: Mike Bostock +version: 3.1.0 +license_file_path: licenses/bin/d3-scale-chromatic.ISC + +--- + name: "d3-scale" license_category: binary module: web-console @@ -6561,16 +6544,6 @@ license_file_path: licenses/bin/upper-case.MIT --- -name: "use-resize-observer" -license_category: binary -module: web-console -license_name: MIT License -copyright: Viktor Hubert -version: 9.1.0 -license_file_path: licenses/bin/use-resize-observer.MIT - ---- - name: "use-sync-external-store" license_category: binary module: web-console diff --git a/web-console/README.md b/web-console/README.md index ff5012344bd4..f9a7f7abbb5d 100644 --- a/web-console/README.md +++ b/web-console/README.md @@ -59,7 +59,8 @@ The console relies on [eslint](https://eslint.org) (and various plugins), [sass- - Install `dbaeumer.vscode-eslint` extension - Install `esbenp.prettier-vscode` extension -- Open User Settings (JSON) and set the following: +- Select `Open User Settings (JSON)` from the editor commnads (`Ctrl+Shift+P` or `Comand+Shift+P`) and set the following: + ```json "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, diff --git a/web-console/package-lock.json b/web-console/package-lock.json index 44d3cbae2a43..e77b282c002b 100644 --- a/web-console/package-lock.json +++ b/web-console/package-lock.json @@ -15,8 +15,6 @@ "@blueprintjs/icons": "^5.10.0", "@blueprintjs/select": "^5.2.1", "@druid-toolkit/query": "^0.22.22", - "@druid-toolkit/visuals-core": "^0.3.3", - "@druid-toolkit/visuals-react": "^0.3.3", "@fontsource/open-sans": "^5.0.28", "ace-builds": "~1.4.14", "axios": "^1.7.4", @@ -28,6 +26,7 @@ "d3-axis": "^2.1.0", "d3-dsv": "^2.0.0", "d3-scale": "^3.3.0", + "d3-scale-chromatic": "^3.1.0", "d3-selection": "^2.0.0", "date-fns": "^2.28.0", "echarts": "^5.4.3", @@ -62,6 +61,7 @@ "@types/d3-axis": "^2.1.3", "@types/d3-dsv": "^2.0.0", "@types/d3-scale": "^3.3.2", + "@types/d3-scale-chromatic": "^3.0.3", "@types/d3-selection": "^2.0.1", "@types/enzyme": "^3.10.17", "@types/enzyme-adapter-react-16": "^1.0.9", @@ -996,30 +996,6 @@ "tslib": "^2.5.2" } }, - "node_modules/@druid-toolkit/visuals-core": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@druid-toolkit/visuals-core/-/visuals-core-0.3.3.tgz", - "integrity": "sha512-Oze2M6LBxNIstFQTI68qayZs6vchtRiTAtIvuyvvalh3RGUqblJ91stMvh+9FtnHUBkr6J7J2C30L3VpDd0LTQ==", - "dependencies": { - "@druid-toolkit/query": "*", - "json-bigint-native": "^1.2.0", - "zustand": "^4.3.2" - } - }, - "node_modules/@druid-toolkit/visuals-react": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@druid-toolkit/visuals-react/-/visuals-react-0.3.3.tgz", - "integrity": "sha512-1WKTA7y2bd2LWA1as9bdAk7tPHkKWkgtcH6P7yZZDzooi1wVhgLWhREpvJFHsyIsau2ZHMYDiZkiDESrc90lIA==", - "dependencies": { - "@druid-toolkit/query": "*", - "@druid-toolkit/visuals-core": "*", - "use-resize-observer": "^9.1.0", - "zustand": "^4.3.2" - }, - "peerDependencies": { - "react": "^18.1.0" - } - }, "node_modules/@emotion/cache": { "version": "10.0.29", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", @@ -2120,11 +2096,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@juggle/resize-observer": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", - "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" - }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", @@ -2503,6 +2474,12 @@ "@types/d3-time": "^2" } }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz", + "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==", + "dev": true + }, "node_modules/@types/d3-selection": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.1.tgz", @@ -5437,6 +5414,18 @@ "d3-time-format": "2 - 3" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale/node_modules/d3-time": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", @@ -17477,18 +17466,6 @@ "integrity": "sha1-iS/pWWCAXoVRnxzUOJ8stMu3ZS8=", "dev": true }, - "node_modules/use-resize-observer": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz", - "integrity": "sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==", - "dependencies": { - "@juggle/resize-observer": "^3.3.1" - }, - "peerDependencies": { - "react": "16.8.0 - 18", - "react-dom": "16.8.0 - 18" - } - }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -19100,27 +19077,6 @@ "tslib": "^2.5.2" } }, - "@druid-toolkit/visuals-core": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@druid-toolkit/visuals-core/-/visuals-core-0.3.3.tgz", - "integrity": "sha512-Oze2M6LBxNIstFQTI68qayZs6vchtRiTAtIvuyvvalh3RGUqblJ91stMvh+9FtnHUBkr6J7J2C30L3VpDd0LTQ==", - "requires": { - "@druid-toolkit/query": "*", - "json-bigint-native": "^1.2.0", - "zustand": "^4.3.2" - } - }, - "@druid-toolkit/visuals-react": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@druid-toolkit/visuals-react/-/visuals-react-0.3.3.tgz", - "integrity": "sha512-1WKTA7y2bd2LWA1as9bdAk7tPHkKWkgtcH6P7yZZDzooi1wVhgLWhREpvJFHsyIsau2ZHMYDiZkiDESrc90lIA==", - "requires": { - "@druid-toolkit/query": "*", - "@druid-toolkit/visuals-core": "*", - "use-resize-observer": "^9.1.0", - "zustand": "^4.3.2" - } - }, "@emotion/cache": { "version": "10.0.29", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", @@ -19980,11 +19936,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "@juggle/resize-observer": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", - "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" - }, "@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", @@ -20309,6 +20260,12 @@ "@types/d3-time": "^2" } }, + "@types/d3-scale-chromatic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz", + "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==", + "dev": true + }, "@types/d3-selection": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.1.tgz", @@ -22606,6 +22563,15 @@ } } }, + "d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "requires": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + } + }, "d3-selection": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz", @@ -31520,14 +31486,6 @@ "integrity": "sha1-iS/pWWCAXoVRnxzUOJ8stMu3ZS8=", "dev": true }, - "use-resize-observer": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz", - "integrity": "sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==", - "requires": { - "@juggle/resize-observer": "^3.3.1" - } - }, "use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", diff --git a/web-console/package.json b/web-console/package.json index 9c9f38d24cba..175fc9415f3f 100644 --- a/web-console/package.json +++ b/web-console/package.json @@ -69,8 +69,6 @@ "@blueprintjs/icons": "^5.10.0", "@blueprintjs/select": "^5.2.1", "@druid-toolkit/query": "^0.22.22", - "@druid-toolkit/visuals-core": "^0.3.3", - "@druid-toolkit/visuals-react": "^0.3.3", "@fontsource/open-sans": "^5.0.28", "ace-builds": "~1.4.14", "axios": "^1.7.4", @@ -82,6 +80,7 @@ "d3-axis": "^2.1.0", "d3-dsv": "^2.0.0", "d3-scale": "^3.3.0", + "d3-scale-chromatic": "^3.1.0", "d3-selection": "^2.0.0", "date-fns": "^2.28.0", "echarts": "^5.4.3", @@ -116,6 +115,7 @@ "@types/d3-axis": "^2.1.3", "@types/d3-dsv": "^2.0.0", "@types/d3-scale": "^3.3.2", + "@types/d3-scale-chromatic": "^3.0.3", "@types/d3-selection": "^2.0.1", "@types/enzyme": "^3.10.17", "@types/enzyme-adapter-react-16": "^1.0.9", diff --git a/web-console/src/components/braced-text/braced-text.tsx b/web-console/src/components/braced-text/braced-text.tsx index 69aafe08b02b..c0fb0b314ee3 100644 --- a/web-console/src/components/braced-text/braced-text.tsx +++ b/web-console/src/components/braced-text/braced-text.tsx @@ -84,7 +84,7 @@ function hideThousandsSeparator(text: string) { } export const BracedText = React.memo(function BracedText(props: BracedTextProps) { - const { className, text, braces, padFractionalPart, unselectableThousandsSeparator, title } = + const { className, text, braces, padFractionalPart, unselectableThousandsSeparator, ...rest } = props; let effectiveBraces = braces.concat(text); @@ -115,7 +115,7 @@ export const BracedText = React.memo(function BracedText(props: BracedTextProps) } return ( - + {findMostNumbers(effectiveBraces)} {unselectableThousandsSeparator ? hideThousandsSeparator(text) : text} diff --git a/web-console/src/components/click-to-copy/__snapshots__/click-to-copy.spec.tsx.snap b/web-console/src/components/click-to-copy/__snapshots__/click-to-copy.spec.tsx.snap index a53da72f6d18..7c8496b481a3 100644 --- a/web-console/src/components/click-to-copy/__snapshots__/click-to-copy.spec.tsx.snap +++ b/web-console/src/components/click-to-copy/__snapshots__/click-to-copy.spec.tsx.snap @@ -3,7 +3,7 @@ exports[`ClickToCopy matches snapshot 1`] = ` Hello world diff --git a/web-console/src/components/click-to-copy/click-to-copy.tsx b/web-console/src/components/click-to-copy/click-to-copy.tsx index 5d1d2a8cf471..6ba17344b502 100644 --- a/web-console/src/components/click-to-copy/click-to-copy.tsx +++ b/web-console/src/components/click-to-copy/click-to-copy.tsx @@ -31,7 +31,7 @@ export const ClickToCopy = React.memo(function ClickToCopy(props: ClickToCopyPro return ( { copy(text, { format: 'text/plain' }); AppToaster.show({ diff --git a/web-console/src/components/header-bar/__snapshots__/header-bar.spec.tsx.snap b/web-console/src/components/header-bar/__snapshots__/header-bar.spec.tsx.snap index fe1e8c92a412..9aac8421b3f4 100644 --- a/web-console/src/components/header-bar/__snapshots__/header-bar.spec.tsx.snap +++ b/web-console/src/components/header-bar/__snapshots__/header-bar.spec.tsx.snap @@ -199,6 +199,7 @@ exports[`HeaderBar matches snapshot 1`] = ` @@ -212,7 +213,7 @@ exports[`HeaderBar matches snapshot 1`] = ` Capabilities { "coordinator": true, "maxTaskSlots": undefined, - "multiStageQuery": true, + "multiStageQueryTask": true, "overlord": true, "queryType": "nativeAndSql", } @@ -333,6 +334,7 @@ exports[`HeaderBar matches snapshot 1`] = ` > @@ -418,6 +420,7 @@ exports[`HeaderBar matches snapshot 1`] = ` > diff --git a/web-console/src/components/header-bar/header-bar.tsx b/web-console/src/components/header-bar/header-bar.tsx index aed667982999..7cdc8cf1d312 100644 --- a/web-console/src/components/header-bar/header-bar.tsx +++ b/web-console/src/components/header-bar/header-bar.tsx @@ -106,7 +106,7 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) { const [overlordDynamicConfigDialogOpen, setOverlordDynamicConfigDialogOpen] = useState(false); const [compactionDynamicConfigDialogOpen, setCompactionDynamicConfigDialogOpen] = useState(false); - const showSplitDataLoaderMenu = capabilities.hasMultiStageQuery(); + const showSplitDataLoaderMenu = capabilities.hasMultiStageQueryTask(); const loadDataViewsMenuActive = oneOf( active, @@ -360,6 +360,7 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) { minimal icon={IconNames.MORE} active={moreViewsMenuActive} + data-tooltip="More views" /> @@ -407,10 +408,10 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) { )} - diff --git a/web-console/src/components/supervisor-history-panel/supervisor-history-panel.tsx b/web-console/src/components/supervisor-history-panel/supervisor-history-panel.tsx index 0e1043a56818..8af5c2df03f0 100644 --- a/web-console/src/components/supervisor-history-panel/supervisor-history-panel.tsx +++ b/web-console/src/components/supervisor-history-panel/supervisor-history-panel.tsx @@ -16,7 +16,7 @@ * limitations under the License. */ -import { Tab, Tabs } from '@blueprintjs/core'; +import { Tab, Tabs, TabsExpander } from '@blueprintjs/core'; import * as JSONBig from 'json-bigint-native'; import React, { useState } from 'react'; @@ -68,7 +68,7 @@ export const SupervisorHistoryPanel = React.memo(function SupervisorHistoryPanel ))} - + {diffIndex !== -1 && ( - Unusable date + Invalid date `; exports[`TableCell matches snapshot Date 1`] = `
- 2022-02-24T01:02:03.000Z + 2022-02-24 01:02:03
`; diff --git a/web-console/src/components/table-cell/table-cell.tsx b/web-console/src/components/table-cell/table-cell.tsx index 3b31caa85f25..6bd53271d6a9 100644 --- a/web-console/src/components/table-cell/table-cell.tsx +++ b/web-console/src/components/table-cell/table-cell.tsx @@ -21,7 +21,7 @@ import * as JSONBig from 'json-bigint-native'; import React, { useState } from 'react'; import { ShowValueDialog } from '../../dialogs/show-value-dialog/show-value-dialog'; -import { isSimpleArray } from '../../utils'; +import { isSimpleArray, prettyFormatIsoDateWithMsIfNeeded } from '../../utils'; import { ActionIcon } from '../action-icon/action-icon'; import './table-cell.scss'; @@ -97,8 +97,8 @@ export const TableCell = React.memo(function TableCell(props: TableCellProps) { } else if (value instanceof Date) { const dateValue = value.valueOf(); return ( -
- {isNaN(dateValue) ? 'Unusable date' : value.toISOString()} +
+ {isNaN(dateValue) ? 'Invalid date' : prettyFormatIsoDateWithMsIfNeeded(value.toISOString())}
); } else if (isSimpleArray(value)) { diff --git a/web-console/src/components/table-clickable-cell/table-clickable-cell.tsx b/web-console/src/components/table-clickable-cell/table-clickable-cell.tsx index fa3d56982a9d..aec706a50fc0 100644 --- a/web-console/src/components/table-clickable-cell/table-clickable-cell.tsx +++ b/web-console/src/components/table-clickable-cell/table-clickable-cell.tsx @@ -36,13 +36,13 @@ export interface TableClickableCellProps { export const TableClickableCell = React.memo(function TableClickableCell( props: TableClickableCellProps, ) { - const { className, onClick, hoverIcon, title, disabled, children } = props; + const { className, onClick, hoverIcon, disabled, children, ...rest } = props; return (
{children} {hoverIcon && !disabled && } diff --git a/web-console/src/console-application.tsx b/web-console/src/console-application.tsx index 36a0b8aa3921..95336713dbb6 100644 --- a/web-console/src/console-application.tsx +++ b/web-console/src/console-application.tsx @@ -28,7 +28,7 @@ import type { Filter } from 'react-table'; import type { HeaderActiveTab } from './components'; import { HeaderBar, Loader } from './components'; -import type { DruidEngine, QueryContext, QueryWithContext } from './druid-models'; +import type { QueryContext, QueryWithContext } from './druid-models'; import { Capabilities, maybeGetClusterCapacity } from './helpers'; import { stringToTableFilters, tableFiltersToString } from './react-table'; import { AppToaster } from './singletons'; @@ -307,14 +307,6 @@ export class ConsoleApplication extends React.PureComponent< this.props; const { capabilities } = this.state; - const queryEngines: DruidEngine[] = ['native']; - if (capabilities.hasSql()) { - queryEngines.push('sql-native'); - } - if (capabilities.hasMultiStageQuery()) { - queryEngines.push('sql-msq-task'); - } - return this.wrapInViewContainer( 'workbench', , @@ -470,7 +461,7 @@ export class ConsoleApplication extends React.PureComponent< component={this.wrappedClassicBatchDataLoaderView} /> )} - {capabilities.hasCoordinatorAccess() && capabilities.hasMultiStageQuery() && ( + {capabilities.hasCoordinatorAccess() && capabilities.hasMultiStageQueryTask() && ( )} diff --git a/web-console/src/dialogs/compaction-config-dialog/__snapshots__/compaction-config-dialog.spec.tsx.snap b/web-console/src/dialogs/compaction-config-dialog/__snapshots__/compaction-config-dialog.spec.tsx.snap index d122f33fbd29..09531d03eac5 100644 --- a/web-console/src/dialogs/compaction-config-dialog/__snapshots__/compaction-config-dialog.spec.tsx.snap +++ b/web-console/src/dialogs/compaction-config-dialog/__snapshots__/compaction-config-dialog.spec.tsx.snap @@ -1620,21 +1620,12 @@ exports[`CompactionConfigDialog matches snapshot without compactionConfig 1`] = onClick={[Function]} text="History" /> - - - + ) : ( - -
+ + )} +
+ ); +}; diff --git a/web-console/src/views/explore-view/components/control-pane/measure-menu.scss b/web-console/src/views/explore-view/components/control-pane/measure-menu.scss new file mode 100644 index 000000000000..f649a7c839c7 --- /dev/null +++ b/web-console/src/views/explore-view/components/control-pane/measure-menu.scss @@ -0,0 +1,70 @@ +/* + * 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 '../../../../variables'; + +.measure-menu { + width: 500px; + height: 47vh; + padding: 15px; + display: flex; + flex-direction: column; + + .tab-bar { + & > * { + flex: 1 !important; + } + } + + & > .#{$bp-ns}-menu { + flex: 1; + padding: 0; + overflow: auto; + + &:not(:last-child) { + margin-bottom: 15px; + } + } + + .editor-container { + flex: 1; + margin-bottom: 15px; + } + + .measure-column-line { + display: flex; + gap: 8px; + + .column-group { + flex: 1; + } + } + + .expander { + flex: 1; + } + + .button-bar { + display: flex; + gap: 10px; + + .button-separator { + flex: 1; + } + } +} diff --git a/web-console/src/views/explore-view/components/control-pane/measure-menu.tsx b/web-console/src/views/explore-view/components/control-pane/measure-menu.tsx new file mode 100644 index 000000000000..f1296dd8ffc0 --- /dev/null +++ b/web-console/src/views/explore-view/components/control-pane/measure-menu.tsx @@ -0,0 +1,291 @@ +/* + * 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 { + Button, + ButtonGroup, + FormGroup, + HTMLSelect, + Icon, + InputGroup, + Intent, + Menu, + MenuItem, + Popover, + Position, +} from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import type { Column } from '@druid-toolkit/query'; +import { SqlAlias, SqlExpression } from '@druid-toolkit/query'; +import React, { useState } from 'react'; + +import { AppToaster } from '../../../../singletons'; +import { columnToIcon } from '../../../../utils'; +import { Measure, MeasurePattern } from '../../models'; +import { ColumnPicker } from '../column-picker/column-picker'; +import { SqlInput } from '../sql-input/sql-input'; + +import './measure-menu.scss'; + +type MeasureMenuTab = 'saved' | 'compose' | 'sql'; + +export interface MeasureMenuProps { + columns: readonly Column[]; + measures: readonly Measure[]; + initMeasure: Measure | undefined; + onSelectMeasure(measure: Measure): void; + onClose(): void; + onAddToSourceQueryAsMeasure?(measure: Measure): void; +} + +export const MeasureMenu = function MeasureMenu(props: MeasureMenuProps) { + const { columns, measures, initMeasure, onSelectMeasure, onClose, onAddToSourceQueryAsMeasure } = + props; + + const [tab, setTab] = useState(() => { + if (!initMeasure) return 'compose'; + if (measures.some(measure => measure.equivalent(initMeasure))) return 'saved'; + return MeasurePattern.fit(initMeasure.expression) ? 'compose' : 'sql'; + }); + const [outputName, setOutputName] = useState(initMeasure?.as || ''); + const [measurePattern, setMeasurePattern] = useState( + initMeasure ? MeasurePattern.fit(initMeasure.expression) : undefined, + ); + const [formula, setFormula] = useState(initMeasure ? String(initMeasure.expression) : ''); + + function getMeasure(): Measure | undefined { + switch (tab) { + case 'saved': + return; + + case 'compose': { + if (!measurePattern) throw new Error('no measure pattern'); + const expression = measurePattern.toExpression(); + return new Measure({ + expression, + as: outputName, + }); + } + + case 'sql': { + if (!formula) { + AppToaster.show({ + message: 'Formula is empty', + intent: Intent.DANGER, + }); + return; + } + + let parsedFormula: SqlExpression; + try { + parsedFormula = SqlExpression.parse(formula); + } catch (e) { + AppToaster.show({ + message: `Could not parse formula: ${e.message}`, + intent: Intent.DANGER, + }); + return; + } + + if (parsedFormula instanceof SqlAlias) { + return new Measure({ + expression: parsedFormula.getUnderlyingExpression(), + as: outputName || parsedFormula.getAliasName(), + }); + } + + return new Measure({ + expression: parsedFormula, + as: outputName, + }); + } + } + } + + const actionDisabled = + (tab === 'compose' && !measurePattern) || (tab === 'sql' && formula === ''); + return ( +
+ + +
+ )} + + ); +}; diff --git a/web-console/src/views/explore-view/control-pane/columns-input.scss b/web-console/src/views/explore-view/components/control-pane/named-expressions-input.scss similarity index 92% rename from web-console/src/views/explore-view/control-pane/columns-input.scss rename to web-console/src/views/explore-view/components/control-pane/named-expressions-input.scss index 2f7fc37e554d..5dec6be7511e 100644 --- a/web-console/src/views/explore-view/control-pane/columns-input.scss +++ b/web-console/src/views/explore-view/components/control-pane/named-expressions-input.scss @@ -16,10 +16,10 @@ * limitations under the License. */ -@import '../../../variables'; +@import '../../../../variables'; -.columns-input { - .#{$ns}-tag-input-values .#{$ns}-tag { +.named-expressions-input { + .#{$bp-ns}-tag-input-values .#{$bp-ns}-tag { &.drop-before::after { content: ''; height: 24px; diff --git a/web-console/src/views/explore-view/control-pane/columns-input.tsx b/web-console/src/views/explore-view/components/control-pane/named-expressions-input.tsx similarity index 50% rename from web-console/src/views/explore-view/control-pane/columns-input.tsx rename to web-console/src/views/explore-view/components/control-pane/named-expressions-input.tsx index 50e6b88c83ef..ae498341ecd6 100644 --- a/web-console/src/views/explore-view/control-pane/columns-input.tsx +++ b/web-console/src/views/explore-view/components/control-pane/named-expressions-input.tsx @@ -18,27 +18,13 @@ import { Classes, Popover, Position, Tag } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import type { ExpressionMeta } from '@druid-toolkit/visuals-core'; import classNames from 'classnames'; import type { JSX } from 'react'; import React, { useCallback, useState } from 'react'; -import { ColumnPickerMenu } from '../column-picker-menu/column-picker-menu'; +import type { ExpressionMeta, Measure } from '../../models'; -import './columns-input.scss'; - -export interface ColumnsInputProps { - columns: ExpressionMeta[]; - value: ExpressionMeta[]; - onValueChange(value: ExpressionMeta[]): void; - allowDuplicates?: boolean; - allowReordering?: boolean; - - /** - * If you want to take control of the way new columns are picked and added - */ - pickerMenu?: (columns: ExpressionMeta[]) => JSX.Element; -} +import './named-expressions-input.scss'; function moveInArray(arr: any[], fromIndex: number, toIndex: number) { arr = arr.concat(); @@ -49,16 +35,24 @@ function moveInArray(arr: any[], fromIndex: number, toIndex: number) { return arr; } -export const ColumnsInput = function ColumnsInput(props: ColumnsInputProps) { - const { columns, value, onValueChange, allowDuplicates, allowReordering, pickerMenu } = props; +export interface NamesExpressionsInputProps { + values: M[]; + onValuesChange(value: M[]): void; + allowReordering?: boolean; + singleton?: boolean; + nonEmpty?: boolean; + itemMenu: (item: M | undefined, onClose: () => void) => JSX.Element; +} - const availableColumns = allowDuplicates - ? columns - : columns.filter(o => !value.find(_ => _.name === o.name)); +export const NamedExpressionsInput = function NamedExpressionsInput< + M extends ExpressionMeta | Measure, +>(props: NamesExpressionsInputProps) { + const { values, onValuesChange, allowReordering, singleton, nonEmpty, itemMenu } = props; const [dragIndex, setDragIndex] = useState(-1); const [dropBefore, setDropBefore] = useState(false); const [dropIndex, setDropIndex] = useState(-1); + const [menuOpenOn, setMenuOpenOn] = useState<{ openOn?: M }>(); const startDrag = useCallback((e: React.DragEvent, i: number) => { e.dataTransfer.effectAllowed = 'move'; @@ -89,52 +83,72 @@ export const ColumnsInput = function ColumnsInput(props: ColumnsInputProps) { if (correctedDropIndex > dragIndex) correctedDropIndex--; if (correctedDropIndex !== dragIndex) { - onValueChange(moveInArray(value, dragIndex, correctedDropIndex)); + onValuesChange(moveInArray(values, dragIndex, correctedDropIndex)); } } setDragIndex(-1); setDropIndex(-1); setDropBefore(false); }, - [dropIndex, dragIndex, onValueChange, value, dropBefore], + [dropIndex, dragIndex, onValuesChange, values, dropBefore], ); + const menuOnClose = () => { + setMenuOpenOn(undefined); + }; + + const canRemove = !nonEmpty || values.length > 1; return ( -
+
- {value.map((c, i) => ( - onDragOver(e, i)} + {values.map((c, i) => ( + startDrag(e, i)} - onRemove={() => { - onValueChange(value.filter(v => v !== c)); - }} + isOpen={Boolean(menuOpenOn && menuOpenOn.openOn === c)} + position={Position.BOTTOM} + onClose={menuOnClose} + content={itemMenu(c, menuOnClose)} > - {c.name} - + setMenuOpenOn({ openOn: c })} + draggable={allowReordering} + onDragOver={e => onDragOver(e, i)} + onDragStart={e => startDrag(e, i)} + onRemove={ + canRemove + ? () => { + onValuesChange(values.filter(v => v !== c)); + } + : undefined + } + > + {c.name} + + ))} - onValueChange(value.concat(c))} - /> - ) - } - > - - + {(!singleton || !values.length) && ( + + setMenuOpenOn({})} /> + + )}
); diff --git a/web-console/src/views/explore-view/control-pane/options-input.tsx b/web-console/src/views/explore-view/components/control-pane/options-input.tsx similarity index 51% rename from web-console/src/views/explore-view/control-pane/options-input.tsx rename to web-console/src/views/explore-view/components/control-pane/options-input.tsx index 9eec12da8305..286889bf6b23 100644 --- a/web-console/src/views/explore-view/control-pane/options-input.tsx +++ b/web-console/src/views/explore-view/components/control-pane/options-input.tsx @@ -16,57 +16,83 @@ * limitations under the License. */ -import { Classes, Menu, MenuItem, Popover, Position, Tag } from '@blueprintjs/core'; +import { Classes, Icon, Menu, MenuItem, Popover, Position, Tag } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import type { OptionValue, ParameterDefinition } from '@druid-toolkit/visuals-core'; -import { getPluginOptionLabel } from '@druid-toolkit/visuals-core'; import classNames from 'classnames'; import React from 'react'; +import type { OptionValue } from '../../models'; + export interface OptionsInputProps { - parameter: ParameterDefinition; options: readonly OptionValue[]; value: OptionValue[]; onValueChange(value: OptionValue[]): void; + optionLabel?(o: OptionValue): string; + allowDuplicates?: boolean; + nonEmpty?: boolean; } export const OptionsInput = function OptionsInput(props: OptionsInputProps) { - const { options, value, onValueChange, parameter } = props; - - if (parameter.type !== 'options') { - return null; - } + const { options, value, onValueChange, optionLabel = String, allowDuplicates, nonEmpty } = props; const selectedOptions: OptionValue[] = value.filter(v => options.includes(v)); - const availableOptions = parameter.allowDuplicates + const availableOptions = allowDuplicates ? options : options.filter(o => !value.find(v => v === o)); + const canRemove = !nonEmpty || options.length > 1; return (
- {selectedOptions.map((o, i) => ( - ( + { - onValueChange(value.filter(v => v !== o)); - }} + position={Position.BOTTOM} + content={ + + {(allowDuplicates + ? options + : options.filter(o => o === selectedOption || !value.find(v => v === o)) + ).map((ao, j) => ( + : undefined + } + onClick={() => { + onValueChange(value.map(v => (v === selectedOption ? ao : v))); + }} + /> + ))} + + } > - {getPluginOptionLabel(o, parameter)} - + { + onValueChange(value.filter(v => v !== selectedOption)); + } + : undefined + } + > + {optionLabel(selectedOption)} + + ))} - {availableOptions.map((o, i) => ( + {availableOptions.map((ao, i) => ( { - onValueChange(value.concat(o)); + onValueChange(value.concat(ao)); }} /> ))} diff --git a/web-console/src/views/explore-view/droppable-container/droppable-container.scss b/web-console/src/views/explore-view/components/droppable-container/droppable-container.scss similarity index 97% rename from web-console/src/views/explore-view/droppable-container/droppable-container.scss rename to web-console/src/views/explore-view/components/droppable-container/droppable-container.scss index 74b8a3973843..0283c69be7b4 100644 --- a/web-console/src/views/explore-view/droppable-container/droppable-container.scss +++ b/web-console/src/views/explore-view/components/droppable-container/droppable-container.scss @@ -16,7 +16,7 @@ * limitations under the License. */ -@import '../../../variables'; +@import '../../../../variables'; .droppable-container { position: relative; diff --git a/web-console/src/views/explore-view/components/droppable-container/droppable-container.tsx b/web-console/src/views/explore-view/components/droppable-container/droppable-container.tsx new file mode 100644 index 000000000000..ef8f36d25aa0 --- /dev/null +++ b/web-console/src/views/explore-view/components/droppable-container/droppable-container.tsx @@ -0,0 +1,74 @@ +/* + * 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 type { Column } from '@druid-toolkit/query'; +import classNames from 'classnames'; +import React, { forwardRef, useState } from 'react'; + +import { DragHelper } from '../../drag-helper'; +import type { Measure } from '../../models'; + +import './droppable-container.scss'; + +export interface DroppableContainerProps extends React.HTMLAttributes { + onDropColumn(column: Column): void; + onDropMeasure?(measure: Measure): void; + children?: React.ReactNode; +} + +export const DroppableContainer = forwardRef( + function DroppableContainer(props, ref) { + const { className, onDropColumn, onDropMeasure, children, ...rest } = props; + const [dropHover, setDropHover] = useState(false); + + return ( +
{ + if (!DragHelper.dragColumn && !(onDropMeasure && DragHelper.dragMeasure)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDropHover(true); + }} + onDragLeave={e => { + const currentTarget = e.currentTarget; + const relatedTarget = e.relatedTarget; + if (currentTarget.contains(relatedTarget as any)) return; + setDropHover(false); + }} + onDrop={() => { + if (!DragHelper.dragColumn && !(onDropMeasure && DragHelper.dragMeasure)) return; + const dragColumn = DragHelper.dragColumn; + const dragMeasure = DragHelper.dragMeasure; + setDropHover(false); + if (dragColumn) { + DragHelper.dragColumn = undefined; + onDropColumn(dragColumn); + } else if (dragMeasure && onDropMeasure) { + DragHelper.dragMeasure = undefined; + onDropMeasure(dragMeasure); + } + }} + > + {children} +
+ ); + }, +); diff --git a/web-console/src/views/explore-view/filter-pane/column-value/column-value.scss b/web-console/src/views/explore-view/components/filter-pane/column-value/column-value.scss similarity index 100% rename from web-console/src/views/explore-view/filter-pane/column-value/column-value.scss rename to web-console/src/views/explore-view/components/filter-pane/column-value/column-value.scss diff --git a/web-console/src/views/explore-view/filter-pane/column-value/column-value.tsx b/web-console/src/views/explore-view/components/filter-pane/column-value/column-value.tsx similarity index 100% rename from web-console/src/views/explore-view/filter-pane/column-value/column-value.tsx rename to web-console/src/views/explore-view/components/filter-pane/column-value/column-value.tsx diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/contains-filter-control/contains-filter-control.scss b/web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.scss similarity index 100% rename from web-console/src/views/explore-view/filter-pane/filter-menu/contains-filter-control/contains-filter-control.scss rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.scss diff --git a/web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx new file mode 100644 index 000000000000..353179c905e4 --- /dev/null +++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx @@ -0,0 +1,97 @@ +/* + * 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 { FormGroup, InputGroup, Menu, MenuItem } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import type { ContainsFilterPattern, QueryResult } from '@druid-toolkit/query'; +import { C, F, filterPatternToExpression, SqlExpression, SqlQuery } from '@druid-toolkit/query'; +import React, { useMemo } from 'react'; + +import { useQueryManager } from '../../../../../../hooks'; +import type { QuerySource } from '../../../../models'; + +import './contains-filter-control.scss'; + +export interface ContainsFilterControlProps { + querySource: QuerySource; + filter: SqlExpression | undefined; + filterPattern: ContainsFilterPattern; + setFilterPattern(filterPattern: ContainsFilterPattern): void; + runSqlQuery(query: string | SqlQuery): Promise; +} + +export const ContainsFilterControl = React.memo(function ContainsFilterControl( + props: ContainsFilterControlProps, +) { + const { querySource, filter, filterPattern, setFilterPattern, runSqlQuery } = props; + const { column, negated, contains } = filterPattern; + + const previewQuery = useMemo( + () => + SqlQuery.from(querySource.query) + .addSelect(F.cast(C(column), 'VARCHAR').as('c'), { addToGroupBy: 'end' }) + .changeWhereExpression( + SqlExpression.and( + filter, + contains ? filterPatternToExpression(filterPattern) : undefined, + ), + ) + .changeOrderByExpression(F.count().toOrderByExpression('DESC')) + .toString(), + // eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps + [querySource.query, filter, column, contains, negated], + ); + + const [previewState] = useQueryManager({ + query: previewQuery, + debounceIdle: 100, + debounceLoading: 500, + processQuery: async query => { + const vs = await runSqlQuery(query); + return (vs.getColumnByName('c') || []).map(String); + }, + }); + + return ( +
+ + setFilterPattern({ ...filterPattern, contains: e.target.value })} + placeholder="Search string" + /> + + + + {previewState.data?.map((v, i) => ( + + ))} + {previewState.loading && } + {previewState.error && ( + + )} + + +
+ ); +}); diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.scss similarity index 78% rename from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.scss index dbb850df020d..b5659da421c1 100644 --- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss +++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.scss @@ -16,20 +16,22 @@ * limitations under the License. */ -@import '../../../../variables'; - .filter-menu { - width: 400px; + width: 500px; + padding: 15px; - &.main { - padding: 15px; + .tab-bar { + & > * { + flex: 1 !important; + } } - .controls .#{$ns}-form-content { + .controls { display: flex; - gap: 15px; + gap: 10px; + align-items: flex-end; - .type-selector { + .column-form-group { flex: 1; } } @@ -39,6 +41,11 @@ } .button-bar { - text-align: right; + display: flex; + gap: 10px; + + .button-separator { + flex: 1; + } } } diff --git a/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx new file mode 100644 index 000000000000..8cbca7e8d53e --- /dev/null +++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx @@ -0,0 +1,425 @@ +/* + * 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 { + Button, + ButtonGroup, + Callout, + FormGroup, + HTMLSelect, + Intent, + Menu, + MenuItem, + Popover, + Position, +} from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import type { + Column, + FilterPattern, + FilterPatternType, + QueryResult, + SqlQuery, +} from '@druid-toolkit/query'; +import { + C, + changeFilterPatternType, + filterPatternToExpression, + fitFilterPattern, + SqlExpression, +} from '@druid-toolkit/query'; +import type { JSX } from 'react'; +import React, { useState } from 'react'; + +import { AppToaster } from '../../../../../singletons'; +import type { QuerySource } from '../../../models'; +import { formatPatternWithoutNegation, initPatternForColumn } from '../../../utils'; +import { ColumnPicker } from '../../column-picker/column-picker'; +import { ColumnPickerMenu } from '../../column-picker-menu/column-picker-menu'; +import { SqlInput } from '../../sql-input/sql-input'; + +import { ContainsFilterControl } from './contains-filter-control/contains-filter-control'; +import { NumberRangeFilterControl } from './number-range-filter-control/number-range-filter-control'; +import { RegexpFilterControl } from './regexp-filter-control/regexp-filter-control'; +import { TimeIntervalFilterControl } from './time-interval-filter-control/time-interval-filter-control'; +import { TimeRelativeFilterControl } from './time-relative-filter-control/time-relative-filter-control'; +import { ValuesFilterControl } from './values-filter-control/values-filter-control'; + +import './filter-menu.scss'; + +const DEFAULT_PATTERN_TYPES: FilterPatternType[] = [ + 'values', + 'contains', + 'regexp', + 'numberRange', + 'timeRelative', + 'timeInterval', +]; + +function getPattenTypesForColumn(column: Column | undefined): FilterPatternType[] { + if (!column) return DEFAULT_PATTERN_TYPES; + + switch (column.sqlType) { + case 'TIMESTAMP': + return ['timeRelative', 'timeInterval', 'numberRange', 'values', 'contains', 'regexp']; + + case 'BOOLEAN': + return ['values', 'contains', 'regexp', 'numberRange']; + + case 'BIGINT': + case 'DOUBLE': + case 'FLOAT': + return ['numberRange', 'values']; + + default: // VARCHAR also gets default + return DEFAULT_PATTERN_TYPES; + } +} + +const PATTERN_TYPE_TO_NAME: Record = { + values: 'Values', + contains: 'Contains', + regexp: 'Regular expression', + mvContains: 'Multi-value contains', + numberRange: 'Number range', + timeRelative: 'Time relative', + timeInterval: 'Time interval', + custom: 'Custom', +}; + +type FilterMenuTab = 'compose' | 'sql'; + +export interface FilterMenuProps { + querySource: QuerySource; + filter: SqlExpression; + initPattern?: FilterPattern; + onPatternChange(newPattern: FilterPattern): void; + onClose(): void; + runSqlQuery(query: string | SqlQuery): Promise; + onAddToSourceQueryAsColumn?(expression: SqlExpression): void; + onMoveToSourceQueryAsClause?(expression: SqlExpression): void; +} + +export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps) { + const { + querySource, + filter, + initPattern, + onPatternChange, + onClose, + runSqlQuery, + onAddToSourceQueryAsColumn, + onMoveToSourceQueryAsClause, + } = props; + + const [tab, setTab] = useState(initPattern?.type === 'custom' ? 'sql' : 'compose'); + const [formula, setFormula] = useState( + initPattern?.type === 'custom' ? filterPatternToExpression(initPattern).toString() : '', + ); + const [pattern, setPattern] = useState(initPattern); + const { columns } = querySource; + + function onAcceptPattern(pattern: FilterPattern) { + onPatternChange(pattern); + onClose(); + } + + const negated = Boolean(pattern?.negated); + + let cont: JSX.Element; + switch (pattern?.type) { + case 'values': + cont = ( + + ); + break; + + case 'contains': + cont = ( + + ); + break; + + case 'regexp': + cont = ( + + ); + break; + + case 'numberRange': + cont = ( + + ); + break; + + case 'timeInterval': + cont = ( + + ); + break; + + case 'timeRelative': + cont = ( + + ); + break; + + case 'mvContains': + case 'custom': { + const columnName: string | undefined = + pattern.type === 'custom' + ? pattern.expression?.getFirstColumnName() + : (pattern as any).column; + const column = columns.find(({ name }) => name === columnName); + cont = ( + + +

The current filter can only be edited as SQL.

+

+ setTab('sql')}>Continue editing in SQL. +

+ {column && ( +

+ setPattern(initPatternForColumn(column))}>{`Compose on column ${C( + column.name, + )}.`} +

+ )} + + + ); + break; + } + + default: + cont =
Pattern no set
; + break; + } + + function parseFormula(): SqlExpression | undefined { + try { + return SqlExpression.parse(formula); + } catch (e) { + AppToaster.show({ + intent: Intent.DANGER, + message: e.message, + }); + return; + } + } + + return ( +
+ + +
+ )} + {cont} + + ) : ( + setPattern(initPatternForColumn(c))} + rightIconForColumn={c => + filter.containsColumnName(c.name) ? IconNames.FILTER : undefined + } + shouldDismissPopover={false} + /> + ))} + {tab === 'sql' && ( + + setFormula(sql)} + columns={columns} + placeholder="SQL expression" + editorHeight={250} + autoFocus + /> + + )} + {(pattern || tab === 'sql') && ( +
+ {pattern && onAddToSourceQueryAsColumn && onMoveToSourceQueryAsClause && ( + + { + if (tab === 'compose') { + onAddToSourceQueryAsColumn( + filterPatternToExpression(pattern).as( + formatPatternWithoutNegation(pattern), + ), + ); + } else { + const parsedFormula = parseFormula(); + if (!parsedFormula) return; + onAddToSourceQueryAsColumn(parsedFormula.as(formula)); + } + onClose(); + }} + /> + { + if (tab === 'compose') { + onMoveToSourceQueryAsClause(filterPatternToExpression(pattern)); + } else { + const parsedFormula = parseFormula(); + if (!parsedFormula) return; + onMoveToSourceQueryAsClause(parsedFormula); + } + onClose(); + }} + /> + + } + > +
+ )} +
+ ); +}); diff --git a/web-console/src/views/explore-view/components/filter-pane/filter-menu/number-range-filter-control/number-range-filter-control.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/number-range-filter-control/number-range-filter-control.tsx new file mode 100644 index 000000000000..eb5adc9c4a74 --- /dev/null +++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/number-range-filter-control/number-range-filter-control.tsx @@ -0,0 +1,69 @@ +/* + * 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 { Button, ControlGroup, FormGroup, NumericInput } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import type { NumberRangeFilterPattern } from '@druid-toolkit/query'; +import React from 'react'; + +import type { QuerySource } from '../../../../models'; + +export interface NumberRangeFilterControlProps { + querySource: QuerySource; + filterPattern: NumberRangeFilterPattern; + setFilterPattern(filterPattern: NumberRangeFilterPattern): void; +} + +export const NumberRangeFilterControl = React.memo(function NumberRangeFilterControl( + props: NumberRangeFilterControlProps, +) { + const { filterPattern, setFilterPattern } = props; + const { start, startBound, end, endBound } = filterPattern; + + return ( +
+ + + setFilterPattern({ ...filterPattern, start })} + fill + /> +
+ ); +}); diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.scss b/web-console/src/views/explore-view/components/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.scss similarity index 100% rename from web-console/src/views/explore-view/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.scss rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.scss diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.tsx similarity index 51% rename from web-console/src/views/explore-view/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.tsx rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.tsx index 13358ac3427b..ea6bdf743858 100644 --- a/web-console/src/views/explore-view/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.tsx +++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.tsx @@ -16,15 +16,14 @@ * limitations under the License. */ -import { Button, FormGroup, InputGroup, Intent, Menu, MenuItem } from '@blueprintjs/core'; +import { FormGroup, InputGroup, Menu, MenuItem } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import type { RegexpFilterPattern } from '@druid-toolkit/query'; -import { C, filterPatternToExpression, SqlExpression, SqlLiteral } from '@druid-toolkit/query'; -import React, { useMemo, useState } from 'react'; +import type { QueryResult, RegexpFilterPattern } from '@druid-toolkit/query'; +import { C, F, filterPatternToExpression, SqlExpression, SqlQuery } from '@druid-toolkit/query'; +import React, { useMemo } from 'react'; -import { useQueryManager } from '../../../../../hooks'; -import { ColumnPicker } from '../../../column-picker/column-picker'; -import type { Dataset } from '../../../utils'; +import { useQueryManager } from '../../../../../../hooks'; +import type { QuerySource } from '../../../../models'; import './regexp-filter-control.scss'; @@ -38,72 +37,51 @@ function regexpIssue(possibleRegexp: string): string | undefined { } export interface RegexpFilterControlProps { - dataset: Dataset; + querySource: QuerySource; filter: SqlExpression | undefined; - initFilterPattern: RegexpFilterPattern; - negated: boolean; + filterPattern: RegexpFilterPattern; setFilterPattern(filterPattern: RegexpFilterPattern): void; - queryDruidSql(sqlQueryPayload: Record): Promise; + runSqlQuery(query: string | SqlQuery): Promise; } export const RegexpFilterControl = React.memo(function RegexpFilterControl( props: RegexpFilterControlProps, ) { - const { dataset, filter, initFilterPattern, negated, setFilterPattern, queryDruidSql } = props; - const [column, setColumn] = useState(initFilterPattern.column); - const [regexp, setRegexp] = useState(initFilterPattern.regexp); + const { querySource, filter, filterPattern, setFilterPattern, runSqlQuery } = props; + const { column, negated, regexp } = filterPattern; - function makePattern(): RegexpFilterPattern { - return { - type: 'regexp', - negated, - column, - regexp: regexpIssue(regexp) ? '' : regexp, - }; - } - - const previewQuery = useMemo(() => { - const columnRef = C(column); - const queryParts: string[] = [`SELECT ${columnRef.as('c')}`, `FROM ${dataset.table}`]; - - const filterEx = SqlExpression.and( - filter, - regexp ? filterPatternToExpression(makePattern()) : undefined, - ); - if (!(filterEx instanceof SqlLiteral)) { - queryParts.push(`WHERE ${filterEx}`); - } - - queryParts.push(`GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 101`); - return queryParts.join('\n'); + const previewQuery = useMemo( + () => + SqlQuery.from(querySource.query) + .addSelect(F.cast(C(column), 'VARCHAR').as('c'), { addToGroupBy: 'end' }) + .changeWhereExpression( + SqlExpression.and(filter, regexp ? filterPatternToExpression(filterPattern) : undefined), + ) + .changeOrderByExpression(F.count().toOrderByExpression('DESC')) + .toString(), // eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps - }, [dataset.table, filter, column, regexp, negated]); + [querySource.query, filter, column, regexp, negated], + ); const [previewState] = useQueryManager({ query: previewQuery, debounceIdle: 100, debounceLoading: 500, processQuery: async query => { - const vs = await queryDruidSql<{ c: any }>({ - query, - }); - - return vs.map(d => String(d.c)); + const vs = await runSqlQuery(query); + return (vs.getColumnByName('c') || []).map(String); }, }); const issue = regexpIssue(regexp); return (
- - - - setRegexp(e.target.value)} placeholder="Regexp" /> + setFilterPattern({ ...filterPattern, regexp: e.target.value })} + placeholder="Regexp" + /> @@ -127,18 +105,6 @@ export const RegexpFilterControl = React.memo(function RegexpFilterControl( )} -
-
); }); diff --git a/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.scss b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.scss new file mode 100644 index 000000000000..51816400f82a --- /dev/null +++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.scss @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.time-interval-filter-control { + display: flex; + gap: 10px; + + & > * { + flex: 1; + } +} diff --git a/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx new file mode 100644 index 000000000000..acb21c309d2f --- /dev/null +++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx @@ -0,0 +1,53 @@ +/* + * 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 { FormGroup } from '@blueprintjs/core'; +import type { TimeIntervalFilterPattern } from '@druid-toolkit/query'; +import React from 'react'; + +import type { QuerySource } from '../../../../models'; +import { UtcDateInput } from '../../../utc-date-input/utc-date-input'; + +import './time-interval-filter-control.scss'; + +export interface TimeIntervalFilterControlProps { + querySource: QuerySource; + filterPattern: TimeIntervalFilterPattern; + setFilterPattern(filterPattern: TimeIntervalFilterPattern): void; +} + +export const TimeIntervalFilterControl = React.memo(function TimeIntervalFilterControl( + props: TimeIntervalFilterControlProps, +) { + const { filterPattern, setFilterPattern } = props; + const { start, end } = filterPattern; + + return ( +
+ + setFilterPattern({ ...filterPattern, start })} + /> + + + setFilterPattern({ ...filterPattern, end })} /> + +
+ ); +}); diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/time-relative-filter-control/time-relative-filter-control.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-relative-filter-control/time-relative-filter-control.tsx similarity index 70% rename from web-console/src/views/explore-view/filter-pane/filter-menu/time-relative-filter-control/time-relative-filter-control.tsx rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/time-relative-filter-control/time-relative-filter-control.tsx index b1ec80611174..32687c0c1b5d 100644 --- a/web-console/src/views/explore-view/filter-pane/filter-menu/time-relative-filter-control/time-relative-filter-control.tsx +++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-relative-filter-control/time-relative-filter-control.tsx @@ -16,12 +16,11 @@ * limitations under the License. */ -import { Button, FormGroup } from '@blueprintjs/core'; +import { Button, ButtonGroup, FormGroup } from '@blueprintjs/core'; import type { TimeRelativeFilterPattern } from '@druid-toolkit/query'; -import React, { useState } from 'react'; +import React from 'react'; -import { ColumnPicker } from '../../../column-picker/column-picker'; -import type { Dataset } from '../../../utils'; +import type { QuerySource } from '../../../../models'; interface PartialPattern { anchor: 'timestamp' | 'maxDataTime'; @@ -99,47 +98,41 @@ const GROUPS: GroupedNamedPartialPatterns[] = [ ]; export interface TimeRelativeFilterControlProps { - dataset: Dataset; - initFilterPattern: TimeRelativeFilterPattern; - negated: boolean; + querySource: QuerySource; + filterPattern: TimeRelativeFilterPattern; setFilterPattern(filterPattern: TimeRelativeFilterPattern): void; } export const TimeRelativeFilterControl = React.memo(function TimeRelativeFilterControl( props: TimeRelativeFilterControlProps, ) { - const { dataset, initFilterPattern, negated, setFilterPattern } = props; - const [column, setColumn] = useState(initFilterPattern.column); + const { filterPattern, setFilterPattern } = props; + const { column, negated } = filterPattern; - const initKey = partialPatternToKey(initFilterPattern); + const initKey = partialPatternToKey(filterPattern); return (
- - - {GROUPS.map(({ groupName, namedPartialPatterns }, i) => ( - {namedPartialPatterns.map(({ name, partialPattern }, i) => ( -
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/values-filter-control/values-filter-control.scss b/web-console/src/views/explore-view/components/filter-pane/filter-menu/values-filter-control/values-filter-control.scss similarity index 100% rename from web-console/src/views/explore-view/filter-pane/filter-menu/values-filter-control/values-filter-control.scss rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/values-filter-control/values-filter-control.scss diff --git a/web-console/src/views/explore-view/components/filter-pane/filter-menu/values-filter-control/values-filter-control.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/values-filter-control/values-filter-control.tsx new file mode 100644 index 000000000000..a15d3daad762 --- /dev/null +++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/values-filter-control/values-filter-control.tsx @@ -0,0 +1,120 @@ +/* + * 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 { FormGroup, InputGroup, Menu, MenuItem } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import type { QueryResult, SqlQuery, ValuesFilterPattern } from '@druid-toolkit/query'; +import { C, F, L, SqlExpression, SqlLiteral } from '@druid-toolkit/query'; +import React, { useMemo, useState } from 'react'; + +import { useQueryManager } from '../../../../../../hooks'; +import { caseInsensitiveContains } from '../../../../../../utils'; +import type { QuerySource } from '../../../../models'; +import { toggle } from '../../../../utils'; +import { ColumnValue } from '../../column-value/column-value'; + +import './values-filter-control.scss'; + +export interface ValuesFilterControlProps { + querySource: QuerySource; + filter: SqlExpression | undefined; + filterPattern: ValuesFilterPattern; + setFilterPattern(filterPattern: ValuesFilterPattern): void; + runSqlQuery(query: string | SqlQuery): Promise; +} + +export const ValuesFilterControl = React.memo(function ValuesFilterControl( + props: ValuesFilterControlProps, +) { + const { querySource, filter, filterPattern, setFilterPattern, runSqlQuery } = props; + const { column, negated, values: selectedValues } = filterPattern; + const [initValues] = useState(selectedValues); + const [searchString, setSearchString] = useState(''); + + const valuesQuery = useMemo(() => { + const columnRef = C(column); + const queryParts: string[] = [`SELECT ${columnRef.as('c')}`, `FROM (${querySource.query})`]; + + const filterEx = SqlExpression.and( + filter, + searchString ? F('ICONTAINS_STRING', columnRef, L(searchString)) : undefined, + ); + if (!(filterEx instanceof SqlLiteral)) { + queryParts.push(`WHERE ${filterEx}`); + } + + queryParts.push(`GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 101`); + return queryParts.join('\n'); + }, [querySource.query, filter, column, searchString]); + + const [valuesState] = useQueryManager({ + query: valuesQuery, + debounceIdle: 100, + debounceLoading: 500, + processQuery: async query => { + const vs = await runSqlQuery(query); + return vs.getColumnByName('c') || []; + }, + }); + + let valuesToShow: any[] = initValues; + const values = valuesState.data; + if (values) { + valuesToShow = valuesToShow.concat(values.filter(v => !initValues.includes(v))); + } + if (searchString) { + valuesToShow = valuesToShow.filter(v => caseInsensitiveContains(v, searchString)); + } + + const showSearch = querySource.columns.find(c => c.name === column)?.sqlType !== 'BOOLEAN'; + + return ( + + {showSearch && ( + setSearchString(e.target.value)} + placeholder="Search" + /> + )} + + {valuesToShow.map((v, i) => ( + } + shouldDismissPopover={false} + onClick={e => { + setFilterPattern({ + ...filterPattern, + values: e.altKey ? [v] : toggle(selectedValues, v), + }); + }} + /> + ))} + {valuesState.loading && } + + + ); +}); diff --git a/web-console/src/views/explore-view/filter-pane/filter-pane.scss b/web-console/src/views/explore-view/components/filter-pane/filter-pane.scss similarity index 96% rename from web-console/src/views/explore-view/filter-pane/filter-pane.scss rename to web-console/src/views/explore-view/components/filter-pane/filter-pane.scss index 9b117a9bc223..9be736495f00 100644 --- a/web-console/src/views/explore-view/filter-pane/filter-pane.scss +++ b/web-console/src/views/explore-view/components/filter-pane/filter-pane.scss @@ -16,14 +16,14 @@ * limitations under the License. */ -@import '../../../variables'; +@import '../../../../variables'; .filter-pane { display: flex; flex-wrap: wrap; gap: 5px; - .filter-label { + .filter-icon-button { pointer-events: none; } diff --git a/web-console/src/views/explore-view/filter-pane/filter-pane.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-pane.tsx similarity index 66% rename from web-console/src/views/explore-view/filter-pane/filter-pane.tsx rename to web-console/src/views/explore-view/components/filter-pane/filter-pane.tsx index 4692cda170c6..72ddd8f70467 100644 --- a/web-console/src/views/explore-view/filter-pane/filter-pane.tsx +++ b/web-console/src/views/explore-view/components/filter-pane/filter-pane.tsx @@ -18,38 +18,51 @@ import { Button, Popover } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import type { FilterPattern, SqlExpression } from '@druid-toolkit/query'; +import type { + Column, + FilterPattern, + QueryResult, + SqlExpression, + SqlQuery, +} from '@druid-toolkit/query'; import { filterPatternsToExpression, fitFilterPatterns } from '@druid-toolkit/query'; -import type { ExpressionMeta } from '@druid-toolkit/visuals-core'; import classNames from 'classnames'; import React, { forwardRef, useImperativeHandle, useState } from 'react'; +import type { QuerySource } from '../../models'; +import { formatPatternWithoutNegation, initPatternForColumn } from '../../utils'; import { DroppableContainer } from '../droppable-container/droppable-container'; -import type { Dataset } from '../utils'; import { FilterMenu } from './filter-menu/filter-menu'; -import { formatPatternWithoutNegation, initPatternForColumn } from './pattern-helpers'; import './filter-pane.scss'; export interface FilterPaneProps { - dataset: Dataset | undefined; + querySource: QuerySource | undefined; filter: SqlExpression; onFilterChange(filter: SqlExpression): void; - queryDruidSql(sqlQueryPayload: Record): Promise; + runSqlQuery(query: string | SqlQuery): Promise; + onAddToSourceQueryAsColumn?: (expression: SqlExpression) => void; + onMoveToSourceQueryAsClause?: (expression: SqlExpression, changeWhere?: SqlExpression) => void; } export const FilterPane = forwardRef(function FilterPane(props: FilterPaneProps, ref) { - const { dataset, filter, onFilterChange, queryDruidSql } = props; + const { + querySource, + filter, + onFilterChange, + runSqlQuery, + onAddToSourceQueryAsColumn, + onMoveToSourceQueryAsClause, + } = props; const patterns = fitFilterPatterns(filter); const [menuIndex, setMenuIndex] = useState(-1); - const [menuNew, setMenuNew] = useState<{ column?: ExpressionMeta }>(); + const [menuNew, setMenuNew] = useState<{ column?: Column }>(); - function filterOn(column: ExpressionMeta) { + function filterOn(column: Column) { const relevantPatternIndex = patterns.findIndex( - pattern => - pattern.type !== 'custom' && pattern.column === column.expression.getFirstColumnName(), + pattern => pattern.type !== 'custom' && pattern.column === column.name, ); if (relevantPatternIndex < 0) { setMenuNew({ column }); @@ -72,17 +85,18 @@ export const FilterPane = forwardRef(function FilterPane(props: FilterPaneProps, return ( -
); }); diff --git a/web-console/src/views/explore-view/highlight-bubble/highlight-bubble.scss b/web-console/src/views/explore-view/components/highlight-bubble/highlight-bubble.scss similarity index 100% rename from web-console/src/views/explore-view/highlight-bubble/highlight-bubble.scss rename to web-console/src/views/explore-view/components/highlight-bubble/highlight-bubble.scss diff --git a/web-console/src/views/explore-view/highlight-bubble/highlight-bubble.tsx b/web-console/src/views/explore-view/components/highlight-bubble/highlight-bubble.tsx similarity index 95% rename from web-console/src/views/explore-view/highlight-bubble/highlight-bubble.tsx rename to web-console/src/views/explore-view/components/highlight-bubble/highlight-bubble.tsx index cf26029957c2..b3c74d7553c7 100644 --- a/web-console/src/views/explore-view/highlight-bubble/highlight-bubble.tsx +++ b/web-console/src/views/explore-view/components/highlight-bubble/highlight-bubble.tsx @@ -21,8 +21,8 @@ import React, { useCallback, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useStore } from 'zustand'; -import { useResizeObserver } from '../../../hooks'; -import { highlightStore } from '../highlight-store/highlight-store'; +import { useResizeObserver } from '../../../../hooks'; +import { highlightStore } from '../../highlight-store/highlight-store'; import './highlight-bubble.scss'; diff --git a/web-console/src/views/explore-view/components/index.ts b/web-console/src/views/explore-view/components/index.ts new file mode 100644 index 000000000000..3cbeb6fe6d37 --- /dev/null +++ b/web-console/src/views/explore-view/components/index.ts @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './control-pane/control-pane'; +export * from './droppable-container/droppable-container'; +export * from './filter-pane/filter-pane'; +export * from './generic-output-table/generic-output-table'; +export * from './highlight-bubble/highlight-bubble'; +export * from './issue/issue'; +export * from './module-pane/module-pane'; +export * from './module-picker/module-picker'; +export * from './resource-pane/resource-pane'; +export * from './source-pane/source-pane'; +export * from './source-query-pane/source-query-pane'; +export * from './sql-input/sql-input'; +export * from './utc-date-input/utc-date-input'; diff --git a/web-console/src/views/explore-view/components/issue/issue.scss b/web-console/src/views/explore-view/components/issue/issue.scss new file mode 100644 index 000000000000..86c279bab1ff --- /dev/null +++ b/web-console/src/views/explore-view/components/issue/issue.scss @@ -0,0 +1,24 @@ +/* + * 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. + */ + +.issue { + display: flex; + justify-content: center; + align-items: center; + background: rgba(0, 0, 0, 0.5); +} diff --git a/web-console/src/views/explore-view/components/issue/issue.tsx b/web-console/src/views/explore-view/components/issue/issue.tsx new file mode 100644 index 000000000000..4374c626511c --- /dev/null +++ b/web-console/src/views/explore-view/components/issue/issue.tsx @@ -0,0 +1,37 @@ +/* + * 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 classNames from 'classnames'; +import React from 'react'; + +import './issue.scss'; + +export interface IssueProps { + className?: string; + issue: string; +} + +export const Issue = React.memo(function Issue(props: IssueProps) { + const { className, issue } = props; + + return ( +
+
{issue}
+
+ ); +}); diff --git a/web-console/src/views/explore-view/components/module-pane/module-pane.scss b/web-console/src/views/explore-view/components/module-pane/module-pane.scss new file mode 100644 index 000000000000..d015fca9ecd3 --- /dev/null +++ b/web-console/src/views/explore-view/components/module-pane/module-pane.scss @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import '../../../../variables'; + +.module-pane { + position: relative; + @include card-like; + + & > .module { + width: 100%; + height: 100%; + position: relative; + + & > .echart-container { + position: absolute; + width: 100%; + height: 100%; + } + + & > .issue { + position: absolute; + width: 100%; + height: 100%; + } + } + + .tile-content { + width: 100%; + height: 100%; + + &.issue { + display: flex; + justify-content: center; + align-items: center; + } + + & > * { + width: 100%; + height: 100%; + } + } +} diff --git a/web-console/src/views/explore-view/components/module-pane/module-pane.tsx b/web-console/src/views/explore-view/components/module-pane/module-pane.tsx new file mode 100644 index 000000000000..4e216e3ac389 --- /dev/null +++ b/web-console/src/views/explore-view/components/module-pane/module-pane.tsx @@ -0,0 +1,100 @@ +/* + * 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 { ResizeSensor } from '@blueprintjs/core'; +import type { QueryResult, SqlExpression, SqlQuery } from '@druid-toolkit/query'; +import React, { useState } from 'react'; + +import type { ParameterDefinition, QuerySource } from '../../models'; +import { effectiveParameterDefault, Stage } from '../../models'; +import { ModuleRepository } from '../../module-repository/module-repository'; +import { Issue } from '../issue/issue'; + +import './module-pane.scss'; + +function fillInDefaults( + parameterValues: Record, + parameters: Record, + querySource: QuerySource, +): Record { + const parameterValuesWithDefaults = { ...parameterValues }; + Object.entries(parameters).forEach(([propName, propDefinition]) => { + if (typeof parameterValuesWithDefaults[propName] !== 'undefined') return; + parameterValuesWithDefaults[propName] = effectiveParameterDefault(propDefinition, querySource); + }); + return parameterValuesWithDefaults; +} + +export interface ModulePaneProps { + moduleId: string; + querySource: QuerySource; + where: SqlExpression; + setWhere(where: SqlExpression): void; + + parameterValues: Record; + setParameterValues(parameters: Record): void; + runSqlQuery(query: string | SqlQuery): Promise; +} + +export const ModulePane = function ModulePane(props: ModulePaneProps) { + const { + moduleId, + querySource, + where, + setWhere, + parameterValues, + setParameterValues, + runSqlQuery, + } = props; + const [stage, setStage] = useState(); + + const module = ModuleRepository.getModule(moduleId); + + let content: React.ReactNode; + if (module) { + const modelIssue = undefined; // AutoForm.issueWithModel(moduleTileConfig.config, module.configFields); + if (modelIssue) { + content = ; + } else if (stage) { + content = React.createElement(module.component, { + stage, + querySource, + where, + setWhere, + parameterValues: fillInDefaults(parameterValues, module.parameters, querySource), + setParameterValues, + runSqlQuery, + }); + } + } else { + content = ; + } + + return ( + { + if (entries.length !== 1) return; + const newStage = new Stage(entries[0].contentRect.width, entries[0].contentRect.height); + if (newStage.equals(stage)) return; + setStage(newStage); + }} + > +
{content}
+
+ ); +}; diff --git a/web-console/src/views/explore-view/tile-picker/tile-picker.scss b/web-console/src/views/explore-view/components/module-picker/module-picker.scss similarity index 84% rename from web-console/src/views/explore-view/tile-picker/tile-picker.scss rename to web-console/src/views/explore-view/components/module-picker/module-picker.scss index 679965670b30..0979ba3789f0 100644 --- a/web-console/src/views/explore-view/tile-picker/tile-picker.scss +++ b/web-console/src/views/explore-view/components/module-picker/module-picker.scss @@ -16,14 +16,10 @@ * limitations under the License. */ -@import '../../../variables'; +@import '../../../../variables'; -.tile-picker { - .picker-button .#{$ns}-button-text { - flex: 1 1 auto; - } - - .more-button.#{$ns}-popover-target { +.module-picker { + .more-button.#{$bp-ns}-popover-target { flex: 0; } } diff --git a/web-console/src/views/explore-view/tile-picker/tile-picker.tsx b/web-console/src/views/explore-view/components/module-picker/module-picker.tsx similarity index 71% rename from web-console/src/views/explore-view/tile-picker/tile-picker.tsx rename to web-console/src/views/explore-view/components/module-picker/module-picker.tsx index cefb37b0f5d7..d4561e1431fa 100644 --- a/web-console/src/views/explore-view/tile-picker/tile-picker.tsx +++ b/web-console/src/views/explore-view/components/module-picker/module-picker.tsx @@ -22,25 +22,21 @@ import { IconNames } from '@blueprintjs/icons'; import type { JSX } from 'react'; import React from 'react'; -import './tile-picker.scss'; +import './module-picker.scss'; -export interface TilePickerProps { - modules: readonly { moduleName: Name; icon: IconName; label: string }[]; - selectedTileName: Name | undefined; - onSelectedTileNameChange(newSelectedTileName: Name): void; +export interface ModulePickerProps { + modules: readonly { id: string; icon: IconName; label: string }[]; + selectedModuleId: string | undefined; + onSelectedModuleIdChange(newSelectedModuleId: string): void; moreMenu?: JSX.Element; } -declare function TilePickerComponent( - props: TilePickerProps, -): JSX.Element; +export const ModulePicker = React.memo(function ModulePicker(props: ModulePickerProps) { + const { modules, selectedModuleId, onSelectedModuleIdChange, moreMenu } = props; -export const TilePicker = React.memo(function TilePicker(props: TilePickerProps) { - const { modules, selectedTileName, onSelectedTileNameChange, moreMenu } = props; - - const selectedTileManifest = modules.find(module => module.moduleName === selectedTileName); + const selectedTileManifest = modules.find(module => module.id === selectedModuleId); return ( - + onSelectedTileNameChange(module.moduleName)} + onClick={() => onSelectedModuleIdChange(module.id)} /> ))} @@ -61,7 +57,7 @@ export const TilePicker = React.memo(function TilePicker(props: TilePickerProps< >
+ + + + ); +}); diff --git a/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.scss b/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.scss new file mode 100644 index 000000000000..c7edf7eeafcd --- /dev/null +++ b/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.scss @@ -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 '../../../../../variables'; + +.measure-dialog { + &.#{$bp-ns}-dialog { + width: 80vw; + } + + .#{$bp-ns}-dialog-body { + display: flex; + gap: 12px; + + .controls { + flex: 1; + display: flex; + flex-direction: column; + + .sql-expression-form-group { + flex: 1; + margin: 0; + + .#{$bp-ns}-form-content { + flex: 1; + + .flexible-query-input { + height: 100%; + } + } + } + } + + .preview-pane { + width: 300px; + } + } + + .#{$bp-ns}-dialog-footer { + margin-top: 0; + } +} diff --git a/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.tsx b/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.tsx new file mode 100644 index 000000000000..f02facf61f09 --- /dev/null +++ b/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.tsx @@ -0,0 +1,176 @@ +/* + * 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 { Button, Classes, Dialog, FormGroup, InputGroup, Intent, Tag } from '@blueprintjs/core'; +import { + type QueryResult, + L, + sql, + SqlExpression, + SqlQuery, + SqlWithPart, +} from '@druid-toolkit/query'; +import React, { useMemo, useState } from 'react'; + +import { AppToaster } from '../../../../../singletons'; +import { Measure, QuerySource } from '../../../models'; +import type { Rename } from '../../../utils'; +import { PreviewPane } from '../../preview-pane/preview-pane'; +import { SqlInput } from '../../sql-input/sql-input'; + +import './measure-dialog.scss'; + +export interface MeasureDialogProps { + initMeasure: Measure | undefined; + measureToDuplicate?: string; + onApply(newQuery: SqlQuery, rename: Rename | undefined): void; + querySource: QuerySource; + runSqlQuery(query: string | SqlQuery): Promise; + onClose(): void; +} + +export const MeasureDialog = React.memo(function MeasureDialog(props: MeasureDialogProps) { + const { initMeasure, measureToDuplicate, onApply, querySource, runSqlQuery, onClose } = props; + + const [outputName, setOutputName] = useState(initMeasure?.name || ''); + const [formula, setFormula] = useState(String(initMeasure?.expression || '')); + + const previewQuery = useMemo(() => { + const expression = SqlExpression.maybeParse(formula); + if (!expression) return; + return SqlQuery.from('t') + .changeWithParts([SqlWithPart.simple('t', QuerySource.stripToBaseSource(querySource.query))]) + .addSelect(L('Overall').as('label')) + .addSelect(expression.as('value')) + .applyIf( + querySource.baseColumns.find(column => column.isTimeColumn()), + q => q.addWhere(sql`MAX_DATA_TIME() - INTERVAL '14' DAY <= __time`), + ) + .toString(); + }, [querySource.query, formula]); + + return ( + +
+
+ + { + setOutputName(e.target.value.slice(0, Measure.MAX_NAME_LENGTH)); + }} + placeholder="Measure name" + /> + + + { + setFormula(formula); + }} + columns={querySource.baseColumns} + placeholder="SQL expression" + editorHeight={400} + autoFocus + showGutter={false} + /> + +
+ +
+
+
+
+
+
+
+
+ ); +}); diff --git a/web-console/src/views/explore-view/components/resource-pane/resource-pane.scss b/web-console/src/views/explore-view/components/resource-pane/resource-pane.scss new file mode 100644 index 000000000000..89a166062637 --- /dev/null +++ b/web-console/src/views/explore-view/components/resource-pane/resource-pane.scss @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import '../../../../variables'; + +.resource-pane { + display: flex; + flex-direction: column; + + .search-input { + margin: 4px; + } + + .list-header { + padding: 6px; + text-transform: uppercase; + font-weight: bold; + position: relative; + + .header-buttons { + position: absolute; + top: 0; + right: 0; + } + } + + .column-resource-list { + flex: 3; + overflow: auto; + border-bottom: 2px solid $light-gray4; + + .#{$bp-ns}-dark & { + border-bottom: 2px solid $dark-gray2; + } + + .column-resource { + display: block; + } + } + + .measure-resource-list { + flex: 1; + overflow: auto; + + .measure-resource { + display: block; + } + } +} diff --git a/web-console/src/views/explore-view/components/resource-pane/resource-pane.tsx b/web-console/src/views/explore-view/components/resource-pane/resource-pane.tsx new file mode 100644 index 000000000000..f6489eb17e2c --- /dev/null +++ b/web-console/src/views/explore-view/components/resource-pane/resource-pane.tsx @@ -0,0 +1,283 @@ +/* + * 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 { + Button, + ButtonGroup, + Classes, + Icon, + Intent, + Menu, + MenuDivider, + MenuItem, + Popover, +} from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import type { Column, QueryResult, SqlExpression, SqlQuery } from '@druid-toolkit/query'; +import classNames from 'classnames'; +import React, { useState } from 'react'; + +import { ClearableInput } from '../../../../components'; +import { caseInsensitiveContains, columnToIcon, filterMap } from '../../../../utils'; +import { DragHelper } from '../../drag-helper'; +import type { Measure, QuerySource } from '../../models'; +import type { Rename } from '../../utils'; + +import { ColumnDialog } from './column-dialog/column-dialog'; +import { MeasureDialog } from './measure-dialog/measure-dialog'; + +import './resource-pane.scss'; + +interface ColumnEditorOpenOn { + expression?: SqlExpression; + columnToDuplicate?: string; +} + +interface MeasureEditorOpenOn { + measure?: Measure; + measureToDuplicate?: string; +} + +export interface ResourcePaneProps { + querySource: QuerySource; + onQueryChange: (newQuery: SqlQuery, rename: Rename | undefined) => void; + onFilter?: (column: Column) => void; + onShowColumn(column: Column): void; + onShowMeasure(measure: Measure): void; + runSqlQuery(query: string | SqlQuery): Promise; +} + +export const ResourcePane = function ResourcePane(props: ResourcePaneProps) { + const { querySource, onQueryChange, onFilter, onShowColumn, onShowMeasure, runSqlQuery } = props; + const [columnSearch, setColumnSearch] = useState(''); + + const [columnEditorOpenOn, setColumnEditorOpenOn] = useState(); + const [measureEditorOpenOn, setMeasureEditorOpenOn] = useState(); + + function applyUtil(nameTransform: (columnName: string) => string) { + if (!querySource) return; + const columnNameMap = querySource.getColumnNameMap(nameTransform); + onQueryChange(querySource.applyColumnNameMap(columnNameMap), columnNameMap); + } + + return ( +
+ +
+ Columns + +
+
+ {filterMap(querySource.columns, (column, i) => { + const columnName = column.name; + if (!caseInsensitiveContains(columnName, columnSearch)) return; + return ( + + {onFilter && ( + onFilter(column)} + /> + )} + onShowColumn(column)} + /> + + + setColumnEditorOpenOn({ + expression: querySource.getSourceExpressionForColumn(columnName), + }) + } + /> + + setColumnEditorOpenOn({ + columnToDuplicate: columnName, + expression: querySource + .getSourceExpressionForColumn(columnName) + .as(querySource.getAvailableName(columnName)), + }) + } + /> + onQueryChange(querySource.deleteColumn(columnName), undefined)} + /> + + } + > +
{ + e.dataTransfer.effectAllowed = 'all'; + DragHelper.dragColumn = column; + DragHelper.createDragGhost(e.dataTransfer, columnName); + }} + > + +
+ {columnName} +
+
+
+ ); + })} +
+
+ Measures + +
+
+ {filterMap(querySource.measures, (measure, i) => { + const measureName = measure.name; + if (!caseInsensitiveContains(measureName, columnSearch)) return; + return ( + + onShowMeasure(measure)} + /> + + + setMeasureEditorOpenOn({ + measure, + }) + } + /> + + setMeasureEditorOpenOn({ + measureToDuplicate: measureName, + measure: measure.changeAs(querySource.getAvailableName(measureName)), + }) + } + /> + onQueryChange(querySource.deleteMeasure(measureName), undefined)} + /> + + } + > +
{ + e.dataTransfer.effectAllowed = 'all'; + DragHelper.dragMeasure = measure.toAggregateBasedMeasure(); + DragHelper.createDragGhost(e.dataTransfer, measure.name); + }} + > + +
+ {measureName} +
+
+
+ ); + })} +
+ {columnEditorOpenOn && ( + setColumnEditorOpenOn(undefined)} + /> + )} + {measureEditorOpenOn && ( + setMeasureEditorOpenOn(undefined)} + /> + )} +
+ ); +}; diff --git a/web-console/src/views/explore-view/source-pane/source-pane.scss b/web-console/src/views/explore-view/components/source-pane/source-pane.scss similarity index 88% rename from web-console/src/views/explore-view/source-pane/source-pane.scss rename to web-console/src/views/explore-view/components/source-pane/source-pane.scss index 741f16562fb8..e10b702cc482 100644 --- a/web-console/src/views/explore-view/source-pane/source-pane.scss +++ b/web-console/src/views/explore-view/components/source-pane/source-pane.scss @@ -16,15 +16,7 @@ * limitations under the License. */ -@import '../../../variables'; - -.source-pane { - .#{$ns}-button-text { - flex: 1 1 auto; - } -} - -.source-menu { +.source-pane-menu { max-height: 80vh; overflow: auto; } diff --git a/web-console/src/views/explore-view/source-pane/source-pane.tsx b/web-console/src/views/explore-view/components/source-pane/source-pane.tsx similarity index 52% rename from web-console/src/views/explore-view/source-pane/source-pane.tsx rename to web-console/src/views/explore-view/components/source-pane/source-pane.tsx index 2208bc32a390..1524b24540f1 100644 --- a/web-console/src/views/explore-view/source-pane/source-pane.tsx +++ b/web-console/src/views/explore-view/components/source-pane/source-pane.tsx @@ -18,23 +18,37 @@ import { Button, Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; +import { SqlQuery, SqlTable } from '@druid-toolkit/query'; +import type { JSX } from 'react'; import React from 'react'; -import { useQueryManager } from '../../../hooks'; -import { queryDruidSql } from '../../../utils'; +import { useQueryManager } from '../../../../hooks'; +import { queryDruidSql } from '../../../../utils'; import './source-pane.scss'; +function formatQuerySource(source: SqlQuery | undefined): string | JSX.Element { + if (!(source instanceof SqlQuery)) return 'No source selected'; + const fromExpressions = source.getFromExpressions(); + if (fromExpressions.length !== 1) return 'Multiple FROM expressions'; + const fromExpression = fromExpressions[0]; + if (!(fromExpression instanceof SqlTable)) return 'Complex FROM expression'; + return fromExpression.getName(); +} + export interface SourcePaneProps { - selectedTableName: string; - onSelectedTableNameChange(newSelectedSource: string): void; + selectedSource: SqlQuery | undefined; + onSelectTable(tableName: string): void; + onShowSourceQuery?: () => void; + fill?: boolean; + minimal?: boolean; disabled?: boolean; } export const SourcePane = React.memo(function SourcePane(props: SourcePaneProps) { - const { selectedTableName, onSelectedTableNameChange, disabled } = props; + const { selectedSource, onSelectTable, onShowSourceQuery, fill, minimal, disabled } = props; - const [sources] = useQueryManager({ + const [tables] = useQueryManager({ initQuery: '', processQuery: async () => { const tables = await queryDruidSql<{ TABLE_NAME: string }>({ @@ -52,20 +66,25 @@ export const SourcePane = React.memo(function SourcePane(props: SourcePaneProps) minimal position={Position.BOTTOM_LEFT} content={ - - {sources.loading && } - {sources.data?.map((s, i) => ( - onSelectedTableNameChange(s)} /> + + {onShowSourceQuery && ( + + )} + {onShowSourceQuery && } + {tables.loading && } + {tables.data?.map((table, i) => ( + onSelectTable(table)} /> ))} - {!sources.data?.length && } + {!tables.data?.length && } } > - - - ), - }; - } - - case 'number': - return { - element: ( - onValueChange(v)} - placeholder={parameter.control?.placeholder} - fill - min={parameter.min} - max={parameter.max} - /> - ), - }; - - case 'string': - return { - element: ( - onValueChange(e.target.value)} - placeholder={parameter.control?.placeholder} - fill - /> - ), - }; - - case 'option': { - const controlOptions = parameter.options || []; - const selectedOption: OptionValue | undefined = controlOptions.find(o => o === value); - return { - element: ( - - {controlOptions.map((o, i) => ( - onValueChange(o)} - /> - ))} - - } - > - } - /> - - ), - }; - } - - case 'options': { - return { - element: ( - - ), - }; - } - - case 'column': - return { - element: ( - onValueChange(undefined) - } - onSelectColumn={onValueChange} - /> - } - > - } - /> - - ), - onDropColumn: onValueChange, - }; - - case 'columns': { - return { - element: ( - - ), - onDropColumn: (column: ExpressionMeta) => { - value = value || []; - const columnName = column.name; - if ( - !parameter.allowDuplicates && - value.find((v: ExpressionMeta) => v.name === columnName) - ) { - AppToaster.show({ - intent: Intent.WARNING, - message: `"${columnName}" already selected`, - }); - return; - } - onValueChange(value.concat(column)); - }, - }; - } - - case 'aggregate': { - return { - element: ( - onValueChange(undefined) - } - /> - } - > - } - /> - - ), - onDropColumn: column => { - const aggregates = getPossibleAggregateForColumn(column); - if (!aggregates.length) return; - onValueChange(aggregates[0]); - }, - }; - } - - case 'aggregates': { - return { - element: ( - ( - onValueChange((value as ExpressionMeta[]).concat(c))} - /> - )} - /> - ), - onDropColumn: column => { - value = value || []; - const aggregates = getPossibleAggregateForColumn(column).filter( - p => !value.some((v: ExpressionMeta) => v.name === p.name), - ); - if (!aggregates.length) return; - onValueChange(value.concat(aggregates[0])); - }, - }; - } - - default: - return { - element: ( -