You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@druid.apache.org by kf...@apache.org on 2024/10/01 03:37:09 UTC
(druid) branch 31.0.0 updated: [Backport] console work to 31 (#16991, #17132, #17133, #17135, #17180) (#17200)
This is an automated email from the ASF dual-hosted git repository.
kfaraz pushed a commit to branch 31.0.0
in repository https://gitbox.apache.org/repos/asf/druid.git
The following commit(s) were added to refs/heads/31.0.0 by this push:
new 20629487212 [Backport] console work to 31 (#16991, #17132, #17133, #17135, #17180) (#17200)
20629487212 is described below
commit 2062948721217893e20568bc2d6cde656e57108d
Author: Vadim Ogievetsky <va...@ogievetsky.com>
AuthorDate: Mon Sep 30 20:37:03 2024 -0700
[Backport] console work to 31 (#16991, #17132, #17133, #17135, #17180) (#17200)
* Web console query view improvements (#16991)
* Made maxNumTaskOptions configurable in the Query view
* Updated the copy for taskAssignment options
* Reordered options in engine menu for msq engine
* fixed snapshot
* maxNumTaskOptions -> maxTasksOptions
* added back select destination item
* fixed duplicate menu item
* snapshot
* Added the ability to hide certain engine menu options
* Added the ability to hide/show more menu items
* Make the tooltip better and improve structure (#17132)
* switch to using arrays by default (#17133)
* Web console: add stage graph (#17135)
* Web console: revamp the experimental explore view (#17180)
* explore revamp
* remove ToDo
* fix CodeQL
* add tooltips
* show issue on echart chars
* fix: browser back does not refresh chart
* fix maxRows 0
* be more resiliant to missing __time
---------
Co-authored-by: Sébastien <se...@imply.io>
---
licenses.yaml | 47 +-
web-console/README.md | 3 +-
web-console/package-lock.json | 112 +-
web-console/package.json | 4 +-
.../src/components/braced-text/braced-text.tsx | 4 +-
.../__snapshots__/click-to-copy.spec.tsx.snap | 2 +-
.../src/components/click-to-copy/click-to-copy.tsx | 2 +-
.../__snapshots__/header-bar.spec.tsx.snap | 5 +-
.../src/components/header-bar/header-bar.tsx | 7 +-
web-console/src/components/index.ts | 1 +
.../record-table-pane/record-table-pane.tsx | 2 +-
.../__snapshots__/rule-editor.spec.tsx.snap | 4 -
.../src/components/rule-editor/rule-editor.tsx | 2 +-
.../supervisor-history-panel.tsx | 6 +-
.../__snapshots__/table-cell.spec.tsx.snap | 8 +-
.../src/components/table-cell/table-cell.tsx | 6 +-
.../table-clickable-cell/table-clickable-cell.tsx | 4 +-
web-console/src/console-application.tsx | 15 +-
.../compaction-config-dialog.spec.tsx.snap | 21 +-
.../compaction-config-dialog.tsx | 11 +-
.../compaction-history-dialog.tsx | 4 +-
.../src/dialogs/history-dialog/history-dialog.tsx | 4 +-
.../__snapshots__/retention-dialog.spec.tsx.snap | 2 +-
.../src/druid-models/execution/execution.ts | 16 +-
.../druid-models/ingestion-spec/ingestion-spec.tsx | 3 +
.../druid-models/query-context/query-context.tsx | 2 +
web-console/src/druid-models/stages/graph-info.ts | 77 +
web-console/src/druid-models/stages/stages.spec.ts | 92 +
web-console/src/druid-models/stages/stages.ts | 194 +-
web-console/src/druid-models/task/task.ts | 6 +
.../workbench-query/workbench-query.ts | 2 +-
web-console/src/entry.scss | 8 +
web-console/src/entry.tsx | 4 +-
web-console/src/helpers/capabilities.ts | 42 +-
web-console/src/hooks/index.ts | 1 +
.../use-hash-and-local-storage-hybrid-state.ts | 83 +
web-console/src/hooks/use-query-manager.ts | 23 +-
.../src/singletons/workbench-running-promises.ts | 7 +-
.../filter-menu.scss => utils/base64-url.spec.ts} | 39 +-
.../source-pane.scss => utils/base64-url.ts} | 22 +-
web-console/src/utils/basic-action.tsx | 2 +-
web-console/src/utils/date.ts | 30 +-
web-console/src/utils/general.tsx | 74 +-
web-console/src/utils/index.tsx | 2 +
web-console/src/utils/local-storage-keys.tsx | 3 +-
.../mouse-tooltip/mouse-tooltip.scss} | 20 +-
.../src/utils/mouse-tooltip/mouse-tooltip.ts | 77 +
web-console/src/utils/table-helpers.ts | 3 +-
web-console/src/utils/types.ts | 6 +
.../explore-view/column-picker/column-picker.tsx | 47 -
.../column-picker-menu/column-picker-menu.scss | 0
.../column-picker-menu/column-picker-menu.tsx | 26 +-
.../components/column-picker/column-picker.tsx | 84 +
.../control-pane/control-pane.scss | 4 +-
.../components/control-pane/control-pane.tsx | 386 +
.../control-pane/expression-menu.scss} | 37 +-
.../components/control-pane/expression-menu.tsx | 212 +
.../control-pane/measure-menu.scss} | 46 +-
.../components/control-pane/measure-menu.tsx | 291 +
.../control-pane/named-expressions-input.scss} | 6 +-
.../control-pane/named-expressions-input.tsx} | 122 +-
.../control-pane/options-input.tsx | 68 +-
.../droppable-container/droppable-container.scss | 2 +-
.../droppable-container/droppable-container.tsx | 74 +
.../filter-pane/column-value/column-value.scss | 0
.../filter-pane/column-value/column-value.tsx | 0
.../contains-filter-control.scss | 0
.../contains-filter-control.tsx | 97 +
.../filter-pane/filter-menu/filter-menu.scss | 25 +-
.../filter-pane/filter-menu/filter-menu.tsx | 425 +
.../number-range-filter-control.tsx | 69 +
.../regexp-filter-control.scss | 0
.../regexp-filter-control.tsx | 92 +-
.../time-interval-filter-control.scss} | 11 +-
.../time-interval-filter-control.tsx | 53 +
.../time-relative-filter-control.tsx | 61 +-
.../values-filter-control.scss | 0
.../values-filter-control.tsx | 120 +
.../{ => components}/filter-pane/filter-pane.scss | 4 +-
.../{ => components}/filter-pane/filter-pane.tsx | 80 +-
.../generic-output-table/generic-output-table.scss | 8 +-
.../generic-output-table/generic-output-table.tsx | 246 +-
.../highlight-bubble/highlight-bubble.scss | 0
.../highlight-bubble/highlight-bubble.tsx | 4 +-
.../explore-view/{modules => }/components/index.ts | 12 +
.../issue/issue.scss} | 12 +-
.../issue/issue.tsx} | 37 +-
.../module-pane/module-pane.scss} | 53 +-
.../components/module-pane/module-pane.tsx | 100 +
.../module-picker/module-picker.scss} | 9 +-
.../module-picker/module-picker.tsx} | 28 +-
.../preview-pane/preview-pane.scss} | 33 +-
.../components/preview-pane/preview-pane.tsx | 72 +
.../column-dialog/column-dialog.scss} | 41 +-
.../resource-pane/column-dialog/column-dialog.tsx | 169 +
.../measure-dialog/measure-dialog.scss} | 41 +-
.../measure-dialog/measure-dialog.tsx | 176 +
.../resource-pane/resource-pane.scss | 42 +-
.../components/resource-pane/resource-pane.tsx | 283 +
.../source-pane/source-pane.scss} | 5 +-
.../{ => components}/source-pane/source-pane.tsx | 47 +-
.../source-query-pane/source-query-pane.scss} | 30 +-
.../source-query-pane/source-query-pane.tsx | 83 +
.../components/sql-input/sql-input.tsx | 167 +
.../components/utc-date-input/utc-date-input.tsx | 86 +
.../explore-view/control-pane/aggregate-menu.tsx | 85 -
.../explore-view/control-pane/aggregates-input.tsx | 69 -
.../explore-view/control-pane/control-pane.tsx | 348 -
.../src/views/explore-view/control-pane/helpers.ts | 111 -
web-console/src/views/explore-view/drag-helper.ts | 7 +-
.../droppable-container/droppable-container.tsx | 67 -
.../src/views/explore-view/explore-state.ts | 232 +
.../src/views/explore-view/explore-view.scss | 81 +-
.../src/views/explore-view/explore-view.tsx | 685 +-
.../contains-filter-control.tsx | 132 -
.../custom-filter-control.tsx | 67 -
.../filter-pane/filter-menu/filter-menu.tsx | 191 -
.../time-interval-filter-control.tsx | 131 -
.../values-filter-control.tsx | 156 -
.../highlight-store/highlight-store.ts | 43 +-
.../views/explore-view/models/expression-meta.ts | 102 +
.../highlight-store.ts => models/highlight.ts} | 47 +-
.../{modules/components => models}/index.ts | 8 +-
.../views/explore-view/models/measure-pattern.ts | 148 +
.../src/views/explore-view/models/measure.ts | 234 +
.../src/views/explore-view/models/parameter.ts | 273 +
.../src/views/explore-view/models/query-source.ts | 364 +
.../tile-picker.scss => models/stage.ts} | 14 +-
.../module-repository/module-repository.ts | 57 +
.../modules/bar-chart-echarts-module.ts | 155 -
.../explore-view/modules/bar-chart-module.tsx | 209 +
...eact-module.scss => grouping-table-module.scss} | 5 +-
.../explore-view/modules/grouping-table-module.tsx | 303 +
.../src/views/explore-view/modules/index.ts | 11 +-
.../modules/multi-axis-chart-echarts-module.ts | 266 -
.../modules/multi-axis-chart-module.tsx | 333 +
.../modules/pie-chart-echarts-module.ts | 201 -
.../explore-view/modules/pie-chart-module.tsx | 257 +
.../record-table-module.scss} | 11 +-
.../explore-view/modules/record-table-module.tsx | 96 +
.../explore-view/modules/table-react-module.tsx | 304 -
.../modules/time-chart-echarts-module.ts | 368 -
.../explore-view/modules/time-chart-module.tsx | 435 +
.../utils/__snapshots__/table-query.spec.ts.snap | 39502 ----------------
.../views/explore-view/modules/utils/utils.spec.ts | 101 -
.../src/views/explore-view/modules/utils/utils.ts | 111 -
.../views/explore-view/query-macros/aggregate.ts | 58 +
.../{modules/components => query-macros}/index.ts | 3 +-
.../max-data-time.ts} | 34 +-
.../explore-view/resource-pane/resource-pane.tsx | 93 -
.../utils/__snapshots__/table-query.spec.ts.snap | 46048 +++++++++++++++++++
.../src/views/explore-view/utils/date-format.ts | 1 +
.../explore-view/utils/duration.spec.ts} | 41 +-
.../src/views/explore-view/utils/duration.ts | 46 +
.../filter-pattern-helpers.ts} | 38 +-
.../src/views/explore-view/utils/general.ts | 54 +
web-console/src/views/explore-view/utils/index.ts | 6 +
.../views/explore-view/utils/max-time-for-table.ts | 52 +
web-console/src/views/explore-view/utils/misc.ts | 48 +-
.../{modules => }/utils/table-query.spec.ts | 345 +-
.../{modules => }/utils/table-query.ts | 400 +-
.../explore-view/utils/time-manipulation.spec.ts | 225 +
.../views/explore-view/utils/time-manipulation.ts | 193 +
.../__snapshots__/home-view.spec.tsx.snap | 30 +-
web-console/src/views/home-view/home-view.scss | 4 +
.../views/home-view/status-card/status-card.tsx | 22 +-
.../__snapshots__/filter-table.spec.tsx.snap | 12 +-
.../src/views/load-data-view/load-data-view.tsx | 6 +-
.../__snapshots__/parse-time-table.spec.tsx.snap | 12 +-
.../__snapshots__/schema-table.spec.tsx.snap | 12 +-
.../__snapshots__/transform-table.spec.tsx.snap | 12 +-
.../destination-form/destination-form.tsx | 10 +-
.../schema-step/preview-table/preview-table.tsx | 2 +-
.../views/supervisors-view/supervisors-view.tsx | 14 +-
web-console/src/views/tasks-view/tasks-view.tsx | 11 +-
.../__snapshots__/column-tree.spec.tsx.snap | 7 +-
.../workbench-view/column-tree/column-tree.tsx | 4 +-
.../execution-details-pane.spec.tsx.snap | 52 +-
.../execution-details-pane.tsx | 27 +-
.../execution-stages-pane.spec.tsx.snap | 52 +-
.../execution-stages-pane.scss | 48 +-
.../execution-stages-pane.tsx | 347 +-
.../execution-summary-panel.tsx | 16 +-
.../explain-dialog/explain-dialog.tsx | 25 +-
.../ingest-success-pane/ingest-success-pane.tsx | 6 +-
.../input-format-step/input-format-step.tsx | 3 +-
.../__snapshots__/max-tasks-button.spec.tsx.snap | 29 +-
.../max-tasks-button/max-tasks-button.tsx | 81 +-
.../views/workbench-view/query-tab/query-tab.tsx | 74 +-
.../recent-query-task-panel.tsx | 16 +-
.../result-table-pane/result-table-pane.tsx | 2 +-
.../views/workbench-view/run-panel/run-panel.tsx | 495 +-
.../workbench-history-dialog.tsx | 13 +-
.../src/views/workbench-view/workbench-view.scss | 14 +-
.../src/views/workbench-view/workbench-view.tsx | 170 +-
195 files changed, 56255 insertions(+), 44718 deletions(-)
diff --git a/licenses.yaml b/licenses.yaml
index c4e2fa52300..e1e7fe2e890 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 ff5012344bd..f9a7f7abbb5 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 44d3cbae2a4..e77b282c002 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 9c9f38d24cb..175fc9415f3 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 69aafe08b02..c0fb0b314ee 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 (
- <span className={classNames('braced-text', className)} title={title}>
+ <span className={classNames('braced-text', className)} {...rest}>
<span className="brace-text">{findMostNumbers(effectiveBraces)}</span>
<span className="real-text">
{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 a53da72f6d1..7c8496b481a 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`] = `
<a
class="click-to-copy"
- title="Click to copy:
+ data-tooltip="Click to copy:
Hello world"
>
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 5d1d2a8cf47..6ba17344b50 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 (
<a
className="click-to-copy"
- title={`Click to copy:\n${text}`}
+ data-tooltip={`Click to copy:\n${text}`}
onClick={() => {
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 fe1e8c92a41..9aac8421b3f 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`] = `
<Blueprint5.Button
active={false}
className="header-entry"
+ data-tooltip="More views"
icon="more"
minimal={true}
/>
@@ -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`] = `
>
<Blueprint5.Button
className="header-entry"
+ data-tooltip="Settings"
icon="cog"
minimal={true}
/>
@@ -418,6 +420,7 @@ exports[`HeaderBar matches snapshot 1`] = `
>
<Blueprint5.Button
className="header-entry"
+ data-tooltip="Help"
icon="help"
minimal={true}
/>
diff --git a/web-console/src/components/header-bar/header-bar.tsx b/web-console/src/components/header-bar/header-bar.tsx
index aed66798299..7cdc8cf1d31 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"
/>
</Popover>
</NavbarGroup>
@@ -407,10 +408,10 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
</Popover>
)}
<Popover content={configMenu} position={Position.BOTTOM_RIGHT}>
- <Button className="header-entry" minimal icon={IconNames.COG} />
+ <Button className="header-entry" minimal icon={IconNames.COG} data-tooltip="Settings" />
</Popover>
<Popover content={helpMenu} position={Position.BOTTOM_RIGHT}>
- <Button className="header-entry" minimal icon={IconNames.HELP} />
+ <Button className="header-entry" minimal icon={IconNames.HELP} data-tooltip="Help" />
</Popover>
</NavbarGroup>
{aboutDialogOpen && <AboutDialog onClose={() => setAboutDialogOpen(false)} />}
diff --git a/web-console/src/components/index.ts b/web-console/src/components/index.ts
index bbae88a5d43..437017a8c76 100644
--- a/web-console/src/components/index.ts
+++ b/web-console/src/components/index.ts
@@ -27,6 +27,7 @@ export * from './clearable-input/clearable-input';
export * from './click-to-copy/click-to-copy';
export * from './deferred/deferred';
export * from './external-link/external-link';
+export * from './fancy-numeric-input/fancy-numeric-input';
export * from './fancy-tab-pane/fancy-tab-pane';
export * from './form-group-with-info/form-group-with-info';
export * from './form-json-selector/form-json-selector';
diff --git a/web-console/src/components/record-table-pane/record-table-pane.tsx b/web-console/src/components/record-table-pane/record-table-pane.tsx
index 262298c1052..e44eab35560 100644
--- a/web-console/src/components/record-table-pane/record-table-pane.tsx
+++ b/web-console/src/components/record-table-pane/record-table-pane.tsx
@@ -140,7 +140,7 @@ export const RecordTablePane = React.memo(function RecordTablePane(props: Record
Header() {
return (
<div className="clickable-cell">
- <div className="output-name" title={columnToSummary(column)}>
+ <div className="output-name" data-tooltip={columnToSummary(column)}>
{icon && <Icon className="type-icon" icon={icon} size={12} />}
{h}
{hasFilterOnHeader(h, i) && <Icon icon={IconNames.FILTER} size={14} />}
diff --git a/web-console/src/components/rule-editor/__snapshots__/rule-editor.spec.tsx.snap b/web-console/src/components/rule-editor/__snapshots__/rule-editor.spec.tsx.snap
index c3c43799667..2769c8af739 100644
--- a/web-console/src/components/rule-editor/__snapshots__/rule-editor.spec.tsx.snap
+++ b/web-console/src/components/rule-editor/__snapshots__/rule-editor.spec.tsx.snap
@@ -348,7 +348,6 @@ exports[`RuleEditor matches snapshot no tier in rule 1`] = `
>
<button
class="bp5-button bp5-minimal"
- title=""
type="button"
>
<span
@@ -1088,7 +1087,6 @@ exports[`RuleEditor matches snapshot with existing tier and non existing tier in
>
<button
class="bp5-button bp5-minimal"
- title=""
type="button"
>
<span
@@ -1480,7 +1478,6 @@ exports[`RuleEditor matches snapshot with existing tier in rule 1`] = `
>
<button
class="bp5-button bp5-minimal"
- title=""
type="button"
>
<span
@@ -1877,7 +1874,6 @@ exports[`RuleEditor matches snapshot with non existing tier in rule 1`] = `
>
<button
class="bp5-button bp5-minimal"
- title=""
type="button"
>
<span
diff --git a/web-console/src/components/rule-editor/rule-editor.tsx b/web-console/src/components/rule-editor/rule-editor.tsx
index 8c40ede1ac5..c5dfb24ddf0 100644
--- a/web-console/src/components/rule-editor/rule-editor.tsx
+++ b/web-console/src/components/rule-editor/rule-editor.tsx
@@ -144,7 +144,7 @@ export const RuleEditor = React.memo(function RuleEditor(props: RuleEditorProps)
minimal
icon={IconNames.PLUS}
disabled={disabled}
- title={disabled ? 'There are no tiers left to assign' : ''}
+ data-tooltip={disabled ? 'There are no tiers left to assign' : undefined}
>
Add historical tier replication
</Button>
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 0e1043a5681..8af5c2df03f 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
<Tab
id={i}
key={i}
- title={pastSupervisor.version}
+ data-tooltip={pastSupervisor.version}
panelClassName="panel"
panel={
<ShowValue
@@ -79,7 +79,7 @@ export const SupervisorHistoryPanel = React.memo(function SupervisorHistoryPanel
}
/>
))}
- <Tabs.Expander />
+ <TabsExpander />
</Tabs>
{diffIndex !== -1 && (
<DiffDialog
diff --git a/web-console/src/components/table-cell/__snapshots__/table-cell.spec.tsx.snap b/web-console/src/components/table-cell/__snapshots__/table-cell.spec.tsx.snap
index 29b4091d7ba..75814e8c7f0 100644
--- a/web-console/src/components/table-cell/__snapshots__/table-cell.spec.tsx.snap
+++ b/web-console/src/components/table-cell/__snapshots__/table-cell.spec.tsx.snap
@@ -3,18 +3,18 @@
exports[`TableCell matches snapshot Date (invalid) 1`] = `
<div
class="table-cell timestamp"
- title="NaN"
+ data-tooltip="NaN"
>
- Unusable date
+ Invalid date
</div>
`;
exports[`TableCell matches snapshot Date 1`] = `
<div
class="table-cell timestamp"
- title="1645664523000"
+ data-tooltip="1645664523000"
>
- 2022-02-24T01:02:03.000Z
+ 2022-02-24 01:02:03
</div>
`;
diff --git a/web-console/src/components/table-cell/table-cell.tsx b/web-console/src/components/table-cell/table-cell.tsx
index 3b31caa85f2..6bd53271d6a 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 (
- <div className="table-cell timestamp" title={String(value.valueOf())}>
- {isNaN(dateValue) ? 'Unusable date' : value.toISOString()}
+ <div className="table-cell timestamp" data-tooltip={String(dateValue)}>
+ {isNaN(dateValue) ? 'Invalid date' : prettyFormatIsoDateWithMsIfNeeded(value.toISOString())}
</div>
);
} 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 fa3d56982a9..aec706a50fc 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 (
<div
className={classNames('table-clickable-cell', className, { disabled })}
- title={title}
onClick={disabled ? undefined : onClick}
+ {...rest}
>
{children}
{hoverIcon && !disabled && <Icon className="hover-icon" icon={hoverIcon} />}
diff --git a/web-console/src/console-application.tsx b/web-console/src/console-application.tsx
index 36a0b8aa392..95336713dbb 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',
<WorkbenchView
@@ -326,8 +318,7 @@ export class ConsoleApplication extends React.PureComponent<
mandatoryQueryContext={mandatoryQueryContext}
baseQueryContext={baseQueryContext}
serverQueryContext={serverQueryContext}
- queryEngines={queryEngines}
- allowExplain
+ queryEngines={capabilities.getSupportedQueryEngines()}
goToTask={this.goToTasksWithTaskId}
getClusterCapacity={maybeGetClusterCapacity}
/>,
@@ -470,7 +461,7 @@ export class ConsoleApplication extends React.PureComponent<
component={this.wrappedClassicBatchDataLoaderView}
/>
)}
- {capabilities.hasCoordinatorAccess() && capabilities.hasMultiStageQuery() && (
+ {capabilities.hasCoordinatorAccess() && capabilities.hasMultiStageQueryTask() && (
<Route path="/sql-data-loader" component={this.wrappedSqlDataLoaderView} />
)}
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 d122f33fbd2..09531d03eac 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"
/>
- <Blueprint5.Tooltip
- compact={false}
- content="There is no compaction config currently set for this datasource"
- hoverCloseDelay={0}
- hoverOpenDelay={100}
- interactionKind="hover-target"
- minimal={false}
- transitionDuration={100}
- >
- <Blueprint5.Button
- disabled={true}
- intent="danger"
- text="Delete"
- />
- </Blueprint5.Tooltip>
+ <Blueprint5.Button
+ data-tooltip="There is no compaction config currently set for this datasource"
+ disabled={true}
+ intent="danger"
+ text="Delete"
+ />
<Blueprint5.Button
onClick={[Function]}
text="Close"
diff --git a/web-console/src/dialogs/compaction-config-dialog/compaction-config-dialog.tsx b/web-console/src/dialogs/compaction-config-dialog/compaction-config-dialog.tsx
index ee286c00760..800d88f78b2 100644
--- a/web-console/src/dialogs/compaction-config-dialog/compaction-config-dialog.tsx
+++ b/web-console/src/dialogs/compaction-config-dialog/compaction-config-dialog.tsx
@@ -16,7 +16,7 @@
* limitations under the License.
*/
-import { Button, Callout, Classes, Code, Dialog, Intent, Switch, Tooltip } from '@blueprintjs/core';
+import { Button, Callout, Classes, Code, Dialog, Intent, Switch } from '@blueprintjs/core';
import React, { useState } from 'react';
import type { FormJsonTabs } from '../../components';
@@ -163,9 +163,12 @@ export const CompactionConfigDialog = React.memo(function CompactionConfigDialog
{compactionConfig ? (
<Button text="Delete" intent={Intent.DANGER} onClick={onDelete} />
) : (
- <Tooltip content="There is no compaction config currently set for this datasource">
- <Button text="Delete" disabled intent={Intent.DANGER} />
- </Tooltip>
+ <Button
+ text="Delete"
+ disabled
+ intent={Intent.DANGER}
+ data-tooltip="There is no compaction config currently set for this datasource"
+ />
)}
<Button text="Close" onClick={onClose} />
<Button
diff --git a/web-console/src/dialogs/compaction-history-dialog/compaction-history-dialog.tsx b/web-console/src/dialogs/compaction-history-dialog/compaction-history-dialog.tsx
index 45234c68ad2..c654a1f9b77 100644
--- a/web-console/src/dialogs/compaction-history-dialog/compaction-history-dialog.tsx
+++ b/web-console/src/dialogs/compaction-history-dialog/compaction-history-dialog.tsx
@@ -16,7 +16,7 @@
* limitations under the License.
*/
-import { Button, Callout, Classes, Dialog, Tab, Tabs, Tag } from '@blueprintjs/core';
+import { Button, Callout, Classes, Dialog, Tab, Tabs, TabsExpander, Tag } from '@blueprintjs/core';
import * as JSONBig from 'json-bigint-native';
import React, { useState } from 'react';
@@ -113,7 +113,7 @@ export const CompactionHistoryDialog = React.memo(function CompactionHistoryDial
}
/>
))}
- <Tabs.Expander />
+ <TabsExpander />
</Tabs>
) : (
<div>
diff --git a/web-console/src/dialogs/history-dialog/history-dialog.tsx b/web-console/src/dialogs/history-dialog/history-dialog.tsx
index 404e2fd273e..b17b8c2e93c 100644
--- a/web-console/src/dialogs/history-dialog/history-dialog.tsx
+++ b/web-console/src/dialogs/history-dialog/history-dialog.tsx
@@ -16,7 +16,7 @@
* limitations under the License.
*/
-import { Button, Classes, Dialog, Tab, Tabs } from '@blueprintjs/core';
+import { Button, Classes, Dialog, Tab, Tabs, TabsExpander } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames';
import * as JSONBig from 'json-bigint-native';
@@ -83,7 +83,7 @@ export const HistoryDialog = React.memo(function HistoryDialog(props: HistoryDia
/>
);
})}
- <Tabs.Expander />
+ <TabsExpander />
</Tabs>
);
}
diff --git a/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap b/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap
index 5a064c5ddd1..e7a3c48569d 100644
--- a/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap
@@ -516,9 +516,9 @@ exports[`RetentionDialog matches snapshot 1`] = `
>
<button
class="bp5-button bp5-disabled bp5-minimal"
+ data-tooltip="There are no tiers left to assign"
disabled=""
tabindex="-1"
- title="There are no tiers left to assign"
type="button"
>
<span
diff --git a/web-console/src/druid-models/execution/execution.ts b/web-console/src/druid-models/execution/execution.ts
index 45bfc517538..f218acc0386 100644
--- a/web-console/src/druid-models/execution/execution.ts
+++ b/web-console/src/druid-models/execution/execution.ts
@@ -255,12 +255,13 @@ export class Execution {
};
}
+ const { createdAt, durationMs, state } = asyncSubmitResult;
return new Execution({
engine: 'sql-msq-task',
id: queryId,
- startTime: new Date(asyncSubmitResult.createdAt),
- duration: asyncSubmitResult.durationMs,
- status: Execution.normalizeAsyncState(asyncSubmitResult.state),
+ startTime: new Date(createdAt),
+ duration: durationMs >= 0 ? durationMs : undefined,
+ status: Execution.normalizeAsyncState(state),
sqlQuery,
queryContext,
stages: Array.isArray(stages) && counters ? new Stages(stages, counters) : undefined,
@@ -335,7 +336,7 @@ export class Execution {
status: Execution.normalizeTaskStatus(status),
segmentStatus: segmentLoaderStatus,
startTime: isNaN(startTime.getTime()) ? undefined : startTime,
- duration: typeof durationMs === 'number' ? durationMs : undefined,
+ duration: typeof durationMs === 'number' && durationMs >= 0 ? durationMs : undefined,
usageInfo: getUsageInfoFromStatusPayload(
deepGet(taskReport, 'multiStageQuery.payload.status'),
),
@@ -441,6 +442,13 @@ export class Execution {
};
}
+ public changeEngine(engine: DruidEngine): Execution {
+ return new Execution({
+ ...this.valueOf(),
+ engine,
+ });
+ }
+
public changeSqlQuery(sqlQuery: string, queryContext?: QueryContext): Execution {
const value = this.valueOf();
diff --git a/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx b/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
index 46d6a3a4cbb..2071aa2c377 100644
--- a/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
+++ b/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
@@ -296,6 +296,9 @@ export type SchemaMode = 'fixed' | 'string-only-discovery' | 'type-aware-discove
export type ArrayMode = 'arrays' | 'multi-values';
export const DEFAULT_FORCE_SEGMENT_SORT_BY_TIME = true;
+export const DEFAULT_SCHEMA_MODE: SchemaMode = 'fixed';
+export const DEFAULT_ARRAY_MODE: ArrayMode = 'arrays';
+
export function getForceSegmentSortByTime(spec: Partial<IngestionSpec>): boolean {
return (
deepGet(spec, 'spec.dataSchema.dimensionsSpec.forceSegmentSortByTime') ??
diff --git a/web-console/src/druid-models/query-context/query-context.tsx b/web-console/src/druid-models/query-context/query-context.tsx
index ee467577510..b702c233457 100644
--- a/web-console/src/druid-models/query-context/query-context.tsx
+++ b/web-console/src/druid-models/query-context/query-context.tsx
@@ -42,6 +42,7 @@ export interface QueryContext {
waitUntilSegmentsLoad?: boolean;
useConcurrentLocks?: boolean;
forceSegmentSortByTime?: boolean;
+ includeAllCounters?: boolean;
[key: string]: any;
}
@@ -65,6 +66,7 @@ export const DEFAULT_SERVER_QUERY_CONTEXT: QueryContext = {
waitUntilSegmentsLoad: false,
useConcurrentLocks: false,
forceSegmentSortByTime: true,
+ includeAllCounters: false,
};
export interface QueryWithContext {
diff --git a/web-console/src/druid-models/stages/graph-info.ts b/web-console/src/druid-models/stages/graph-info.ts
new file mode 100644
index 00000000000..98f41d709c6
--- /dev/null
+++ b/web-console/src/druid-models/stages/graph-info.ts
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { filterMap } from '../../utils';
+
+import type { StageDefinition } from './stages';
+
+export type GraphInfoLane =
+ | { type: 'stage'; stageNumber: number; fromLanes: number[]; hasOut: boolean }
+ | { type: 'line'; stageNumber: number; fromLane: number };
+
+export type GraphInfo = GraphInfoLane[];
+
+export function computeNextInfo(
+ stage: StageDefinition,
+ prevInfo: GraphInfo,
+ isLastStage: boolean,
+): GraphInfo {
+ const inputStageNumbers = filterMap(stage.definition.input, i =>
+ i.type === 'stage' ? i.stage : undefined,
+ );
+
+ const myInfo: GraphInfo = [];
+ let addedSelf = false;
+ for (let i = 0; i < prevInfo.length; i++) {
+ const prevLane = prevInfo[i];
+ if (addedSelf) {
+ if (inputStageNumbers.includes(prevLane.stageNumber)) {
+ // Do nothing because this was already recorded in the 'stage' lane
+ } else {
+ myInfo.push({ type: 'line', stageNumber: prevLane.stageNumber, fromLane: i });
+ }
+ } else {
+ if (inputStageNumbers.includes(prevLane.stageNumber)) {
+ // Add self
+ myInfo.push({
+ type: 'stage',
+ stageNumber: stage.stageNumber,
+ fromLanes: filterMap(prevInfo, (lane, i) =>
+ inputStageNumbers.includes(lane.stageNumber) ? i : undefined,
+ ),
+ hasOut: !isLastStage,
+ });
+ addedSelf = true;
+ } else {
+ myInfo.push({ type: 'line', stageNumber: prevLane.stageNumber, fromLane: i });
+ }
+ }
+ }
+
+ // If the stage was not already added then it must be a new source, start a new lane
+ if (!addedSelf) {
+ myInfo.push({
+ type: 'stage',
+ stageNumber: stage.stageNumber,
+ fromLanes: [], // We know the stage is a source
+ hasOut: !isLastStage,
+ });
+ }
+
+ return myInfo;
+}
diff --git a/web-console/src/druid-models/stages/stages.spec.ts b/web-console/src/druid-models/stages/stages.spec.ts
index ad8f2e77408..2c0a6f5ec18 100644
--- a/web-console/src/druid-models/stages/stages.spec.ts
+++ b/web-console/src/druid-models/stages/stages.spec.ts
@@ -16,8 +16,53 @@
* limitations under the License.
*/
+import { aggregateSortProgressCounters } from './stages';
import { STAGES } from './stages.mock';
+describe('aggregateSortProgressCounters', () => {
+ it('works', () => {
+ expect(
+ aggregateSortProgressCounters([
+ {
+ type: 'sortProgress',
+ totalMergingLevels: 2,
+ levelToTotalBatches: { 0: 3, 1: 4, 2: 4 },
+ levelToMergedBatches: { 0: 3, 1: 4, 2: 4 },
+ totalMergersForUltimateLevel: 1,
+ },
+ {
+ type: 'sortProgress',
+ totalMergingLevels: 4,
+ levelToTotalBatches: { 0: 2, 1: 4, 2: 6, 3: 5 },
+ levelToMergedBatches: { 0: 2, 1: 4, 2: 6, 3: 5 },
+ totalMergersForUltimateLevel: 1,
+ },
+ ]),
+ ).toEqual({
+ totalMergingLevels: {
+ '2': 1,
+ '4': 1,
+ },
+ levelToBatches: {
+ '0': {
+ '2': 1,
+ '3': 1,
+ },
+ '1': {
+ '4': 2,
+ },
+ '2': {
+ '4': 1,
+ '6': 1,
+ },
+ '3': {
+ '5': 1,
+ },
+ },
+ });
+ });
+});
+
describe('Stages', () => {
describe('#overallProgress', () => {
it('works when finished', () => {
@@ -290,4 +335,51 @@ describe('Stages', () => {
`);
});
});
+
+ describe('#getGraphInfos', () => {
+ it('works for mock', () => {
+ expect(STAGES.getGraphInfos()).toMatchInlineSnapshot(`
+ [
+ [
+ {
+ "fromLanes": [],
+ "hasOut": true,
+ "stageNumber": 0,
+ "type": "stage",
+ },
+ ],
+ [
+ {
+ "fromLanes": [
+ 0,
+ ],
+ "hasOut": true,
+ "stageNumber": 1,
+ "type": "stage",
+ },
+ ],
+ [
+ {
+ "fromLanes": [
+ 0,
+ ],
+ "hasOut": true,
+ "stageNumber": 2,
+ "type": "stage",
+ },
+ ],
+ [
+ {
+ "fromLanes": [
+ 0,
+ ],
+ "hasOut": false,
+ "stageNumber": 3,
+ "type": "stage",
+ },
+ ],
+ ]
+ `);
+ });
+ });
});
diff --git a/web-console/src/druid-models/stages/stages.ts b/web-console/src/druid-models/stages/stages.ts
index 0d263a40553..fbb2c1cd3d4 100644
--- a/web-console/src/druid-models/stages/stages.ts
+++ b/web-console/src/druid-models/stages/stages.ts
@@ -18,15 +18,48 @@
import { max, sum } from 'd3-array';
-import { deleteKeys, filterMap, oneOf, zeroDivide } from '../../utils';
+import { countBy, deleteKeys, filterMap, groupByAsMap, oneOf, zeroDivide } from '../../utils';
import type { InputFormat } from '../input-format/input-format';
import type { InputSource } from '../input-source/input-source';
+import type { GraphInfo } from './graph-info';
+import { computeNextInfo } from './graph-info';
+
const SHUFFLE_WEIGHT = 0.5;
const READING_INPUT_WITH_SHUFFLE_WEIGHT = 1 - SHUFFLE_WEIGHT;
export type InOut = 'in' | 'out';
+function simpleSum(xs: number[]) {
+ return sum(xs);
+}
+function aggregateThings<T>(
+ things: T[],
+ aggregators: {
+ [Property in keyof T]: T[Property] | ((vs: T[Property][], ts: T[]) => T[Property]);
+ },
+): T {
+ return Object.fromEntries(
+ Object.entries(aggregators).map(([k, f]) => [
+ k,
+ typeof f === 'function'
+ ? f(
+ filterMap(things, t => (t as any)[k]),
+ things,
+ )
+ : f,
+ ]),
+ ) as any;
+}
+
+function filterToExistingKeys<T>(keys: (keyof T)[], ts: T[]): (keyof T)[] {
+ return keys.filter(k => ts.some(t => Object.hasOwn(t as any, k)));
+}
+
+function objectFromKeys<T>(keys: string[], value: T): Record<string, T> {
+ return Object.fromEntries(keys.map(k => [k, value]));
+}
+
export type StageInput =
| {
type: 'stage';
@@ -63,6 +96,7 @@ export interface StageDefinition {
targetSize?: number;
partitions?: number;
aggregate?: boolean;
+ limitHint?: number;
};
maxWorkerCount: number;
shuffleCheckHasMultipleValues?: boolean;
@@ -86,26 +120,25 @@ export interface ClusterBy {
bucketByCount?: number;
}
-export function formatClusterBy(
- clusterBy: ClusterBy | undefined,
- part: 'all' | 'partition' | 'cluster' = 'all',
-): string {
- if (!clusterBy) return '';
-
- let { columns, bucketByCount } = clusterBy;
- if (bucketByCount) {
- if (part === 'partition') {
- columns = columns.slice(0, bucketByCount);
- } else {
- columns = columns.slice(bucketByCount);
- }
- }
-
+function formatClusterByColumns(columns: ClusterBy['columns']): string {
return columns
.map(part => part.columnName + (part.order === 'DESCENDING' ? ' DESC' : ''))
.join(', ');
}
+export function formatClusterBy(clusterBy: ClusterBy): string {
+ const { columns, bucketByCount } = clusterBy;
+
+ if (clusterBy.bucketByCount) {
+ return [
+ `Partition by: ${formatClusterByColumns(columns.slice(0, bucketByCount))}`,
+ `Cluster by: ${formatClusterByColumns(columns.slice(bucketByCount))}`,
+ ].join('\n');
+ } else {
+ return `Cluster by: ${formatClusterByColumns(columns)}`;
+ }
+}
+
export interface StageWorkerCounter {
[k: `input${number}`]: ChannelCounter | undefined;
output?: ChannelCounter;
@@ -113,6 +146,7 @@ export interface StageWorkerCounter {
sortProgress?: SortProgressCounter;
segmentGenerationProgress?: SegmentGenerationProgressCounter;
warnings?: WarningCounter;
+ cpu?: CpusCounter;
}
export type ChannelCounterName = `input${number}` | 'output' | 'shuffle';
@@ -156,6 +190,29 @@ export interface SortProgressCounter {
triviallyComplete?: boolean;
}
+export interface AggregatedSortProgress {
+ totalMergingLevels: Record<string, number>;
+ levelToBatches: Record<string, Record<string, number>>;
+}
+
+export function aggregateSortProgressCounters(
+ sortProgressCounters: SortProgressCounter[],
+): AggregatedSortProgress {
+ return {
+ totalMergingLevels: countBy(sortProgressCounters, c => c.totalMergingLevels),
+ levelToBatches: groupByAsMap(
+ sortProgressCounters.flatMap(c =>
+ Object.entries(c.levelToMergedBatches).map(([level, merged]) => [
+ level,
+ Math.max(merged, c.levelToTotalBatches[level as any] || 0),
+ ]),
+ ),
+ ([level]) => level,
+ entities => countBy(entities, x => x[1]),
+ ),
+ };
+}
+
export interface SegmentGenerationProgressCounter {
type: 'segmentGenerationProgress';
rowsProcessed: number;
@@ -176,12 +233,79 @@ export interface WarningCounter {
// More types of warnings might be added later
}
+export type CpusCounterFields =
+ | 'main'
+ | 'collectKeyStatistics'
+ | 'mergeInput'
+ | 'hashPartitionOutput'
+ | 'mixOutput'
+ | 'sortOutput';
+
+export const CPUS_COUNTER_FIELDS: CpusCounterFields[] = [
+ 'main',
+ 'collectKeyStatistics',
+ 'mergeInput',
+ 'hashPartitionOutput',
+ 'mixOutput',
+ 'sortOutput',
+];
+
+export function cpusCounterFieldTitle(k: CpusCounterFields) {
+ switch (k) {
+ case 'main':
+ return 'Main';
+
+ case 'collectKeyStatistics':
+ return 'Collect key stats';
+
+ case 'mergeInput':
+ return 'Merge input';
+
+ case 'hashPartitionOutput':
+ return 'Hash partition out';
+
+ case 'mixOutput':
+ return 'Mix output';
+
+ case 'sortOutput':
+ return 'Sort output';
+
+ default:
+ return k;
+ }
+}
+
+export interface CpusCounter {
+ type: 'cpus';
+ main?: CpuCounter;
+ collectKeyStatistics?: CpuCounter;
+ mergeInput?: CpuCounter;
+ hashPartitionOutput?: CpuCounter;
+ mixOutput?: CpuCounter;
+ sortOutput?: CpuCounter;
+}
+
+export interface CpuCounter {
+ type: 'cpu';
+ cpu: number;
+ wall: number;
+}
+
+function sumCpuCounters(cs: CpuCounter[]): CpuCounter {
+ return aggregateThings(cs, {
+ type: 'cpu',
+ cpu: simpleSum,
+ wall: simpleSum,
+ });
+}
+
export interface SimpleWideCounter {
index: number;
[k: `input${number}`]: Record<ChannelFields, number> | undefined;
output?: Record<ChannelFields, number>;
shuffle?: Record<ChannelFields, number>;
segmentGenerationProgress?: SegmentGenerationProgressCounter;
+ cpu?: CpusCounter;
}
function zeroChannelFields(): Record<ChannelFields, number> {
@@ -347,6 +471,14 @@ export class Stages {
return this.stages.findIndex(({ phase }) => oneOf(phase, 'READING_INPUT', 'POST_READING'));
}
+ hasCounter(counterName: CounterName): boolean {
+ const { counters } = this;
+ if (!counters) return false;
+ return Object.values(counters).some(counter =>
+ Object.values(counter).some(c => Boolean(c[counterName])),
+ );
+ }
+
hasCounterForStage(stage: StageDefinition, counterName: CounterName): boolean {
return this.getCountersForStage(stage).some(c => Boolean(c[counterName]));
}
@@ -395,6 +527,14 @@ export class Stages {
);
}
+ getCpuTotalsForStage(stage: StageDefinition): CpusCounter {
+ const cpusCounters = filterMap(this.getCountersForStage(stage), c => c.cpu);
+ return aggregateThings(cpusCounters, {
+ type: 'cpus',
+ ...objectFromKeys(filterToExistingKeys(CPUS_COUNTER_FIELDS, cpusCounters), sumCpuCounters),
+ });
+ }
+
getTotalCounterForStage(
stage: StageDefinition,
counterName: CounterName,
@@ -478,6 +618,7 @@ export class Stages {
: zeroChannelFields();
}
newWideCounter.segmentGenerationProgress = stageCounters.segmentGenerationProgress;
+ newWideCounter.cpu = stageCounters.cpu;
return newWideCounter;
});
}
@@ -549,6 +690,12 @@ export class Stages {
return simpleCounters;
}
+ getAggregatedSortProgressForStage(stage: StageDefinition): AggregatedSortProgress | undefined {
+ const sortProgressCounters = filterMap(this.getCountersForStage(stage), c => c.sortProgress);
+ if (!sortProgressCounters.length) return;
+ return aggregateSortProgressCounters(sortProgressCounters);
+ }
+
getRateFromStage(stage: StageDefinition, field: ChannelFields): number | undefined {
if (!stage.duration) return;
if (field === 'bytes' && stage.definition.input.some(input => input.type !== 'stage')) {
@@ -578,4 +725,19 @@ export class Stages {
return potentiallyStuckIndex;
}
+
+ getGraphInfos(): GraphInfo[] {
+ const { stages } = this;
+
+ let prevInfo: GraphInfo = [];
+ const ret: GraphInfo[] = [];
+
+ for (const stage of stages) {
+ ret.push(
+ (prevInfo = computeNextInfo(stage, prevInfo, stage.stageNumber === stages.length - 1)),
+ );
+ }
+
+ return ret;
+ }
}
diff --git a/web-console/src/druid-models/task/task.ts b/web-console/src/druid-models/task/task.ts
index 1a245508e8d..3445cc5fb45 100644
--- a/web-console/src/druid-models/task/task.ts
+++ b/web-console/src/druid-models/task/task.ts
@@ -113,6 +113,12 @@ export interface MsqTaskReportResponse {
};
stages: StageDefinition[];
counters: Counters;
+ results?: {
+ signature: { name: string; type: string }[];
+ sqlTypeNames: string[];
+ results: any[];
+ resultsTruncated?: boolean;
+ };
};
};
error?: any;
diff --git a/web-console/src/druid-models/workbench-query/workbench-query.ts b/web-console/src/druid-models/workbench-query/workbench-query.ts
index 76bd29a4448..dd75c94b75e 100644
--- a/web-console/src/druid-models/workbench-query/workbench-query.ts
+++ b/web-console/src/druid-models/workbench-query/workbench-query.ts
@@ -153,7 +153,7 @@ export class WorkbenchQuery {
static fromTaskQueryAndContext(queryString: string, context: QueryContext): WorkbenchQuery {
const noSqlOuterLimit = typeof context['sqlOuterLimit'] === 'undefined';
- const cleanContext = deleteKeys(context, ['sqlOuterLimit']);
+ const cleanContext = deleteKeys(context, ['sqlOuterLimit', '__resultFormat']);
let retQuery = WorkbenchQuery.blank()
.changeEngine('sql-msq-task')
diff --git a/web-console/src/entry.scss b/web-console/src/entry.scss
index c980decba1e..73cbb238eb1 100644
--- a/web-console/src/entry.scss
+++ b/web-console/src/entry.scss
@@ -36,6 +36,7 @@ body {
overflow: hidden;
font-size: 13px;
overscroll-behavior-x: none;
+ cursor: default;
}
body {
@@ -74,3 +75,10 @@ body {
max-height: 47vh;
overflow: auto;
}
+
+// Make buttons stretch nicely
+.#{$bp-ns}-button.#{$bp-ns}-fill {
+ .#{$bp-ns}-button-text {
+ flex: 1;
+ }
+}
diff --git a/web-console/src/entry.tsx b/web-console/src/entry.tsx
index 0e698a3f84b..dc1d2f56da3 100644
--- a/web-console/src/entry.tsx
+++ b/web-console/src/entry.tsx
@@ -32,7 +32,7 @@ import type { QueryContext } from './druid-models';
import type { Links } from './links';
import { setLinkOverrides } from './links';
import { Api, UrlBaser } from './singletons';
-import { setLocalStorageNamespace } from './utils';
+import { initMouseTooltip, setLocalStorageNamespace } from './utils';
import './entry.scss';
@@ -118,6 +118,8 @@ createRoot(container).render(
</OverlaysProvider>,
);
+initMouseTooltip();
+
// ---------------------------------
// Taken from https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2
diff --git a/web-console/src/helpers/capabilities.ts b/web-console/src/helpers/capabilities.ts
index 4b10a49c965..fe125b67231 100644
--- a/web-console/src/helpers/capabilities.ts
+++ b/web-console/src/helpers/capabilities.ts
@@ -16,6 +16,7 @@
* limitations under the License.
*/
+import type { DruidEngine } from '../druid-models';
import { Api } from '../singletons';
import { maybeGetClusterCapacity } from './index';
@@ -35,7 +36,7 @@ export type QueryType = 'none' | 'nativeOnly' | 'nativeAndSql';
export interface CapabilitiesValue {
queryType: QueryType;
- multiStageQuery: boolean;
+ multiStageQueryTask: boolean;
coordinator: boolean;
overlord: boolean;
maxTaskSlots?: number;
@@ -51,7 +52,7 @@ export class Capabilities {
static NO_PROXY: Capabilities;
private readonly queryType: QueryType;
- private readonly multiStageQuery: boolean;
+ private readonly multiStageQueryTask: boolean;
private readonly coordinator: boolean;
private readonly overlord: boolean;
private readonly maxTaskSlots?: number;
@@ -129,7 +130,7 @@ export class Capabilities {
return true;
}
- static async detectMultiStageQuery(): Promise<boolean> {
+ static async detectMultiStageQueryTask(): Promise<boolean> {
try {
const resp = await Api.instance.get(`/druid/v2/sql/task/enabled?capabilities`);
return Boolean(resp.data.enabled);
@@ -153,11 +154,11 @@ export class Capabilities {
coordinator = overlord = await Capabilities.detectManagementProxy();
}
- const multiStageQuery = await Capabilities.detectMultiStageQuery();
+ const multiStageQueryTask = await Capabilities.detectMultiStageQueryTask();
return new Capabilities({
queryType,
- multiStageQuery,
+ multiStageQueryTask,
coordinator,
overlord,
});
@@ -177,7 +178,7 @@ export class Capabilities {
constructor(value: CapabilitiesValue) {
this.queryType = value.queryType;
- this.multiStageQuery = value.multiStageQuery;
+ this.multiStageQueryTask = value.multiStageQueryTask;
this.coordinator = value.coordinator;
this.overlord = value.overlord;
this.maxTaskSlots = value.maxTaskSlots;
@@ -186,7 +187,7 @@ export class Capabilities {
public valueOf(): CapabilitiesValue {
return {
queryType: this.queryType,
- multiStageQuery: this.multiStageQuery,
+ multiStageQueryTask: this.multiStageQueryTask,
coordinator: this.coordinator,
overlord: this.overlord,
maxTaskSlots: this.maxTaskSlots,
@@ -243,8 +244,19 @@ export class Capabilities {
return this.queryType === 'nativeAndSql';
}
- public hasMultiStageQuery(): boolean {
- return this.multiStageQuery;
+ public hasMultiStageQueryTask(): boolean {
+ return this.multiStageQueryTask;
+ }
+
+ public getSupportedQueryEngines(): DruidEngine[] {
+ const queryEngines: DruidEngine[] = ['native'];
+ if (this.hasSql()) {
+ queryEngines.push('sql-native');
+ }
+ if (this.hasMultiStageQueryTask()) {
+ queryEngines.push('sql-msq-task');
+ }
+ return queryEngines;
}
public hasCoordinatorAccess(): boolean {
@@ -269,37 +281,37 @@ export class Capabilities {
}
Capabilities.FULL = new Capabilities({
queryType: 'nativeAndSql',
- multiStageQuery: true,
+ multiStageQueryTask: true,
coordinator: true,
overlord: true,
});
Capabilities.NO_SQL = new Capabilities({
queryType: 'nativeOnly',
- multiStageQuery: false,
+ multiStageQueryTask: false,
coordinator: true,
overlord: true,
});
Capabilities.COORDINATOR_OVERLORD = new Capabilities({
queryType: 'none',
- multiStageQuery: false,
+ multiStageQueryTask: false,
coordinator: true,
overlord: true,
});
Capabilities.COORDINATOR = new Capabilities({
queryType: 'none',
- multiStageQuery: false,
+ multiStageQueryTask: false,
coordinator: true,
overlord: false,
});
Capabilities.OVERLORD = new Capabilities({
queryType: 'none',
- multiStageQuery: false,
+ multiStageQueryTask: false,
coordinator: false,
overlord: true,
});
Capabilities.NO_PROXY = new Capabilities({
queryType: 'nativeAndSql',
- multiStageQuery: true,
+ multiStageQueryTask: true,
coordinator: false,
overlord: false,
});
diff --git a/web-console/src/hooks/index.ts b/web-console/src/hooks/index.ts
index 62b22443f3b..515fbe0db36 100644
--- a/web-console/src/hooks/index.ts
+++ b/web-console/src/hooks/index.ts
@@ -19,6 +19,7 @@
export * from './use-clock';
export * from './use-constant';
export * from './use-global-event-listener';
+export * from './use-hash-and-local-storage-hybrid-state';
export * from './use-interval';
export * from './use-last-defined';
export * from './use-local-storage-state';
diff --git a/web-console/src/hooks/use-hash-and-local-storage-hybrid-state.ts b/web-console/src/hooks/use-hash-and-local-storage-hybrid-state.ts
new file mode 100644
index 00000000000..10911c7798b
--- /dev/null
+++ b/web-console/src/hooks/use-hash-and-local-storage-hybrid-state.ts
@@ -0,0 +1,83 @@
+/*
+ * 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 * as JSONBig from 'json-bigint-native';
+import type { Dispatch, SetStateAction } from 'react';
+import { useEffect, useState } from 'react';
+
+import type { LocalStorageKeys } from '../utils';
+import {
+ base64UrlDecode,
+ base64UrlEncode,
+ localStorageGetJson,
+ localStorageSetJson,
+} from '../utils';
+
+function encodeHashState(prefix: string, x: unknown): string {
+ return prefix + base64UrlEncode(JSONBig.stringify(x));
+}
+
+function decodeHashState(prefix: string, x: string): any {
+ if (!x.startsWith(prefix)) return;
+ try {
+ return JSONBig.parse(base64UrlDecode(x.slice(prefix.length)));
+ } catch {
+ return;
+ }
+}
+
+export function useHashAndLocalStorageHybridState<T>(
+ prefix: string,
+ key: LocalStorageKeys,
+ initialValue?: T,
+ inflateFn?: (x: any) => T,
+): [T, Dispatch<SetStateAction<T>>] {
+ const [state, setState] = useState(() => {
+ // Try to read state from hash and fallback to local storage
+ const valueToInflate =
+ decodeHashState(prefix, window.location.hash) ?? localStorageGetJson(key);
+ if (typeof valueToInflate === 'undefined') return initialValue;
+ return inflateFn ? inflateFn(valueToInflate) : valueToInflate;
+ });
+
+ const setValue: Dispatch<SetStateAction<T>> = (value: T | ((prevState: T) => T)) => {
+ const valueToStore = value instanceof Function ? value(state) : value;
+ window.history.pushState(null, '', encodeHashState(prefix, value));
+ setState(valueToStore);
+ localStorageSetJson(key, valueToStore);
+ };
+
+ // Listen for "popstate" event (triggered by browser back/forward navigation)
+ useEffect(() => {
+ const handlePopState = () => {
+ const valueToInflate = decodeHashState(prefix, window.location.hash);
+ if (typeof valueToInflate === 'undefined') return;
+ const value = inflateFn ? inflateFn(valueToInflate) : valueToInflate;
+ setState(value);
+ localStorageSetJson(key, value);
+ };
+
+ window.addEventListener('popstate', handlePopState);
+
+ return () => {
+ window.removeEventListener('popstate', handlePopState);
+ };
+ }, []);
+
+ return [state, setValue];
+}
diff --git a/web-console/src/hooks/use-query-manager.ts b/web-console/src/hooks/use-query-manager.ts
index 38444374583..36bf83f6ecd 100644
--- a/web-console/src/hooks/use-query-manager.ts
+++ b/web-console/src/hooks/use-query-manager.ts
@@ -16,7 +16,7 @@
* limitations under the License.
*/
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import type { QueryManagerOptions } from '../utils';
import { QueryManager, QueryState } from '../utils';
@@ -39,7 +39,8 @@ export function useQueryManager<Q, R, I = never, E extends Error = Error>(
backgroundStatusCheck || ((() => {}) as any),
);
- const [resultState, setResultState] = useState<QueryState<R, E, I>>(initState || QueryState.INIT);
+ const resultStateRef = useRef<QueryState<R, E, I>>(initState || QueryState.INIT);
+ const [_, setResultState] = useState<QueryState<R, E, I>>(initState || QueryState.INIT);
function makeQueryManager() {
return new QueryManager<Q, R, I, E>({
@@ -47,7 +48,10 @@ export function useQueryManager<Q, R, I = never, E extends Error = Error>(
initState,
processQuery: concreteProcessQuery,
backgroundStatusCheck: backgroundStatusCheck ? concreteBackgroundStatusCheck : undefined,
- onStateChange: setResultState,
+ onStateChange: s => {
+ resultStateRef.current = s;
+ setResultState(s);
+ },
});
}
@@ -75,12 +79,11 @@ export function useQueryManager<Q, R, I = never, E extends Error = Error>(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- useEffect(() => {
- if (typeof query !== 'undefined') {
- queryManager.runQuery(query);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [query]);
+ const prevQuery = useRef<Q | undefined>(initQuery);
+ if (typeof query !== 'undefined' && query !== prevQuery.current) {
+ prevQuery.current = query;
+ queryManager.runQuery(query);
+ }
- return [resultState, queryManager];
+ return [resultStateRef.current, queryManager];
}
diff --git a/web-console/src/singletons/workbench-running-promises.ts b/web-console/src/singletons/workbench-running-promises.ts
index 996070537d3..2e4e34acf81 100644
--- a/web-console/src/singletons/workbench-running-promises.ts
+++ b/web-console/src/singletons/workbench-running-promises.ts
@@ -16,11 +16,10 @@
* limitations under the License.
*/
-import type { QueryResult } from '@druid-toolkit/query';
+import type { Execution } from '../druid-models';
export interface WorkbenchRunningPromise {
- promise: Promise<QueryResult>;
- prefixLines: number;
+ executionPromise: Promise<Execution>;
startTime: Date;
}
@@ -28,7 +27,7 @@ export class WorkbenchRunningPromises {
private static readonly promises = new Map<string, WorkbenchRunningPromise>();
static isWorkbenchRunningPromise(x: any): x is WorkbenchRunningPromise {
- return Boolean(x.promise);
+ return Boolean(x.executionPromise);
}
static storePromise(id: string, promise: WorkbenchRunningPromise): void {
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/utils/base64-url.spec.ts
similarity index 55%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/utils/base64-url.spec.ts
index dbb850df020..e509779f4ae 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
+++ b/web-console/src/utils/base64-url.spec.ts
@@ -16,29 +16,24 @@
* limitations under the License.
*/
-@import '../../../../variables';
+import { base64UrlDecode, base64UrlEncode } from './base64-url';
-.filter-menu {
- width: 400px;
+describe('base64-url', () => {
+ it('works in simple case', () => {
+ const originalString = 'Hello, World!';
+ const encoded = base64UrlEncode(originalString);
+ expect(encoded).toEqual('SGVsbG8sIFdvcmxkIQ');
- &.main {
- padding: 15px;
- }
+ const decoded = base64UrlDecode(encoded);
+ expect(decoded).toEqual(originalString);
+ });
- .controls .#{$ns}-form-content {
- display: flex;
- gap: 15px;
-
- .type-selector {
- flex: 1;
+ it('works for all ascii chars', () => {
+ for (let c = 0; c < 256; c++) {
+ const originalString = String.fromCharCode(c);
+ const encoded = base64UrlEncode(originalString);
+ const decoded = base64UrlDecode(encoded);
+ expect(decoded).toEqual(originalString);
}
- }
-
- textarea {
- width: 100%;
- }
-
- .button-bar {
- text-align: right;
- }
-}
+ });
+});
diff --git a/web-console/src/views/explore-view/source-pane/source-pane.scss b/web-console/src/utils/base64-url.ts
similarity index 57%
rename from web-console/src/views/explore-view/source-pane/source-pane.scss
rename to web-console/src/utils/base64-url.ts
index 741f16562fb..d93b9ff4897 100644
--- a/web-console/src/views/explore-view/source-pane/source-pane.scss
+++ b/web-console/src/utils/base64-url.ts
@@ -16,15 +16,19 @@
* limitations under the License.
*/
-@import '../../../variables';
-
-.source-pane {
- .#{$ns}-button-text {
- flex: 1 1 auto;
- }
+export function base64UrlEncode(input: string): string {
+ const base64 = btoa(input); // Encode to base64
+ return base64
+ .replace(/\+/g, '-') // Replace '+' with '-'
+ .replace(/\//g, '_') // Replace '/' with '_'
+ .replace(/=+$/, ''); // Remove any trailing '='
}
-.source-menu {
- max-height: 80vh;
- overflow: auto;
+export function base64UrlDecode(input: string): string {
+ const base64 = input
+ .replace(/-/g, '+') // Replace '-' with '+'
+ .replace(/_/g, '/'); // Replace '_' with '/'
+
+ // Add padding '=' if necessary
+ return atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=')); // Decode from base64
}
diff --git a/web-console/src/utils/basic-action.tsx b/web-console/src/utils/basic-action.tsx
index 5194d26fa1b..f98b5b66d7d 100644
--- a/web-console/src/utils/basic-action.tsx
+++ b/web-console/src/utils/basic-action.tsx
@@ -45,7 +45,7 @@ export function basicActionsToMenu(
intent={intent}
onClick={onAction}
disabled={Boolean(disabledReason)}
- title={disabledReason}
+ data-super-title={disabledReason}
/>
))}
</Menu>
diff --git a/web-console/src/utils/date.ts b/web-console/src/utils/date.ts
index b6ace7d6dda..1d5aa4f61e9 100644
--- a/web-console/src/utils/date.ts
+++ b/web-console/src/utils/date.ts
@@ -27,9 +27,35 @@ export function isNonNullRange(range: DateRange): range is NonNullDateRange {
export function dateToIsoDateString(date: Date): string {
return date.toISOString().slice(0, 10);
}
+export function prettyFormatIsoDateWithMsIfNeeded(isoDate: string | Date): string {
+ return (typeof isoDate === 'string' ? isoDate : isoDate.toISOString())
+ .replace('T', ' ')
+ .replace('Z', '')
+ .replace('.000', '');
+}
+
+export function prettyFormatIsoDate(isoDate: string | Date): string {
+ return prettyFormatIsoDateWithMsIfNeeded(isoDate).replace(/\.\d\d\d/, '');
+}
+
+export function prettyFormatIsoDateTick(date: Date): string {
+ // s like 2016-06-27T19:00:00.000Z
+ let s = date.toISOString();
+ if (!s.endsWith('.000Z')) {
+ return s.slice(19, 23); // => ".001"
+ }
+ s = s.slice(0, 19); // s like 2016-06-27T19:00:00
+
+ if (!s.endsWith(':00')) {
+ return s.slice(11); // => 00:00:01
+ }
+ s = s.slice(0, 16); // s like 2016-06-27T19:00
+
+ if (!s.endsWith('T00:00')) {
+ return s.slice(11); // => 00:00
+ }
-export function prettyFormatIsoDate(isoDate: string): string {
- return isoDate.replace('T', ' ').replace(/\.\d\d\dZ$/, '');
+ return s.slice(0, 10); // s like 2016-06-27
}
export function utcToLocalDate(utcDate: Date): Date {
diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx
index 765f7e30f64..c6885ca4337 100644
--- a/web-console/src/utils/general.tsx
+++ b/web-console/src/utils/general.tsx
@@ -148,7 +148,7 @@ export function change<T>(xs: readonly T[], from: T, to: T): T[] {
export function countBy<T>(
array: readonly T[],
- fn: (x: T, index: number) => string = String,
+ fn: (x: T, index: number) => string | number = String,
): Record<string, number> {
const counts: Record<string, number> = {};
for (let i = 0; i < array.length; i++) {
@@ -184,11 +184,30 @@ export function mapRecord<T, Q>(
const newRecord: Record<string, Q> = {};
const keys = Object.keys(record);
for (const key of keys) {
- newRecord[key] = fn(record[key], key);
+ const mapped = fn(record[key], key);
+ if (typeof mapped === 'undefined') continue;
+ newRecord[key] = mapped;
}
return newRecord;
}
+export function mapRecordIfChanged<T>(
+ record: Record<string, T>,
+ fn: (value: T, key: string) => T,
+): Record<string, T> {
+ const newRecord: Record<string, T> = {};
+ let changed = false;
+ const keys = Object.keys(record);
+ for (const key of keys) {
+ const v = record[key];
+ const mapped = fn(v, key);
+ if (v !== mapped) changed = true;
+ if (typeof mapped === 'undefined') continue;
+ newRecord[key] = mapped;
+ }
+ return changed ? newRecord : record;
+}
+
export function groupBy<T, Q>(
array: readonly T[],
keyFn: (x: T, index: number) => string,
@@ -207,7 +226,7 @@ export function groupBy<T, Q>(
export function groupByAsMap<T, Q>(
array: readonly T[],
- keyFn: (x: T, index: number) => string,
+ keyFn: (x: T, index: number) => string | number,
aggregateFn: (xs: readonly T[], key: string) => Q,
): Record<string, Q> {
const buckets: Record<string, T[]> = {};
@@ -235,6 +254,12 @@ export function uniq(array: readonly string[]): string[] {
// ----------------------------
+export function formatEmpty(str: string): string {
+ return str === '' ? 'empty' : str;
+}
+
+// ----------------------------
+
export function formatInteger(n: NumberLike): string {
return numeral(n).format('0,0');
}
@@ -293,6 +318,10 @@ export function forceSignInNumberFormatter(
};
}
+function sign(n: NumberLike): string {
+ return n < 0 ? '-' : '';
+}
+
function pad2(str: string | number): string {
return ('00' + str).slice(-2);
}
@@ -302,35 +331,46 @@ function pad3(str: string | number): string {
}
export function formatDuration(ms: NumberLike): string {
- const n = Number(ms);
+ const n = Math.abs(Number(ms));
const timeInHours = Math.floor(n / 3600000);
const timeInMin = Math.floor(n / 60000) % 60;
const timeInSec = Math.floor(n / 1000) % 60;
- return timeInHours + ':' + pad2(timeInMin) + ':' + pad2(timeInSec);
+ return sign(ms) + timeInHours + ':' + pad2(timeInMin) + ':' + pad2(timeInSec);
}
export function formatDurationWithMs(ms: NumberLike): string {
- const n = Number(ms);
+ const n = Math.abs(Number(ms));
const timeInHours = Math.floor(n / 3600000);
const timeInMin = Math.floor(n / 60000) % 60;
const timeInSec = Math.floor(n / 1000) % 60;
return (
- timeInHours + ':' + pad2(timeInMin) + ':' + pad2(timeInSec) + '.' + pad3(Math.floor(n) % 1000)
+ sign(ms) +
+ timeInHours +
+ ':' +
+ pad2(timeInMin) +
+ ':' +
+ pad2(timeInSec) +
+ '.' +
+ pad3(Math.floor(n) % 1000)
);
}
+export function formatDurationWithMsIfNeeded(ms: NumberLike): string {
+ return Number(ms) < 1000 ? formatDurationWithMs(ms) : formatDuration(ms);
+}
+
export function formatDurationHybrid(ms: NumberLike): string {
- const n = Number(ms);
+ const n = Math.abs(Number(ms));
if (n < 600000) {
// anything that looks like 1:23.45 (max 9:59.99)
const timeInMin = Math.floor(n / 60000);
const timeInSec = Math.floor(n / 1000) % 60;
const timeInMs = Math.floor(n) % 1000;
- return `${timeInMin ? `${timeInMin}:` : ''}${timeInMin ? pad2(timeInSec) : timeInSec}.${pad3(
- timeInMs,
- ).slice(0, 2)}s`;
+ return `${sign(ms)}${timeInMin ? `${timeInMin}:` : ''}${
+ timeInMin ? pad2(timeInSec) : timeInSec
+ }.${pad3(timeInMs).slice(0, 2)}s`;
} else {
- return formatDuration(n);
+ return formatDuration(ms);
}
}
@@ -374,6 +414,16 @@ export function filterMap<T, Q>(xs: readonly T[], f: (x: T, i: number) => Q | un
return xs.map(f).filter((x: Q | undefined) => typeof x !== 'undefined') as Q[];
}
+export function filterMapIfChanged<T>(xs: T[], f: (x: T, i: number) => T | undefined): T[] {
+ let changed = false;
+ const newXs = filterMap(xs, (x, i) => {
+ const newX = f(x, i);
+ if (typeof newX === 'undefined' || x !== newX) changed = true;
+ return newX;
+ });
+ return changed ? newXs : xs;
+}
+
export function findMap<T, Q>(
xs: readonly T[],
f: (x: T, i: number) => Q | undefined,
diff --git a/web-console/src/utils/index.tsx b/web-console/src/utils/index.tsx
index d9b6d62a66e..edea5ad0a52 100644
--- a/web-console/src/utils/index.tsx
+++ b/web-console/src/utils/index.tsx
@@ -16,6 +16,7 @@
* limitations under the License.
*/
+export * from './base64-url';
export * from './column-metadata';
export * from './date';
export * from './download';
@@ -26,6 +27,7 @@ export * from './formatter';
export * from './general';
export * from './local-storage-backed-visibility';
export * from './local-storage-keys';
+export * from './mouse-tooltip/mouse-tooltip';
export * from './null-mode-detection';
export * from './object-change';
export * from './query-action';
diff --git a/web-console/src/utils/local-storage-keys.tsx b/web-console/src/utils/local-storage-keys.tsx
index 7c797d2c89a..d4efec06e22 100644
--- a/web-console/src/utils/local-storage-keys.tsx
+++ b/web-console/src/utils/local-storage-keys.tsx
@@ -56,8 +56,7 @@ export const LocalStorageKeys = {
SQL_DATA_LOADER_CONTENT: 'sql-data-loader-content' as const,
- EXPLORE_CONTENT: 'explore-content' as const,
- EXPLORE_ESSENCE: 'explore-essence' as const,
+ EXPLORE_STATE: 'explore-state' as const,
};
export type LocalStorageKeys = (typeof LocalStorageKeys)[keyof typeof LocalStorageKeys];
diff --git a/web-console/src/views/explore-view/control-pane/aggregate-menu.scss b/web-console/src/utils/mouse-tooltip/mouse-tooltip.scss
similarity index 75%
copy from web-console/src/views/explore-view/control-pane/aggregate-menu.scss
copy to web-console/src/utils/mouse-tooltip/mouse-tooltip.scss
index 754bd23858b..d2ebc813788 100644
--- a/web-console/src/views/explore-view/control-pane/aggregate-menu.scss
+++ b/web-console/src/utils/mouse-tooltip/mouse-tooltip.scss
@@ -16,13 +16,17 @@
* limitations under the License.
*/
-.aggregate-menu {
- .search-input {
- margin: 4px;
- }
+@import '../../variables';
- .inner-menu {
- height: 400px;
- overflow: auto;
- }
+.mouse-tooltip {
+ position: absolute;
+ background: $dark-gray1;
+ color: $light-gray5;
+ padding: 5px 8px;
+ border-radius: 4px;
+ pointer-events: none;
+ z-index: 1000;
+ font-size: 11px;
+ max-width: 75vw;
+ white-space: pre-wrap;
}
diff --git a/web-console/src/utils/mouse-tooltip/mouse-tooltip.ts b/web-console/src/utils/mouse-tooltip/mouse-tooltip.ts
new file mode 100644
index 00000000000..bbea9050890
--- /dev/null
+++ b/web-console/src/utils/mouse-tooltip/mouse-tooltip.ts
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { clamp } from '../general';
+
+import './mouse-tooltip.scss';
+
+const OFFSET_X = 0;
+const OFFSET_Y = 30;
+
+function findDataTooltip(element: HTMLElement): string | undefined {
+ let e: HTMLElement | null = element;
+ while (e) {
+ const t = e.getAttribute('data-tooltip');
+ if (t) return t;
+ e = e.parentElement;
+ }
+ return;
+}
+
+export function initMouseTooltip() {
+ let tooltipDiv: HTMLDivElement | undefined;
+
+ // Mouse move handler to follow the mouse
+ const handleMouseMove = (event: MouseEvent) => {
+ if (!tooltipDiv) return;
+
+ tooltipDiv.style.left = `${clamp(
+ event.pageX + OFFSET_X,
+ 0,
+ window.innerWidth - tooltipDiv.offsetWidth,
+ )}px`;
+ tooltipDiv.style.top = `${clamp(
+ event.pageY + OFFSET_Y,
+ 0,
+ window.innerHeight - tooltipDiv.offsetHeight,
+ )}px`;
+ };
+
+ // Mouse over handler to show the tooltip for elements with the appropriate attribute
+ const handleMouseOver = (event: MouseEvent) => {
+ const title = findDataTooltip(event.target as HTMLElement);
+ if (title) {
+ if (!tooltipDiv) {
+ tooltipDiv = document.createElement('div');
+ tooltipDiv.className = 'mouse-tooltip';
+ document.body.appendChild(tooltipDiv);
+ document.addEventListener('mousemove', handleMouseMove);
+ }
+ if (tooltipDiv.innerText !== title) {
+ tooltipDiv.innerText = title;
+ handleMouseMove(event); // Position the div as it might be the first positioning
+ }
+ } else if (tooltipDiv) {
+ document.removeEventListener('mousemove', handleMouseMove);
+ tooltipDiv.remove();
+ tooltipDiv = undefined;
+ }
+ };
+
+ document.addEventListener('mouseover', handleMouseOver);
+}
diff --git a/web-console/src/utils/table-helpers.ts b/web-console/src/utils/table-helpers.ts
index 006d56dff48..90df7fa1064 100644
--- a/web-console/src/utils/table-helpers.ts
+++ b/web-console/src/utils/table-helpers.ts
@@ -16,7 +16,7 @@
* limitations under the License.
*/
-import type { QueryResult } from '@druid-toolkit/query';
+import type { QueryResult, SqlExpression } from '@druid-toolkit/query';
import { C } from '@druid-toolkit/query';
import type { Filter } from 'react-table';
@@ -35,6 +35,7 @@ export function changePage(pagination: Pagination, page: number): Pagination {
export interface ColumnHint {
displayName?: string;
group?: string;
+ expressionForWhere?: SqlExpression;
formatter?: (x: any) => string;
}
diff --git a/web-console/src/utils/types.ts b/web-console/src/utils/types.ts
index 8ebbd6938b3..7f48655cb1c 100644
--- a/web-console/src/utils/types.ts
+++ b/web-console/src/utils/types.ts
@@ -111,6 +111,12 @@ export function dataTypeToIcon(dataType: string): IconName {
case 'COMPLEX<COMPRESSEDBIGDECIMAL>':
return IconNames.SORT_NUMERICAL_DESC;
+ case 'COMPLEX<TEXT>':
+ return IconNames.LABEL;
+
+ case 'COMPLEX<VECTOR>':
+ return IconNames.REGRESSION_CHART;
+
default:
if (typeUpper.startsWith('ARRAY')) return IconNames.ARRAY;
if (typeUpper.startsWith('COMPLEX')) return IconNames.ASTERISK;
diff --git a/web-console/src/views/explore-view/column-picker/column-picker.tsx b/web-console/src/views/explore-view/column-picker/column-picker.tsx
deleted file mode 100644
index 237e84b99e4..00000000000
--- a/web-console/src/views/explore-view/column-picker/column-picker.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * 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 { HTMLSelect } from '@blueprintjs/core';
-import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
-import React from 'react';
-
-export interface ColumnPickerProps {
- availableColumns: ExpressionMeta[] | undefined;
- selectedColumnName: string;
- onSelectedColumnNameChange(selectedColumnName: string): void;
-}
-
-export const ColumnPicker = React.memo(function ColumnPicker(props: ColumnPickerProps) {
- const { availableColumns, selectedColumnName, onSelectedColumnNameChange } = props;
-
- return (
- <HTMLSelect
- className="column-picker"
- value={selectedColumnName}
- onChange={e => {
- onSelectedColumnNameChange(e.target.value);
- }}
- >
- {availableColumns?.map((column, i) => (
- <option key={i} value={column.name}>
- {column.name}
- </option>
- )) || <option value="loading">Loading...</option>}
- </HTMLSelect>
- );
-});
diff --git a/web-console/src/views/explore-view/column-picker-menu/column-picker-menu.scss b/web-console/src/views/explore-view/components/column-picker-menu/column-picker-menu.scss
similarity index 100%
rename from web-console/src/views/explore-view/column-picker-menu/column-picker-menu.scss
rename to web-console/src/views/explore-view/components/column-picker-menu/column-picker-menu.scss
diff --git a/web-console/src/views/explore-view/column-picker-menu/column-picker-menu.tsx b/web-console/src/views/explore-view/components/column-picker-menu/column-picker-menu.tsx
similarity index 80%
rename from web-console/src/views/explore-view/column-picker-menu/column-picker-menu.tsx
rename to web-console/src/views/explore-view/components/column-picker-menu/column-picker-menu.tsx
index 16d3cbee9b1..e25bbd6b788 100644
--- a/web-console/src/views/explore-view/column-picker-menu/column-picker-menu.tsx
+++ b/web-console/src/views/explore-view/components/column-picker-menu/column-picker-menu.tsx
@@ -19,26 +19,32 @@
import type { IconName } from '@blueprintjs/core';
import { Icon, InputGroup, Menu, MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
-import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
+import type { Column } from '@druid-toolkit/query';
import classNames from 'classnames';
import React, { useState } from 'react';
-import { caseInsensitiveContains, dataTypeToIcon, filterMap } from '../../../utils';
+import { caseInsensitiveContains, columnToIcon, filterMap } from '../../../../utils';
import './column-picker-menu.scss';
export interface ColumnPickerMenuProps {
className?: string;
- columns: ExpressionMeta[];
- onSelectColumn(column: ExpressionMeta): void;
- iconForColumn?: (column: ExpressionMeta) => IconName | undefined;
+ columns: readonly Column[];
+ onSelectColumn(column: Column): void;
+ rightIconForColumn?: (column: Column) => IconName | undefined;
onSelectNone?: () => void;
shouldDismissPopover?: boolean;
}
export const ColumnPickerMenu = function ColumnPickerMenu(props: ColumnPickerMenuProps) {
- const { className, columns, onSelectColumn, iconForColumn, onSelectNone, shouldDismissPopover } =
- props;
+ const {
+ className,
+ columns,
+ onSelectColumn,
+ onSelectNone,
+ rightIconForColumn,
+ shouldDismissPopover,
+ } = props;
const [columnSearch, setColumnSearch] = useState('');
return (
@@ -47,7 +53,7 @@ export const ColumnPickerMenu = function ColumnPickerMenu(props: ColumnPickerMen
className="search-input"
value={columnSearch}
onChange={e => setColumnSearch(e.target.value)}
- placeholder="Search..."
+ placeholder="Search"
autoFocus
/>
<Menu className="column-menu">
@@ -61,11 +67,11 @@ export const ColumnPickerMenu = function ColumnPickerMenu(props: ColumnPickerMen
)}
{filterMap(columns, (c, i) => {
if (!caseInsensitiveContains(c.name, columnSearch)) return;
- const iconName = iconForColumn?.(c);
+ const iconName = rightIconForColumn?.(c);
return (
<MenuItem
key={i}
- icon={c.sqlType ? dataTypeToIcon(c.sqlType) : IconNames.BLANK}
+ icon={columnToIcon(c) || IconNames.BLANK}
text={c.name}
labelElement={iconName && <Icon icon={iconName} />}
onClick={() => onSelectColumn(c)}
diff --git a/web-console/src/views/explore-view/components/column-picker/column-picker.tsx b/web-console/src/views/explore-view/components/column-picker/column-picker.tsx
new file mode 100644
index 00000000000..81c88fbf9b7
--- /dev/null
+++ b/web-console/src/views/explore-view/components/column-picker/column-picker.tsx
@@ -0,0 +1,84 @@
+/*
+ * 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, Menu, MenuItem, Popover, Position } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import type { Column } from '@druid-toolkit/query';
+import React, { useState } from 'react';
+
+import { columnToIcon } from '../../../../utils';
+
+export interface ColumnPickerProps {
+ availableColumns: readonly Column[] | undefined;
+ selectedColumnName: string;
+ onSelectedColumnNameChange(selectedColumnName: string): void;
+ fill?: boolean;
+ disabled?: boolean;
+ shouldDismissPopover?: boolean;
+}
+
+export const ColumnPicker = React.memo(function ColumnPicker(props: ColumnPickerProps) {
+ const {
+ availableColumns,
+ selectedColumnName,
+ onSelectedColumnNameChange,
+ fill,
+ disabled,
+ shouldDismissPopover,
+ } = props;
+ const [isOpen, setIsOpen] = useState(false);
+
+ const selectedColumn = availableColumns?.find(c => c.name === selectedColumnName);
+ return (
+ <Popover
+ className="column-picker"
+ position={Position.BOTTOM_LEFT}
+ isOpen={isOpen}
+ minimal
+ fill={fill}
+ disabled={disabled}
+ content={
+ <Menu style={{ maxHeight: '600px', overflowY: 'scroll' }}>
+ {availableColumns?.map((column, i) => (
+ <MenuItem
+ key={i}
+ icon={columnToIcon(column)}
+ text={column.name}
+ onClick={() => {
+ setIsOpen(false);
+ onSelectedColumnNameChange(column.name);
+ }}
+ shouldDismissPopover={shouldDismissPopover}
+ />
+ )) || <MenuItem text="Loading..." />}
+ </Menu>
+ }
+ >
+ <Button
+ icon={selectedColumn ? columnToIcon(selectedColumn) : undefined}
+ text={selectedColumnName}
+ rightIcon={IconNames.CARET_DOWN}
+ fill={fill}
+ disabled={disabled}
+ onClick={() => {
+ setIsOpen(true);
+ }}
+ />
+ </Popover>
+ );
+});
diff --git a/web-console/src/views/explore-view/control-pane/control-pane.scss b/web-console/src/views/explore-view/components/control-pane/control-pane.scss
similarity index 91%
rename from web-console/src/views/explore-view/control-pane/control-pane.scss
rename to web-console/src/views/explore-view/components/control-pane/control-pane.scss
index 52e30069bc4..c1608cdbf86 100644
--- a/web-console/src/views/explore-view/control-pane/control-pane.scss
+++ b/web-console/src/views/explore-view/components/control-pane/control-pane.scss
@@ -16,10 +16,10 @@
* limitations under the License.
*/
-@import '../../../variables';
+@import '../../../../variables';
.control-pane {
- .#{$ns}-tag-input-values .#{$ns}-tag {
+ .#{$bp-ns}-tag-input-values .#{$bp-ns}-tag {
vertical-align: top;
}
}
diff --git a/web-console/src/views/explore-view/components/control-pane/control-pane.tsx b/web-console/src/views/explore-view/components/control-pane/control-pane.tsx
new file mode 100644
index 00000000000..7fcc6352f29
--- /dev/null
+++ b/web-console/src/views/explore-view/components/control-pane/control-pane.tsx
@@ -0,0 +1,386 @@
+/*
+ * 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,
+ InputGroup,
+ Intent,
+ Menu,
+ MenuItem,
+ Popover,
+ SegmentedControl,
+ Tag,
+} from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import type { Column, SqlExpression } from '@druid-toolkit/query';
+import { SqlColumn } from '@druid-toolkit/query';
+import type { JSX } from 'react';
+import React from 'react';
+
+import {
+ AutoForm,
+ FancyNumericInput,
+ FormGroupWithInfo,
+ PopoverText,
+} from '../../../../components';
+import { AppToaster } from '../../../../singletons';
+import { filterMap } from '../../../../utils';
+import type { OptionValue, ParameterDefinition, QuerySource } from '../../models';
+import {
+ effectiveParameterDefault,
+ evaluateFunctor,
+ ExpressionMeta,
+ getModuleOptionLabel,
+ Measure,
+} from '../../models';
+import { changeOrAdd } from '../../utils';
+import { DroppableContainer } from '../droppable-container/droppable-container';
+
+import { ExpressionMenu } from './expression-menu';
+import { MeasureMenu } from './measure-menu';
+import { NamedExpressionsInput } from './named-expressions-input';
+import { OptionsInput } from './options-input';
+
+import './control-pane.scss';
+
+export interface ControlPaneProps {
+ querySource: QuerySource | undefined;
+ onUpdateParameterValues(params: Record<string, unknown>): void;
+ parameters: Record<string, ParameterDefinition>;
+ parameterValues: Record<string, unknown>;
+ onAddToSourceQueryAsColumn?(expression: SqlExpression): void;
+ onAddToSourceQueryAsMeasure?(measure: Measure): void;
+}
+
+export const ControlPane = function ControlPane(props: ControlPaneProps) {
+ const {
+ querySource,
+ onUpdateParameterValues,
+ parameters,
+ parameterValues,
+ onAddToSourceQueryAsColumn,
+ onAddToSourceQueryAsMeasure,
+ } = props;
+ const columns = querySource?.columns || [];
+ const measures = querySource?.measures || [];
+
+ function renderOptionsPropInput(
+ parameter: ParameterDefinition,
+ value: any,
+ onValueChange: (value: any) => void,
+ ): {
+ element: JSX.Element;
+ onDropColumn?: (column: Column) => void;
+ onDropMeasure?: (measure: Measure) => void;
+ } {
+ const effectiveValue = value ?? effectiveParameterDefault(parameter, querySource);
+ const required = evaluateFunctor(parameter.required, parameterValues);
+ switch (parameter.type) {
+ case 'boolean': {
+ return {
+ element: (
+ <SegmentedControl
+ value={String(effectiveValue)}
+ onValueChange={v => {
+ onValueChange(v === 'true');
+ }}
+ options={[
+ { value: 'false', label: 'False' },
+ { value: 'true', label: 'True' },
+ ]}
+ small
+ />
+ ),
+ };
+ }
+
+ case 'number':
+ return {
+ element: (
+ <FancyNumericInput
+ value={effectiveValue}
+ onValueChange={onValueChange}
+ placeholder={parameter.placeholder}
+ fill
+ min={parameter.min}
+ max={parameter.max}
+ />
+ ),
+ };
+
+ case 'string':
+ return {
+ element: (
+ <InputGroup
+ value={(effectiveValue as string) || ''}
+ onChange={e => onValueChange(e.target.value)}
+ placeholder={parameter.placeholder}
+ fill
+ />
+ ),
+ };
+
+ case 'option': {
+ const controlOptions = parameter.options || [];
+ const selectedOption: OptionValue | undefined = controlOptions.find(
+ o => o === effectiveValue,
+ );
+ return {
+ element: (
+ <Popover
+ fill
+ position="bottom-left"
+ minimal
+ content={
+ <Menu>
+ {controlOptions.map((o, i) => (
+ <MenuItem
+ key={i}
+ text={getModuleOptionLabel(o, parameter)}
+ onClick={() => onValueChange(o)}
+ />
+ ))}
+ </Menu>
+ }
+ >
+ <InputGroup
+ value={
+ typeof selectedOption === 'undefined'
+ ? String(effectiveValue)
+ : getModuleOptionLabel(selectedOption, parameter)
+ }
+ readOnly
+ fill
+ rightElement={<Button icon={IconNames.CARET_DOWN} minimal />}
+ />
+ </Popover>
+ ),
+ };
+ }
+
+ case 'options': {
+ return {
+ element: (
+ <OptionsInput
+ options={parameter.options || []}
+ value={(effectiveValue as OptionValue[]) || []}
+ onValueChange={onValueChange}
+ optionLabel={o => getModuleOptionLabel(o, parameter)}
+ allowDuplicates={parameter.allowDuplicates}
+ nonEmpty={parameter.nonEmpty}
+ />
+ ),
+ };
+ }
+
+ case 'expression': {
+ const handleSelectColumn = (c: Column) => {
+ onValueChange(ExpressionMeta.fromColumn(c));
+ };
+ return {
+ element: (
+ <NamedExpressionsInput
+ allowReordering
+ values={effectiveValue ? [effectiveValue] : []}
+ onValuesChange={vs => onValueChange(vs[0])}
+ singleton
+ nonEmpty={required}
+ itemMenu={(initExpression, onClose) => (
+ <ExpressionMenu
+ columns={columns}
+ initExpression={initExpression}
+ onSelectExpression={onValueChange}
+ onClose={onClose}
+ onAddToSourceQueryAsColumn={onAddToSourceQueryAsColumn}
+ />
+ )}
+ />
+ ),
+ onDropColumn: handleSelectColumn,
+ };
+ }
+
+ case 'expressions': {
+ const disabledColumnNames = parameter.allowDuplicates
+ ? []
+ : filterMap(effectiveValue as ExpressionMeta[], ({ expression }) =>
+ expression instanceof SqlColumn ? expression.getName() : undefined,
+ );
+ return {
+ element: (
+ <NamedExpressionsInput
+ allowReordering
+ values={effectiveValue as ExpressionMeta[]}
+ onValuesChange={onValueChange}
+ nonEmpty={parameter.nonEmpty}
+ itemMenu={(initExpression, onClose) => (
+ <ExpressionMenu
+ columns={columns}
+ initExpression={initExpression}
+ onSelectExpression={c =>
+ onValueChange(changeOrAdd(effectiveValue, initExpression, c))
+ }
+ disabledColumnNames={disabledColumnNames}
+ onClose={onClose}
+ onAddToSourceQueryAsColumn={onAddToSourceQueryAsColumn}
+ />
+ )}
+ />
+ ),
+ onDropColumn: (column: Column) => {
+ const columnName = column.name;
+ if (
+ !parameter.allowDuplicates &&
+ effectiveValue.find((v: ExpressionMeta) => v.name === columnName)
+ ) {
+ AppToaster.show({
+ intent: Intent.WARNING,
+ message: (
+ <>
+ <Tag minimal>{columnName}</Tag> already selected
+ </>
+ ),
+ });
+ return;
+ }
+ onValueChange(effectiveValue.concat(ExpressionMeta.fromColumn(column)));
+ },
+ };
+ }
+
+ case 'measure': {
+ return {
+ element: (
+ <NamedExpressionsInput
+ values={effectiveValue ? [effectiveValue] : []}
+ onValuesChange={vs => onValueChange(vs[0])}
+ singleton
+ nonEmpty={required}
+ itemMenu={(initMeasure, onClose) => (
+ <MeasureMenu
+ columns={columns}
+ measures={measures}
+ initMeasure={initMeasure}
+ onSelectMeasure={onValueChange}
+ onClose={onClose}
+ onAddToSourceQueryAsMeasure={onAddToSourceQueryAsMeasure}
+ />
+ )}
+ />
+ ),
+ onDropColumn: column => {
+ const measures = Measure.getPossibleMeasuresForColumn(column);
+ if (!measures.length) return;
+ onValueChange(measures[0]);
+ },
+ onDropMeasure: onValueChange,
+ };
+ }
+
+ case 'measures': {
+ return {
+ element: (
+ <NamedExpressionsInput<Measure>
+ values={effectiveValue}
+ onValuesChange={onValueChange}
+ allowReordering
+ nonEmpty={parameter.nonEmpty}
+ itemMenu={(initMeasure, onClose) => (
+ <MeasureMenu
+ columns={columns}
+ measures={measures}
+ initMeasure={initMeasure}
+ onSelectMeasure={m => onValueChange(changeOrAdd(effectiveValue, initMeasure, m))}
+ onClose={onClose}
+ onAddToSourceQueryAsMeasure={onAddToSourceQueryAsMeasure}
+ />
+ )}
+ />
+ ),
+ onDropColumn: column => {
+ const measures = Measure.getPossibleMeasuresForColumn(column).filter(
+ p => !effectiveValue.some((v: ExpressionMeta) => v.name === p.name),
+ );
+ if (!measures.length) return;
+ onValueChange(effectiveValue.concat(measures[0]));
+ },
+ onDropMeasure: measure => {
+ onValueChange(effectiveValue.concat(measure));
+ },
+ };
+ }
+
+ default:
+ return {
+ element: (
+ <Button
+ icon={IconNames.ERROR}
+ text={`Type not supported: ${(parameter as { type: string }).type}`}
+ disabled
+ fill
+ />
+ ),
+ };
+ }
+ }
+
+ const namedParameters = Object.entries(parameters ?? {});
+
+ return (
+ <div className="control-pane">
+ {namedParameters.map(([name, parameter], i) => {
+ const visible = evaluateFunctor(parameter.visible, parameterValues);
+ if (visible === false) return;
+
+ const value = parameterValues[name];
+ function onValueChange(newValue: unknown) {
+ onUpdateParameterValues({ [name]: newValue });
+ }
+
+ const { element, onDropColumn, onDropMeasure } = renderOptionsPropInput(
+ parameter,
+ value,
+ onValueChange,
+ );
+
+ const description = evaluateFunctor(parameter.description, parameterValues);
+ const formGroup = (
+ <FormGroupWithInfo
+ key={i}
+ label={
+ evaluateFunctor(parameter.label, parameterValues) || AutoForm.makeLabelName(name)
+ }
+ info={description && <PopoverText>{description}</PopoverText>}
+ >
+ {element}
+ </FormGroupWithInfo>
+ );
+
+ if (!onDropColumn) {
+ return formGroup;
+ }
+
+ return (
+ <DroppableContainer key={i} onDropColumn={onDropColumn} onDropMeasure={onDropMeasure}>
+ {formGroup}
+ </DroppableContainer>
+ );
+ })}
+ </div>
+ );
+};
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/views/explore-view/components/control-pane/expression-menu.scss
similarity index 69%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/views/explore-view/components/control-pane/expression-menu.scss
index dbb850df020..0c60d1f9f0a 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
+++ b/web-console/src/views/explore-view/components/control-pane/expression-menu.scss
@@ -18,27 +18,40 @@
@import '../../../../variables';
-.filter-menu {
- width: 400px;
+.expression-menu {
+ width: 500px;
+ height: 47vh;
+ padding: 15px;
+ display: flex;
+ flex-direction: column;
- &.main {
- padding: 15px;
+ .tab-bar {
+ & > * {
+ flex: 1 !important;
+ }
}
- .controls .#{$ns}-form-content {
- display: flex;
- gap: 15px;
+ & > .#{$bp-ns}-menu {
+ flex: 1;
+ padding: 0;
+ overflow: auto;
- .type-selector {
- flex: 1;
+ &:not(:last-child) {
+ margin-bottom: 15px;
}
}
- textarea {
- width: 100%;
+ .editor-container {
+ flex: 1;
+ margin-bottom: 15px;
}
.button-bar {
- text-align: right;
+ display: flex;
+ gap: 10px;
+
+ .button-separator {
+ flex: 1;
+ }
}
}
diff --git a/web-console/src/views/explore-view/components/control-pane/expression-menu.tsx b/web-console/src/views/explore-view/components/control-pane/expression-menu.tsx
new file mode 100644
index 00000000000..407bf9ea329
--- /dev/null
+++ b/web-console/src/views/explore-view/components/control-pane/expression-menu.tsx
@@ -0,0 +1,212 @@
+/*
+ * 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,
+ Icon,
+ InputGroup,
+ Intent,
+ Menu,
+ MenuItem,
+ Popover,
+ Position,
+} from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import type { Column } from '@druid-toolkit/query';
+import { SqlColumn, SqlExpression } from '@druid-toolkit/query';
+import React, { useState } from 'react';
+
+import { AppToaster } from '../../../../singletons';
+import { columnToIcon } from '../../../../utils';
+import { ExpressionMeta } from '../../models';
+import { SqlInput } from '../sql-input/sql-input';
+
+import './expression-menu.scss';
+
+type ExpressionMenuTab = 'select' | 'sql';
+
+export interface ExpressionMenuProps {
+ columns: readonly Column[];
+ initExpression: ExpressionMeta | undefined;
+ onSelectExpression(measure: ExpressionMeta): void;
+ disabledColumnNames?: readonly string[];
+ onClose(): void;
+ onAddToSourceQueryAsColumn?(expression: SqlExpression): void;
+}
+
+function getColumnIndex(columns: readonly Column[], expression: SqlExpression): number {
+ if (!(expression instanceof SqlColumn)) return -1;
+ const columnName = expression.getName();
+ return columns.findIndex(column => column.name === columnName);
+}
+
+export const ExpressionMenu = function ExpressionMenu(props: ExpressionMenuProps) {
+ const {
+ columns,
+ initExpression,
+ onSelectExpression,
+ disabledColumnNames = [],
+ onClose,
+ onAddToSourceQueryAsColumn,
+ } = props;
+ const [tab, setTab] = useState<ExpressionMenuTab>(
+ initExpression && getColumnIndex(columns, initExpression.expression) === -1 ? 'sql' : 'select',
+ );
+ const [outputName, setOutputName] = useState<string>(initExpression?.as || '');
+ const [selectedColumnIndex, setSelectedColumnIndex] = useState<number>(
+ initExpression ? getColumnIndex(columns, initExpression.expression) : -1,
+ );
+ const [formula, setFormula] = useState<string>(
+ initExpression ? String(initExpression.expression) : '',
+ );
+
+ function getExpression(): SqlExpression | undefined {
+ 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;
+ }
+
+ return parsedFormula;
+ }
+
+ return (
+ <div className="expression-menu">
+ <FormGroup>
+ <ButtonGroup className="tab-bar" fill>
+ <Button
+ text="Select"
+ active={tab === 'select'}
+ onClick={() => {
+ if (tab === 'sql') {
+ const parsedFormula = SqlExpression.maybeParse(formula);
+ if (parsedFormula) {
+ const selectedColumnIndex = getColumnIndex(columns, parsedFormula);
+ if (selectedColumnIndex >= 0) {
+ setSelectedColumnIndex(selectedColumnIndex);
+ }
+ }
+ }
+ setTab('select');
+ }}
+ />
+ <Button
+ text="SQL"
+ active={tab === 'sql'}
+ onClick={() => {
+ setTab('sql');
+ }}
+ />
+ </ButtonGroup>
+ </FormGroup>
+ {tab === 'select' && (
+ <Menu>
+ {columns.map((c, i) => (
+ <MenuItem
+ key={i}
+ icon={columnToIcon(c)}
+ text={c.name}
+ disabled={i !== selectedColumnIndex && disabledColumnNames.includes(c.name)}
+ labelElement={i === selectedColumnIndex ? <Icon icon={IconNames.TICK} /> : undefined}
+ onClick={() => {
+ onSelectExpression(ExpressionMeta.fromColumn(c));
+ }}
+ />
+ ))}
+ </Menu>
+ )}
+ {tab === 'sql' && (
+ <>
+ <div className="editor-container">
+ <SqlInput
+ value={formula}
+ onValueChange={setFormula}
+ columns={columns}
+ placeholder="SQL expression"
+ autoFocus
+ />
+ </div>
+ <FormGroup label="Name">
+ <InputGroup
+ value={outputName}
+ onChange={e => {
+ setOutputName(e.target.value.slice(0, ExpressionMeta.MAX_NAME_LENGTH));
+ }}
+ placeholder="(default)"
+ />
+ </FormGroup>
+ <div className="button-bar">
+ {onAddToSourceQueryAsColumn && (
+ <Popover
+ position={Position.BOTTOM_LEFT}
+ content={
+ <Menu>
+ <MenuItem
+ text="Add as column to the source query"
+ onClick={() => {
+ const expression = getExpression();
+ if (!expression) return;
+ onAddToSourceQueryAsColumn(expression.as(outputName || formula));
+ onClose();
+ }}
+ />
+ </Menu>
+ }
+ >
+ <Button icon={IconNames.TH_DERIVED} minimal />
+ </Popover>
+ )}
+ <div className="button-separator" />
+ <Button text="Cancel" onClick={onClose} />
+ <Button
+ intent={Intent.PRIMARY}
+ text="Apply"
+ onClick={() => {
+ const expression = getExpression();
+ if (!expression) return;
+
+ onSelectExpression(
+ new ExpressionMeta({
+ expression,
+ as: outputName,
+ }),
+ );
+ onClose();
+ }}
+ />
+ </div>
+ </>
+ )}
+ </div>
+ );
+};
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/views/explore-view/components/control-pane/measure-menu.scss
similarity index 63%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/views/explore-view/components/control-pane/measure-menu.scss
index dbb850df020..f649a7c839c 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
+++ b/web-console/src/views/explore-view/components/control-pane/measure-menu.scss
@@ -18,27 +18,53 @@
@import '../../../../variables';
-.filter-menu {
- width: 400px;
+.measure-menu {
+ width: 500px;
+ height: 47vh;
+ padding: 15px;
+ display: flex;
+ flex-direction: column;
- &.main {
- padding: 15px;
+ .tab-bar {
+ & > * {
+ flex: 1 !important;
+ }
+ }
+
+ & > .#{$bp-ns}-menu {
+ flex: 1;
+ padding: 0;
+ overflow: auto;
+
+ &:not(:last-child) {
+ margin-bottom: 15px;
+ }
}
- .controls .#{$ns}-form-content {
+ .editor-container {
+ flex: 1;
+ margin-bottom: 15px;
+ }
+
+ .measure-column-line {
display: flex;
- gap: 15px;
+ gap: 8px;
- .type-selector {
+ .column-group {
flex: 1;
}
}
- textarea {
- width: 100%;
+ .expander {
+ flex: 1;
}
.button-bar {
- text-align: right;
+ 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 00000000000..f1296dd8ffc
--- /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<MeasureMenuTab>(() => {
+ if (!initMeasure) return 'compose';
+ if (measures.some(measure => measure.equivalent(initMeasure))) return 'saved';
+ return MeasurePattern.fit(initMeasure.expression) ? 'compose' : 'sql';
+ });
+ const [outputName, setOutputName] = useState<string>(initMeasure?.as || '');
+ const [measurePattern, setMeasurePattern] = useState<MeasurePattern | undefined>(
+ initMeasure ? MeasurePattern.fit(initMeasure.expression) : undefined,
+ );
+ const [formula, setFormula] = useState<string>(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 (
+ <div className="measure-menu">
+ <FormGroup>
+ <ButtonGroup className="tab-bar" fill>
+ <Button
+ text="Saved"
+ active={tab === 'saved'}
+ onClick={() => {
+ setTab('saved');
+ }}
+ />
+ <Button
+ text="Compose"
+ active={tab === 'compose'}
+ onClick={() => {
+ if (tab === 'sql') {
+ const parsedFormula = SqlExpression.maybeParse(formula);
+ if (parsedFormula) {
+ const pattern = MeasurePattern.fit(parsedFormula);
+ if (pattern) {
+ setMeasurePattern(pattern);
+ }
+ }
+ }
+ setTab('compose');
+ }}
+ />
+ <Button
+ text="SQL"
+ active={tab === 'sql'}
+ onClick={() => {
+ if (tab === 'compose' && measurePattern) {
+ setFormula(String(measurePattern));
+ }
+ setTab('sql');
+ }}
+ />
+ </ButtonGroup>
+ </FormGroup>
+ {tab === 'saved' && (
+ <Menu>
+ {measures.map((measure, i) => {
+ const aggregateMeasure = measure.toAggregateBasedMeasure();
+ return (
+ <MenuItem
+ key={i}
+ icon={IconNames.NUMERICAL}
+ text={aggregateMeasure.name}
+ labelElement={
+ aggregateMeasure.equals(initMeasure) ? <Icon icon={IconNames.TICK} /> : undefined
+ }
+ onClick={() => {
+ onSelectMeasure(aggregateMeasure);
+ }}
+ />
+ );
+ })}
+ </Menu>
+ )}
+ {tab === 'compose' &&
+ (measurePattern ? (
+ <div className="measure-column-line">
+ <FormGroup label="Aggregate">
+ <HTMLSelect
+ value={measurePattern.aggregate}
+ onChange={(e: any) =>
+ setMeasurePattern(measurePattern.changeAggregate(e.target.value))
+ }
+ >
+ {MeasurePattern.AGGREGATES.map(measure => (
+ <option key={measure} value={measure}>
+ {measure}
+ </option>
+ ))}
+ </HTMLSelect>
+ </FormGroup>
+ <FormGroup label="Column" className="column-group">
+ <ColumnPicker
+ availableColumns={columns}
+ disabled={measurePattern.aggregate === 'count'}
+ selectedColumnName={measurePattern.column}
+ onSelectedColumnNameChange={column => {
+ setMeasurePattern(measurePattern.changeColumn(column));
+ }}
+ fill
+ shouldDismissPopover={false}
+ />
+ </FormGroup>
+ </div>
+ ) : (
+ <Menu>
+ {columns.map((c, i) => (
+ <MenuItem
+ key={i}
+ icon={columnToIcon(c)}
+ text={c.name}
+ shouldDismissPopover={false}
+ onClick={() => {
+ setMeasurePattern(MeasurePattern.fromColumn(c));
+ }}
+ />
+ ))}
+ </Menu>
+ ))}
+ {tab === 'sql' && (
+ <div className="editor-container">
+ <SqlInput
+ value={formula}
+ onValueChange={setFormula}
+ columns={columns}
+ placeholder="SQL measure expression"
+ autoFocus
+ />
+ </div>
+ )}
+ {((tab === 'compose' && measurePattern) || tab === 'sql') && (
+ <FormGroup label="Name">
+ <InputGroup
+ value={outputName}
+ placeholder="(default)"
+ onChange={e => {
+ setOutputName(e.target.value.slice(0, Measure.MAX_NAME_LENGTH));
+ }}
+ />
+ </FormGroup>
+ )}
+ {tab === 'compose' && measurePattern && <div className="expander" />}
+ {tab !== 'saved' && (
+ <div className="button-bar">
+ {onAddToSourceQueryAsMeasure && (
+ <Popover
+ disabled={actionDisabled}
+ position={Position.BOTTOM_LEFT}
+ content={
+ <Menu>
+ <MenuItem
+ text="Add as measure to the source query"
+ onClick={() => {
+ const measure = getMeasure();
+ if (!measure) return;
+
+ onAddToSourceQueryAsMeasure(measure);
+ onClose();
+ }}
+ />
+ </Menu>
+ }
+ >
+ <Button icon={IconNames.TH_DERIVED} minimal disabled={actionDisabled} />
+ </Popover>
+ )}
+ <div className="button-separator" />
+ <Button text="Cancel" onClick={onClose} />
+ <Button
+ intent={Intent.PRIMARY}
+ text="Apply"
+ disabled={actionDisabled}
+ onClick={() => {
+ const measure = getMeasure();
+ if (!measure) return;
+
+ onSelectMeasure(measure);
+ onClose();
+ }}
+ />
+ </div>
+ )}
+ </div>
+ );
+};
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%
copy from web-console/src/views/explore-view/control-pane/columns-input.scss
copy to web-console/src/views/explore-view/components/control-pane/named-expressions-input.scss
index 2f7fc37e554..5dec6be7511 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 50e6b88c83e..ae498341ecd 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<M extends ExpressionMeta | Measure> {
+ 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<M>) {
+ 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 (
- <div className={classNames('columns-input', Classes.INPUT, Classes.TAG_INPUT, Classes.FILL)}>
+ <div
+ className={classNames(
+ 'named-expressions-input',
+ Classes.INPUT,
+ Classes.TAG_INPUT,
+ Classes.FILL,
+ )}
+ >
<div className={Classes.TAG_INPUT_VALUES} onDragEnd={onDrop}>
- {value.map((c, i) => (
- <Tag
- className={classNames({
- 'drop-before': dropIndex === i && dropBefore,
- 'drop-after': dropIndex === i && !dropBefore,
- })}
- interactive
- draggable={allowReordering}
- onDragOver={e => onDragOver(e, i)}
+ {values.map((c, i) => (
+ <Popover
key={i}
- onDragStart={e => 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}
- </Tag>
+ <Tag
+ className={classNames({
+ 'drop-before': dropIndex === i && dropBefore,
+ 'drop-after': dropIndex === i && !dropBefore,
+ })}
+ title={`Expression: ${c.expression}`}
+ interactive
+ onClick={() => 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}
+ </Tag>
+ </Popover>
))}
- <Popover
- position={Position.BOTTOM}
- content={
- pickerMenu ? (
- pickerMenu(availableColumns)
- ) : (
- <ColumnPickerMenu
- columns={availableColumns}
- onSelectColumn={c => onValueChange(value.concat(c))}
- />
- )
- }
- >
- <Tag icon={IconNames.PLUS} interactive />
- </Popover>
+ {(!singleton || !values.length) && (
+ <Popover
+ isOpen={Boolean(menuOpenOn && !menuOpenOn.openOn)}
+ position={Position.BOTTOM}
+ onClose={menuOnClose}
+ content={itemMenu(undefined, menuOnClose)}
+ >
+ <Tag icon={IconNames.PLUS} interactive onClick={() => setMenuOpenOn({})} />
+ </Popover>
+ )}
</div>
</div>
);
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 9eec12da830..286889bf6b2 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 (
<div className={classNames('options-input', Classes.INPUT, Classes.TAG_INPUT, Classes.FILL)}>
<div className={Classes.TAG_INPUT_VALUES}>
- {selectedOptions.map((o, i) => (
- <Tag
- interactive
+ {selectedOptions.map((selectedOption, i) => (
+ <Popover
key={i}
- onRemove={() => {
- onValueChange(value.filter(v => v !== o));
- }}
+ position={Position.BOTTOM}
+ content={
+ <Menu>
+ {(allowDuplicates
+ ? options
+ : options.filter(o => o === selectedOption || !value.find(v => v === o))
+ ).map((ao, j) => (
+ <MenuItem
+ key={j}
+ text={optionLabel(ao)}
+ labelElement={
+ ao === selectedOption ? <Icon icon={IconNames.TICK} /> : undefined
+ }
+ onClick={() => {
+ onValueChange(value.map(v => (v === selectedOption ? ao : v)));
+ }}
+ />
+ ))}
+ </Menu>
+ }
>
- {getPluginOptionLabel(o, parameter)}
- </Tag>
+ <Tag
+ interactive
+ onRemove={
+ canRemove
+ ? () => {
+ onValueChange(value.filter(v => v !== selectedOption));
+ }
+ : undefined
+ }
+ >
+ {optionLabel(selectedOption)}
+ </Tag>
+ </Popover>
))}
<Popover
position={Position.BOTTOM}
content={
<Menu>
- {availableOptions.map((o, i) => (
+ {availableOptions.map((ao, i) => (
<MenuItem
key={i}
- text={getPluginOptionLabel(o, parameter)}
+ text={optionLabel(ao)}
onClick={() => {
- 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%
copy from web-console/src/views/explore-view/droppable-container/droppable-container.scss
copy to web-console/src/views/explore-view/components/droppable-container/droppable-container.scss
index 74b8a397384..0283c69be7b 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 00000000000..ef8f36d25aa
--- /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<HTMLDivElement> {
+ onDropColumn(column: Column): void;
+ onDropMeasure?(measure: Measure): void;
+ children?: React.ReactNode;
+}
+
+export const DroppableContainer = forwardRef<HTMLDivElement, DroppableContainerProps>(
+ function DroppableContainer(props, ref) {
+ const { className, onDropColumn, onDropMeasure, children, ...rest } = props;
+ const [dropHover, setDropHover] = useState(false);
+
+ return (
+ <div
+ ref={ref}
+ className={classNames('droppable-container', className, { 'drop-hover': dropHover })}
+ {...rest}
+ onDragOver={e => {
+ 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}
+ </div>
+ );
+ },
+);
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%
copy from web-console/src/views/explore-view/filter-pane/column-value/column-value.scss
copy 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 00000000000..353179c905e
--- /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<QueryResult>;
+}
+
+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<string, string[]>({
+ query: previewQuery,
+ debounceIdle: 100,
+ debounceLoading: 500,
+ processQuery: async query => {
+ const vs = await runSqlQuery(query);
+ return (vs.getColumnByName('c') || []).map(String);
+ },
+ });
+
+ return (
+ <div className="contains-filter-control">
+ <FormGroup>
+ <InputGroup
+ value={contains}
+ onChange={e => setFilterPattern({ ...filterPattern, contains: e.target.value })}
+ placeholder="Search string"
+ />
+ </FormGroup>
+ <FormGroup label="Preview">
+ <Menu className="preview-list">
+ {previewState.data?.map((v, i) => (
+ <MenuItem
+ key={i}
+ className="preview-item"
+ text={String(v)}
+ shouldDismissPopover={false}
+ />
+ ))}
+ {previewState.loading && <MenuItem disabled text="Loading..." />}
+ {previewState.error && (
+ <MenuItem icon={IconNames.ERROR} disabled text={previewState.getErrorMessage()} />
+ )}
+ </Menu>
+ </FormGroup>
+ </div>
+ );
+});
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%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.scss
index dbb850df020..b5659da421c 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 00000000000..8cbca7e8d53
--- /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<FilterPatternType, string> = {
+ 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<QueryResult>;
+ 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<FilterMenuTab>(initPattern?.type === 'custom' ? 'sql' : 'compose');
+ const [formula, setFormula] = useState<string>(
+ initPattern?.type === 'custom' ? filterPatternToExpression(initPattern).toString() : '',
+ );
+ const [pattern, setPattern] = useState<FilterPattern | undefined>(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 = (
+ <ValuesFilterControl
+ querySource={querySource}
+ filter={filter.removeColumnFromAnd(pattern.column)}
+ filterPattern={pattern}
+ setFilterPattern={setPattern}
+ runSqlQuery={runSqlQuery}
+ />
+ );
+ break;
+
+ case 'contains':
+ cont = (
+ <ContainsFilterControl
+ querySource={querySource}
+ filter={filter.removeColumnFromAnd(pattern.column)}
+ filterPattern={pattern}
+ setFilterPattern={setPattern}
+ runSqlQuery={runSqlQuery}
+ />
+ );
+ break;
+
+ case 'regexp':
+ cont = (
+ <RegexpFilterControl
+ querySource={querySource}
+ filter={filter.removeColumnFromAnd(pattern.column)}
+ filterPattern={pattern}
+ setFilterPattern={setPattern}
+ runSqlQuery={runSqlQuery}
+ />
+ );
+ break;
+
+ case 'numberRange':
+ cont = (
+ <NumberRangeFilterControl
+ querySource={querySource}
+ filterPattern={pattern}
+ setFilterPattern={setPattern}
+ />
+ );
+ break;
+
+ case 'timeInterval':
+ cont = (
+ <TimeIntervalFilterControl
+ querySource={querySource}
+ filterPattern={pattern}
+ setFilterPattern={setPattern}
+ />
+ );
+ break;
+
+ case 'timeRelative':
+ cont = (
+ <TimeRelativeFilterControl
+ querySource={querySource}
+ filterPattern={pattern}
+ setFilterPattern={setPattern}
+ />
+ );
+ 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 = (
+ <FormGroup>
+ <Callout>
+ <p>The current filter can only be edited as SQL.</p>
+ <p>
+ <a onClick={() => setTab('sql')}>Continue editing in SQL.</a>
+ </p>
+ {column && (
+ <p>
+ <a onClick={() => setPattern(initPatternForColumn(column))}>{`Compose on column ${C(
+ column.name,
+ )}.`}</a>
+ </p>
+ )}
+ </Callout>
+ </FormGroup>
+ );
+ break;
+ }
+
+ default:
+ cont = <div>Pattern no set</div>;
+ break;
+ }
+
+ function parseFormula(): SqlExpression | undefined {
+ try {
+ return SqlExpression.parse(formula);
+ } catch (e) {
+ AppToaster.show({
+ intent: Intent.DANGER,
+ message: e.message,
+ });
+ return;
+ }
+ }
+
+ return (
+ <div className="filter-menu">
+ <FormGroup>
+ <ButtonGroup className="tab-bar" fill>
+ <Button
+ text="Compose"
+ active={tab === 'compose'}
+ onClick={() => {
+ if (formula) {
+ const parsedFormula = parseFormula();
+ if (!parsedFormula) return;
+ setPattern(fitFilterPattern(parsedFormula));
+ } else {
+ setPattern(undefined);
+ }
+ setTab('compose');
+ }}
+ />
+ <Button
+ text="SQL"
+ active={tab === 'sql'}
+ onClick={() => {
+ setFormula(pattern ? filterPatternToExpression(pattern).toString() : '');
+ setTab('sql');
+ }}
+ />
+ </ButtonGroup>
+ </FormGroup>
+ {tab === 'compose' &&
+ (pattern ? (
+ <>
+ {pattern.type !== 'custom' && (
+ <div className="controls">
+ <FormGroup className="column-form-group" label="Column">
+ <ColumnPicker
+ availableColumns={querySource.columns}
+ selectedColumnName={pattern.column}
+ onSelectedColumnNameChange={selectedColumnName => {
+ setPattern({ ...pattern, column: selectedColumnName });
+ }}
+ fill
+ />
+ </FormGroup>
+ <FormGroup label="Filter type">
+ <HTMLSelect
+ className="type-selector"
+ value={pattern.type}
+ onChange={e =>
+ setPattern(
+ changeFilterPatternType(pattern, e.target.value as FilterPatternType),
+ )
+ }
+ >
+ {getPattenTypesForColumn(
+ querySource.columns.find(c => c.name === pattern.column),
+ ).map(type => (
+ <option key={type} value={type}>
+ {PATTERN_TYPE_TO_NAME[type]}
+ </option>
+ ))}
+ </HTMLSelect>
+ </FormGroup>
+ <FormGroup>
+ <ButtonGroup>
+ <Button
+ icon={IconNames.FILTER}
+ active={!negated}
+ onClick={() => setPattern({ ...pattern, negated: false })}
+ data-tooltip="Include"
+ />
+ <Button
+ icon={IconNames.FILTER_REMOVE}
+ active={negated}
+ onClick={() => setPattern({ ...pattern, negated: true })}
+ data-tooltip="Exclude"
+ />
+ </ButtonGroup>
+ </FormGroup>
+ </div>
+ )}
+ {cont}
+ </>
+ ) : (
+ <ColumnPickerMenu
+ columns={columns}
+ onSelectColumn={c => setPattern(initPatternForColumn(c))}
+ rightIconForColumn={c =>
+ filter.containsColumnName(c.name) ? IconNames.FILTER : undefined
+ }
+ shouldDismissPopover={false}
+ />
+ ))}
+ {tab === 'sql' && (
+ <FormGroup>
+ <SqlInput
+ value={formula}
+ onValueChange={sql => setFormula(sql)}
+ columns={columns}
+ placeholder="SQL expression"
+ editorHeight={250}
+ autoFocus
+ />
+ </FormGroup>
+ )}
+ {(pattern || tab === 'sql') && (
+ <div className="button-bar">
+ {pattern && onAddToSourceQueryAsColumn && onMoveToSourceQueryAsClause && (
+ <Popover
+ position={Position.BOTTOM_LEFT}
+ content={
+ <Menu>
+ <MenuItem
+ text="Add as boolean column to the source query"
+ onClick={() => {
+ if (tab === 'compose') {
+ onAddToSourceQueryAsColumn(
+ filterPatternToExpression(pattern).as(
+ formatPatternWithoutNegation(pattern),
+ ),
+ );
+ } else {
+ const parsedFormula = parseFormula();
+ if (!parsedFormula) return;
+ onAddToSourceQueryAsColumn(parsedFormula.as(formula));
+ }
+ onClose();
+ }}
+ />
+ <MenuItem
+ text="Move where clause to the source query"
+ onClick={() => {
+ if (tab === 'compose') {
+ onMoveToSourceQueryAsClause(filterPatternToExpression(pattern));
+ } else {
+ const parsedFormula = parseFormula();
+ if (!parsedFormula) return;
+ onMoveToSourceQueryAsClause(parsedFormula);
+ }
+ onClose();
+ }}
+ />
+ </Menu>
+ }
+ >
+ <Button icon={IconNames.TH_DERIVED} minimal />
+ </Popover>
+ )}
+ <div className="button-separator" />
+ <Button text="Cancel" onClick={onClose} />
+ <Button
+ intent={Intent.PRIMARY}
+ text="Apply"
+ disabled={tab === 'sql' && formula === ''}
+ onClick={() => {
+ if (tab === 'compose') {
+ if (pattern) {
+ onAcceptPattern(pattern);
+ }
+ } else {
+ const parsedFormula = parseFormula();
+ if (!parsedFormula) return;
+ onAcceptPattern({ type: 'custom', negated: false, expression: parsedFormula });
+ }
+ }}
+ />
+ </div>
+ )}
+ </div>
+ );
+});
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 00000000000..eb5adc9c4a7
--- /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 (
+ <div className="number-range-filter-control">
+ <FormGroup label="Bounds">
+ <ControlGroup>
+ <NumericInput
+ value={start}
+ onValueChange={start => setFilterPattern({ ...filterPattern, start })}
+ fill
+ />
+ <Button
+ icon={startBound === '[' ? IconNames.LESS_THAN_OR_EQUAL_TO : IconNames.LESS_THAN}
+ onClick={() =>
+ setFilterPattern({ ...filterPattern, startBound: startBound === '[' ? '(' : '[' })
+ }
+ />
+ <Button text="X" disabled />
+ <Button
+ icon={endBound === ']' ? IconNames.LESS_THAN_OR_EQUAL_TO : IconNames.LESS_THAN}
+ onClick={() =>
+ setFilterPattern({ ...filterPattern, endBound: endBound === ']' ? ')' : ']' })
+ }
+ />
+ <NumericInput
+ value={end}
+ onValueChange={end => setFilterPattern({ ...filterPattern, end })}
+ fill
+ />
+ </ControlGroup>
+ </FormGroup>
+ </div>
+ );
+});
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 13358ac3427..ea6bdf74385 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<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]>;
+ runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
}
export const RegexpFilterControl = React.memo(function RegexpFilterControl(
props: RegexpFilterControlProps,
) {
- const { dataset, filter, initFilterPattern, negated, setFilterPattern, queryDruidSql } = props;
- const [column, setColumn] = useState<string>(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<string, string[]>({
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 (
<div className="regexp-filter-control">
- <FormGroup label="Column">
- <ColumnPicker
- availableColumns={dataset.columns}
- selectedColumnName={column}
- onSelectedColumnNameChange={setColumn}
- />
- </FormGroup>
<FormGroup>
- <InputGroup value={regexp} onChange={e => setRegexp(e.target.value)} placeholder="Regexp" />
+ <InputGroup
+ value={regexp}
+ onChange={e => setFilterPattern({ ...filterPattern, regexp: e.target.value })}
+ placeholder="Regexp"
+ />
</FormGroup>
<FormGroup label="Preview">
<Menu className="preview-list">
@@ -127,18 +105,6 @@ export const RegexpFilterControl = React.memo(function RegexpFilterControl(
)}
</Menu>
</FormGroup>
- <div className="button-bar">
- <Button
- intent={Intent.PRIMARY}
- text="OK"
- onClick={() => {
- const newPattern = makePattern();
- // TODO: check if pattern is valid
- // if (!isFilterPatternValid(newPattern)) return;
- setFilterPattern(newPattern);
- }}
- />
- </div>
</div>
);
});
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/filter-menu/time-interval-filter-control/time-interval-filter-control.scss
similarity index 90%
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/filter-menu/time-interval-filter-control/time-interval-filter-control.scss
index 9ef99b60b6f..51816400f82 100644
--- a/web-console/src/views/explore-view/filter-pane/column-value/column-value.scss
+++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.scss
@@ -16,10 +16,11 @@
* limitations under the License.
*/
-.column-value {
- &.empty,
- &.null {
- font-style: italic;
- opacity: 0.9;
+.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 00000000000..acb21c309d2
--- /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 (
+ <div className="time-interval-filter-control">
+ <FormGroup label="Start">
+ <UtcDateInput
+ date={start}
+ onChange={start => setFilterPattern({ ...filterPattern, start })}
+ />
+ </FormGroup>
+ <FormGroup label="End">
+ <UtcDateInput date={end} onChange={end => setFilterPattern({ ...filterPattern, end })} />
+ </FormGroup>
+ </div>
+ );
+});
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 b1ec8061117..32687c0c1b5 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<string>(initFilterPattern.column);
+ const { filterPattern, setFilterPattern } = props;
+ const { column, negated } = filterPattern;
- const initKey = partialPatternToKey(initFilterPattern);
+ const initKey = partialPatternToKey(filterPattern);
return (
<div className="time-relative-filter-control">
- <FormGroup label="Column">
- <ColumnPicker
- availableColumns={dataset.columns}
- selectedColumnName={column}
- onSelectedColumnNameChange={setColumn}
- />
- </FormGroup>
{GROUPS.map(({ groupName, namedPartialPatterns }, i) => (
<FormGroup key={i} label={groupName}>
- {namedPartialPatterns.map(({ name, partialPattern }, i) => (
- <Button
- key={i}
- text={name}
- active={initKey === partialPatternToKey(partialPattern)}
- onClick={() => {
- setFilterPattern({
- type: 'timeRelative',
- negated,
- column,
- ...partialPattern,
- startBound: '[',
- endBound: ')',
- });
- }}
- />
- ))}
+ <ButtonGroup>
+ {namedPartialPatterns.map(({ name, partialPattern }, i) => (
+ <Button
+ key={i}
+ text={name}
+ active={initKey === partialPatternToKey(partialPattern)}
+ onClick={() => {
+ setFilterPattern({
+ type: 'timeRelative',
+ negated,
+ column,
+ ...partialPattern,
+ startBound: '[',
+ endBound: ')',
+ });
+ }}
+ />
+ ))}
+ </ButtonGroup>
</FormGroup>
))}
</div>
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 00000000000..a15d3daad76
--- /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<QueryResult>;
+}
+
+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<string, any[]>({
+ 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 (
+ <FormGroup className="values-filter-control">
+ {showSearch && (
+ <InputGroup
+ value={searchString}
+ onChange={e => setSearchString(e.target.value)}
+ placeholder="Search"
+ />
+ )}
+ <Menu className="value-list">
+ {valuesToShow.map((v, i) => (
+ <MenuItem
+ key={i}
+ icon={
+ selectedValues.includes(v)
+ ? negated
+ ? IconNames.DELETE
+ : IconNames.TICK_CIRCLE
+ : IconNames.CIRCLE
+ }
+ text={<ColumnValue value={v} />}
+ shouldDismissPopover={false}
+ onClick={e => {
+ setFilterPattern({
+ ...filterPattern,
+ values: e.altKey ? [v] : toggle(selectedValues, v),
+ });
+ }}
+ />
+ ))}
+ {valuesState.loading && <MenuItem icon={IconNames.BLANK} text="Loading..." disabled />}
+ </Menu>
+ </FormGroup>
+ );
+});
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 9b117a9bc22..9be736495f0 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 4692cda170c..72ddd8f7046 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<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]>;
+ runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
+ 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<number>(-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 (
<DroppableContainer className="filter-pane" onDropColumn={filterOn}>
- <Button className="filter-label" minimal text="Filter:" />
+ <Button className="filter-icon-button" icon={IconNames.FILTER} minimal disabled />
{patterns.map((pattern, i) => {
return (
<div className="filter-pill" key={i}>
- {dataset ? (
+ {querySource ? (
<Popover
isOpen={i === menuIndex}
onClose={() => setMenuIndex(-1)}
+ position="bottom"
content={
<FilterMenu
- dataset={dataset}
+ querySource={querySource}
filter={filter}
initPattern={pattern}
onPatternChange={newPattern => {
@@ -91,10 +105,22 @@ export const FilterPane = forwardRef(function FilterPane(props: FilterPaneProps,
onClose={() => {
setMenuIndex(-1);
}}
- queryDruidSql={queryDruidSql}
+ runSqlQuery={runSqlQuery}
+ onAddToSourceQueryAsColumn={onAddToSourceQueryAsColumn}
+ onMoveToSourceQueryAsClause={
+ onMoveToSourceQueryAsClause
+ ? ex => {
+ onMoveToSourceQueryAsClause(
+ ex,
+ filterPatternsToExpression(
+ patterns.filter((_clause, idx) => idx !== i),
+ ),
+ );
+ }
+ : undefined
+ }
/>
}
- position="bottom"
>
<Button
className={classNames('filter-text-button', { negated: pattern.negated })}
@@ -121,7 +147,7 @@ export const FilterPane = forwardRef(function FilterPane(props: FilterPaneProps,
</div>
);
})}
- {dataset && (
+ {querySource ? (
<Popover
className="add-button"
isOpen={Boolean(menuNew)}
@@ -129,7 +155,7 @@ export const FilterPane = forwardRef(function FilterPane(props: FilterPaneProps,
onClose={() => setMenuNew(undefined)}
content={
<FilterMenu
- dataset={dataset}
+ querySource={querySource}
filter={filter}
initPattern={menuNew?.column ? initPatternForColumn(menuNew?.column) : undefined}
onPatternChange={newPattern => {
@@ -138,12 +164,26 @@ export const FilterPane = forwardRef(function FilterPane(props: FilterPaneProps,
onClose={() => {
setMenuNew(undefined);
}}
- queryDruidSql={queryDruidSql}
+ runSqlQuery={runSqlQuery}
+ onAddToSourceQueryAsColumn={onAddToSourceQueryAsColumn}
+ onMoveToSourceQueryAsClause={onMoveToSourceQueryAsClause}
/>
}
>
- <Button icon={IconNames.PLUS} onClick={() => setMenuNew({})} minimal />
+ <Button
+ icon={IconNames.PLUS}
+ text={patterns.length ? undefined : 'Add filter'}
+ onClick={() => setMenuNew({})}
+ minimal
+ />
</Popover>
+ ) : (
+ <Button
+ icon={IconNames.PLUS}
+ text={patterns.length ? undefined : 'Add filter'}
+ disabled
+ minimal
+ />
)}
</DroppableContainer>
);
diff --git a/web-console/src/views/explore-view/modules/components/generic-output-table/generic-output-table.scss b/web-console/src/views/explore-view/components/generic-output-table/generic-output-table.scss
similarity index 94%
rename from web-console/src/views/explore-view/modules/components/generic-output-table/generic-output-table.scss
rename to web-console/src/views/explore-view/components/generic-output-table/generic-output-table.scss
index 31733db7828..f30d6efd54d 100644
--- a/web-console/src/views/explore-view/modules/components/generic-output-table/generic-output-table.scss
+++ b/web-console/src/views/explore-view/components/generic-output-table/generic-output-table.scss
@@ -16,8 +16,8 @@
* limitations under the License.
*/
-@import '../../../../../variables';
-@import '../../../../../blueprint-overrides/common/colors';
+@import '../../../../variables';
+@import '../../../../blueprint-overrides/common/colors';
.generic-output-table {
position: relative;
@@ -55,7 +55,7 @@
box-shadow: 0 1px 0 0 rgba(black, 0.2); // This is a hack! this line is sometimes too weak in tables.
.rt-th {
- &.aggregate-header {
+ &.measure-header {
background: rgba($druid-brand, 0.06);
}
@@ -115,7 +115,7 @@
width: 100%;
}
- .aggregate-column {
+ .measure-column {
background-color: rgba($druid-brand, 0.06);
}
}
diff --git a/web-console/src/views/explore-view/modules/components/generic-output-table/generic-output-table.tsx b/web-console/src/views/explore-view/components/generic-output-table/generic-output-table.tsx
similarity index 70%
rename from web-console/src/views/explore-view/modules/components/generic-output-table/generic-output-table.tsx
rename to web-console/src/views/explore-view/components/generic-output-table/generic-output-table.tsx
index ebc56aefe6a..0050a34122c 100644
--- a/web-console/src/views/explore-view/modules/components/generic-output-table/generic-output-table.tsx
+++ b/web-console/src/views/explore-view/components/generic-output-table/generic-output-table.tsx
@@ -19,18 +19,18 @@
import { Button, Icon, Intent, Menu, MenuItem, Popover } from '@blueprintjs/core';
import type { IconName } from '@blueprintjs/icons';
import { IconNames } from '@blueprintjs/icons';
-import type { Column, QueryResult, SqlExpression } from '@druid-toolkit/query';
-import { SqlColumn, SqlLiteral, trimString } from '@druid-toolkit/query';
+import type { Column, QueryResult } from '@druid-toolkit/query';
+import { SqlColumn, SqlExpression, SqlLiteral, trimString } from '@druid-toolkit/query';
import classNames from 'classnames';
import type { JSX } from 'react';
import React, { useEffect, useState } from 'react';
import type { Column as TableColumn } from 'react-table';
import ReactTable from 'react-table';
-import { BracedText, Deferred, TableCell } from '../../../../../components';
-import { possibleDruidFormatForValues, TIME_COLUMN } from '../../../../../druid-models';
-import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from '../../../../../react-table';
-import type { ColumnHint, Pagination, QueryAction } from '../../../../../utils';
+import { BracedText, Deferred, TableCell } from '../../../../components';
+import { ShowValueDialog } from '../../../../dialogs/show-value-dialog/show-value-dialog';
+import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from '../../../../react-table';
+import type { ColumnHint, Pagination } from '../../../../utils';
import {
columnToIcon,
columnToWidth,
@@ -39,8 +39,7 @@ import {
getNumericColumnBraces,
prettyPrintSql,
stringifyValue,
- timeFormatToSql,
-} from '../../../../../utils';
+} from '../../../../utils';
import './generic-output-table.scss';
@@ -94,12 +93,16 @@ function columnNester(
return ret;
}
+export function calculateInitPageSize(height: number): number {
+ return Math.ceil((height - 74) / 39);
+}
+
export interface GenericOutputTableProps {
queryResult: QueryResult;
- onQueryAction(action: QueryAction): void;
+ onWhereChange?(where: SqlExpression): void;
+ onHavingChange?(having: SqlExpression): void;
onOrderByChange?(columnName: string, desc: boolean): void;
onExport?(): void;
- runeMode: boolean;
showTypeIcons: boolean;
initPageSize?: number;
columnHints?: Map<string, ColumnHint>;
@@ -110,10 +113,10 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
) {
const {
queryResult,
- onQueryAction,
+ onWhereChange,
+ onHavingChange,
onOrderByChange,
onExport,
- runeMode,
showTypeIcons,
initPageSize,
columnHints,
@@ -123,6 +126,7 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
page: 0,
pageSize: initPageSize || 20,
});
+ const [showValue, setShowValue] = useState<string>();
// Reset page to 0 if number of results changes
useEffect(() => {
@@ -147,9 +151,6 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
const menuItems: JSX.Element[] = [];
if (parsedQuery) {
- const noStar = !parsedQuery.hasStarInSelect();
- const selectExpression = parsedQuery.getSelectExpressionForIndex(headerIndex);
-
if (onOrderByChange) {
const orderBy = parsedQuery.getOrderByForSelectIndex(headerIndex);
@@ -189,139 +190,91 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
}
if (parsedQuery.isRealOutputColumnAtSelectIndex(headerIndex)) {
- const whereExpression = parsedQuery.getWhereExpression();
- if (whereExpression && whereExpression.containsColumnName(header)) {
- menuItems.push(
- <MenuItem
- key="remove_where"
- icon={IconNames.FILTER_REMOVE}
- text="Remove from WHERE clause"
- onClick={() => {
- onQueryAction(q =>
- q.changeWhereExpression(whereExpression.removeColumnFromAnd(header)),
- );
- }}
- />,
- );
- }
-
- const havingExpression = parsedQuery.getHavingExpression();
- if (havingExpression && havingExpression.containsColumnName(header)) {
- menuItems.push(
- <MenuItem
- key="remove_having"
- icon={IconNames.FILTER_REMOVE}
- text="Remove from HAVING clause"
- onClick={() => {
- onQueryAction(q =>
- q.changeHavingExpression(havingExpression.removeColumnFromAnd(header)),
- );
- }}
- />,
- );
+ if (onWhereChange) {
+ const whereExpression = parsedQuery.getWhereExpression();
+ if (whereExpression && whereExpression.containsColumnName(header)) {
+ menuItems.push(
+ <MenuItem
+ key="remove_where"
+ icon={IconNames.FILTER_REMOVE}
+ text="Remove from WHERE clause"
+ onClick={() => {
+ const newWhere = whereExpression.removeColumnFromAnd(header);
+ if (!newWhere) return;
+ onWhereChange(newWhere);
+ }}
+ />,
+ );
+ }
}
- }
-
- if (noStar && selectExpression) {
- if (column.isTimeColumn()) {
- // ToDo: clean
- } else if (column.sqlType === 'TIMESTAMP') {
- menuItems.push(
- <MenuItem
- key="declare_time"
- icon={IconNames.TIME}
- text="Use as the primary time column"
- onClick={() => {
- onQueryAction(q => q.changeSelect(headerIndex, selectExpression.as(TIME_COLUMN)));
- }}
- />,
- );
- } else {
- // Not a time column -------------------------------------------
- const values = queryResult.rows.map(row => row[headerIndex]);
- const possibleDruidFormat = possibleDruidFormatForValues(values);
- const formatSql = possibleDruidFormat ? timeFormatToSql(possibleDruidFormat) : undefined;
-
- if (formatSql) {
- const newSelectExpression = formatSql.fillPlaceholders([
- selectExpression.getUnderlyingExpression(),
- ]);
+ if (onHavingChange) {
+ const havingExpression = parsedQuery.getHavingExpression();
+ if (havingExpression && havingExpression.containsColumnName(header)) {
menuItems.push(
<MenuItem
- key="parse_time"
- icon={IconNames.TIME}
- text={`Time parse as '${possibleDruidFormat}' and use as the primary time column`}
+ key="remove_having"
+ icon={IconNames.FILTER_REMOVE}
+ text="Remove from HAVING clause"
onClick={() => {
- onQueryAction(q =>
- q.changeSelect(headerIndex, newSelectExpression.as(TIME_COLUMN)),
- );
+ const newHaving = havingExpression.removeColumnFromAnd(header);
+ if (!newHaving) return;
+ onHavingChange(newHaving);
}}
/>,
);
}
}
}
- } else {
- menuItems.push(
- <MenuItem
- key="copy_ref"
- icon={IconNames.CLIPBOARD}
- text={`Copy: ${prettyRef}`}
- onClick={() => {
- copyAndAlert(String(ref), `${prettyRef}' copied to clipboard`);
- }}
- />,
- );
-
- if (!runeMode) {
- const orderByExpression = SqlColumn.create(header);
- const descOrderBy = orderByExpression.toOrderByExpression('DESC');
- const ascOrderBy = orderByExpression.toOrderByExpression('ASC');
- const descOrderByPretty = prettyPrintSql(descOrderBy);
- const ascOrderByPretty = prettyPrintSql(descOrderBy);
-
- menuItems.push(
- <MenuItem
- key="copy_desc"
- icon={IconNames.CLIPBOARD}
- text={`Copy: ${descOrderByPretty}`}
- onClick={() =>
- copyAndAlert(descOrderBy.toString(), `'${descOrderByPretty}' copied to clipboard`)
- }
- />,
- <MenuItem
- key="copy_asc"
- icon={IconNames.CLIPBOARD}
- text={`Copy: ${ascOrderByPretty}`}
- onClick={() =>
- copyAndAlert(ascOrderBy.toString(), `'${ascOrderByPretty}' copied to clipboard`)
- }
- />,
- );
- }
}
+ menuItems.push(
+ <MenuItem
+ key="copy_ref"
+ icon={IconNames.CLIPBOARD}
+ text={`Copy: ${prettyRef}`}
+ onClick={() => {
+ copyAndAlert(String(ref), `${prettyRef}' copied to clipboard`);
+ }}
+ />,
+ );
+
return <Menu>{menuItems}</Menu>;
}
function filterOnMenuItem(icon: IconName, clause: SqlExpression, having: boolean) {
if (!parsedQuery) return;
+ const columnName = clause.getUsedColumnNames()[0];
- return (
- <MenuItem
- icon={icon}
- text={`${having ? 'Having' : 'Filter on'}: ${prettyPrintSql(clause)}`}
- onClick={() => {
- const columnName = clause.getUsedColumnNames()[0];
- onQueryAction(
- having
- ? q => q.removeFromHaving(columnName).addHaving(clause)
- : q => q.removeColumnFromWhere(columnName).addWhere(clause),
- );
- }}
- />
- );
+ if (having) {
+ const havingExpression = parsedQuery.getEffectiveHavingExpression();
+ if (!onHavingChange) return;
+ return (
+ <MenuItem
+ icon={icon}
+ text={`Having: ${prettyPrintSql(clause)}`}
+ onClick={() => {
+ onHavingChange(
+ SqlExpression.and(havingExpression.removeColumnFromAnd(columnName), clause),
+ );
+ }}
+ />
+ );
+ } else {
+ const whereExpression = parsedQuery.getEffectiveWhereExpression();
+ if (!onWhereChange) return;
+ return (
+ <MenuItem
+ icon={icon}
+ text={`Filter on: ${prettyPrintSql(clause)}`}
+ onClick={() => {
+ onWhereChange(
+ SqlExpression.and(whereExpression.removeColumnFromAnd(columnName), clause),
+ );
+ }}
+ />
+ );
+ }
}
function clipboardMenuItem(clause: SqlExpression) {
@@ -341,7 +294,7 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
icon={IconNames.EYE_OPEN}
text="Show full value"
onClick={() => {
- // ToDo: clean up
+ setShowValue(stringifyValue(value));
}}
/>
);
@@ -349,19 +302,21 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
const val = sqlLiteralForColumnValue(column, value);
if (parsedQuery) {
- let ex: SqlExpression | undefined;
+ let ex = columnHints?.get(column.name)?.expressionForWhere;
let having = false;
- if (parsedQuery.hasStarInSelect()) {
- ex = SqlColumn.create(column.name);
- } else {
- const selectValue = parsedQuery.getSelectExpressionForIndex(headerIndex);
- if (selectValue) {
- const outputName = selectValue.getOutputName();
- having = parsedQuery.isAggregateSelectIndex(headerIndex);
- if (having && outputName) {
- ex = SqlColumn.create(outputName);
- } else {
- ex = selectValue.getUnderlyingExpression();
+ if (!ex) {
+ if (parsedQuery.hasStarInSelect()) {
+ ex = SqlColumn.create(column.name);
+ } else {
+ const selectValue = parsedQuery.getSelectExpressionForIndex(headerIndex);
+ if (selectValue) {
+ const outputName = selectValue.getOutputName();
+ having = parsedQuery.isAggregateSelectIndex(headerIndex);
+ if (having && outputName) {
+ ex = SqlColumn.create(outputName);
+ } else {
+ ex = selectValue.getUnderlyingExpression();
+ }
}
}
}
@@ -395,7 +350,7 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
text={`Copy: ${trimmedValue}`}
onClick={() => copyAndAlert(stringValue, `${trimmedValue} copied to clipboard`)}
/>
- {!runeMode && val && (
+ {val && (
<>
{clipboardMenuItem(ref.equal(val))}
{clipboardMenuItem(ref.unequal(val))}
@@ -418,7 +373,7 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
}
if (parsedQuery.isAggregateOutputColumn(header)) {
- className.push('aggregate-header');
+ className.push('measure-header');
}
return className.join(' ');
@@ -514,7 +469,7 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
width: columnToWidth(column),
className:
parsedQuery && parsedQuery.isAggregateOutputColumn(h)
- ? 'aggregate-column'
+ ? 'measure-column'
: undefined,
};
}),
@@ -523,6 +478,7 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
)}
/>
)}
+ {showValue && <ShowValueDialog onClose={() => setShowValue(undefined)} str={showValue} />}
</div>
);
});
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 cf26029957c..b3c74d7553c 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/modules/components/index.ts b/web-console/src/views/explore-view/components/index.ts
similarity index 61%
copy from web-console/src/views/explore-view/modules/components/index.ts
copy to web-console/src/views/explore-view/components/index.ts
index 4b7eeff07ef..3cbeb6fe6d3 100644
--- a/web-console/src/views/explore-view/modules/components/index.ts
+++ b/web-console/src/views/explore-view/components/index.ts
@@ -16,4 +16,16 @@
* 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/modules/table-react-module.scss b/web-console/src/views/explore-view/components/issue/issue.scss
similarity index 88%
copy from web-console/src/views/explore-view/modules/table-react-module.scss
copy to web-console/src/views/explore-view/components/issue/issue.scss
index 26523b1c782..86c279bab1f 100644
--- a/web-console/src/views/explore-view/modules/table-react-module.scss
+++ b/web-console/src/views/explore-view/components/issue/issue.scss
@@ -16,11 +16,9 @@
* limitations under the License.
*/
-.table-module {
- width: 100%;
- height: 100%;
-
- .generic-output-table {
- height: 100%;
- }
+.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/filter-pane/filter-menu/filter-menu.scss b/web-console/src/views/explore-view/components/issue/issue.tsx
similarity index 67%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/views/explore-view/components/issue/issue.tsx
index dbb850df020..4374c626511 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
+++ b/web-console/src/views/explore-view/components/issue/issue.tsx
@@ -16,29 +16,22 @@
* limitations under the License.
*/
-@import '../../../../variables';
+import classNames from 'classnames';
+import React from 'react';
-.filter-menu {
- width: 400px;
+import './issue.scss';
- &.main {
- padding: 15px;
- }
-
- .controls .#{$ns}-form-content {
- display: flex;
- gap: 15px;
-
- .type-selector {
- flex: 1;
- }
- }
+export interface IssueProps {
+ className?: string;
+ issue: string;
+}
- textarea {
- width: 100%;
- }
+export const Issue = React.memo(function Issue(props: IssueProps) {
+ const { className, issue } = props;
- .button-bar {
- text-align: right;
- }
-}
+ return (
+ <div className={classNames('issue', className)}>
+ <div className="issue-inner">{issue}</div>
+ </div>
+ );
+});
diff --git a/web-console/src/views/explore-view/control-pane/columns-input.scss b/web-console/src/views/explore-view/components/module-pane/module-pane.scss
similarity index 63%
rename from web-console/src/views/explore-view/control-pane/columns-input.scss
rename to web-console/src/views/explore-view/components/module-pane/module-pane.scss
index 2f7fc37e554..d015fca9ecd 100644
--- a/web-console/src/views/explore-view/control-pane/columns-input.scss
+++ b/web-console/src/views/explore-view/components/module-pane/module-pane.scss
@@ -16,30 +16,43 @@
* limitations under the License.
*/
-@import '../../../variables';
-
-.columns-input {
- .#{$ns}-tag-input-values .#{$ns}-tag {
- &.drop-before::after {
- content: '';
- height: 24px;
- width: 1px;
+@import '../../../../variables';
+
+.module-pane {
+ position: relative;
+ @include card-like;
+
+ & > .module {
+ width: 100%;
+ height: 100%;
+ position: relative;
+
+ & > .echart-container {
position: absolute;
- left: -3px;
- top: 50%;
- transform: translate(0, -50%);
- background-color: white;
+ width: 100%;
+ height: 100%;
}
- &.drop-after::after {
- content: '';
- height: 24px;
- width: 1px;
+ & > .issue {
position: absolute;
- right: -3px;
- top: 50%;
- transform: translate(0, -50%);
- background-color: white;
+ 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 00000000000..4e216e3ac38
--- /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<string, any>,
+ parameters: Record<string, ParameterDefinition>,
+ querySource: QuerySource,
+): Record<string, any> {
+ 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<string, any>;
+ setParameterValues(parameters: Record<string, any>): void;
+ runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
+}
+
+export const ModulePane = function ModulePane(props: ModulePaneProps) {
+ const {
+ moduleId,
+ querySource,
+ where,
+ setWhere,
+ parameterValues,
+ setParameterValues,
+ runSqlQuery,
+ } = props;
+ const [stage, setStage] = useState<Stage | undefined>();
+
+ const module = ModuleRepository.getModule(moduleId);
+
+ let content: React.ReactNode;
+ if (module) {
+ const modelIssue = undefined; // AutoForm.issueWithModel(moduleTileConfig.config, module.configFields);
+ if (modelIssue) {
+ content = <Issue issue={modelIssue} />;
+ } else if (stage) {
+ content = React.createElement(module.component, {
+ stage,
+ querySource,
+ where,
+ setWhere,
+ parameterValues: fillInDefaults(parameterValues, module.parameters, querySource),
+ setParameterValues,
+ runSqlQuery,
+ });
+ }
+ } else {
+ content = <Issue issue={`Unknown module id: ${moduleId}`} />;
+ }
+
+ return (
+ <ResizeSensor
+ onResize={entries => {
+ if (entries.length !== 1) return;
+ const newStage = new Stage(entries[0].contentRect.width, entries[0].contentRect.height);
+ if (newStage.equals(stage)) return;
+ setStage(newStage);
+ }}
+ >
+ <div className="module-pane">{content}</div>
+ </ResizeSensor>
+ );
+};
diff --git a/web-console/src/views/explore-view/modules/table-react-module.scss b/web-console/src/views/explore-view/components/module-picker/module-picker.scss
similarity index 88%
copy from web-console/src/views/explore-view/modules/table-react-module.scss
copy to web-console/src/views/explore-view/components/module-picker/module-picker.scss
index 26523b1c782..0979ba3789f 100644
--- a/web-console/src/views/explore-view/modules/table-react-module.scss
+++ b/web-console/src/views/explore-view/components/module-picker/module-picker.scss
@@ -16,11 +16,10 @@
* limitations under the License.
*/
-.table-module {
- width: 100%;
- height: 100%;
+@import '../../../../variables';
- .generic-output-table {
- height: 100%;
+.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 cefb37b0f5d..d4561e1431f 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<Name extends string> {
- 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<Name extends string>(
- props: TilePickerProps<Name>,
-): 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<string>) {
- const { modules, selectedTileName, onSelectedTileNameChange, moreMenu } = props;
-
- const selectedTileManifest = modules.find(module => module.moduleName === selectedTileName);
+ const selectedTileManifest = modules.find(module => module.id === selectedModuleId);
return (
- <ButtonGroup className="tile-picker" fill>
+ <ButtonGroup className="module-picker" fill>
<Popover
className="picker-button"
minimal
@@ -53,7 +49,7 @@ export const TilePicker = React.memo(function TilePicker(props: TilePickerProps<
key={i}
icon={module.icon}
text={module.label}
- onClick={() => onSelectedTileNameChange(module.moduleName)}
+ onClick={() => onSelectedModuleIdChange(module.id)}
/>
))}
</Menu>
@@ -61,7 +57,7 @@ export const TilePicker = React.memo(function TilePicker(props: TilePickerProps<
>
<Button
icon={selectedTileManifest ? selectedTileManifest.icon : IconNames.BOX}
- text={selectedTileManifest ? selectedTileManifest.label : 'Please select something'}
+ text={selectedTileManifest ? selectedTileManifest.label : 'Select module'}
fill
minimal
rightIcon={IconNames.CARET_DOWN}
@@ -74,4 +70,4 @@ export const TilePicker = React.memo(function TilePicker(props: TilePickerProps<
)}
</ButtonGroup>
);
-}) as typeof TilePickerComponent;
+});
diff --git a/web-console/src/views/explore-view/droppable-container/droppable-container.scss b/web-console/src/views/explore-view/components/preview-pane/preview-pane.scss
similarity index 69%
rename from web-console/src/views/explore-view/droppable-container/droppable-container.scss
rename to web-console/src/views/explore-view/components/preview-pane/preview-pane.scss
index 74b8a397384..03624ee8a68 100644
--- a/web-console/src/views/explore-view/droppable-container/droppable-container.scss
+++ b/web-console/src/views/explore-view/components/preview-pane/preview-pane.scss
@@ -16,23 +16,28 @@
* limitations under the License.
*/
-@import '../../../variables';
-
-.droppable-container {
+.preview-pane {
position: relative;
- overflow: hidden;
+ display: flex;
+ flex-direction: column;
+
+ .preview-values-wrapper {
+ flex: 1;
+ position: relative;
- &.drop-hover {
- &::after {
+ .preview-values {
position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- pointer-events: none;
- border: 1px solid $druid-brand;
- border-radius: 3px;
- content: '';
+ overflow: hidden;
+ height: 100%;
+ width: 100%;
+
+ .preview-value {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ padding: 1px 0;
+ opacity: 0.8;
+ }
}
}
}
diff --git a/web-console/src/views/explore-view/components/preview-pane/preview-pane.tsx b/web-console/src/views/explore-view/components/preview-pane/preview-pane.tsx
new file mode 100644
index 00000000000..faaf9dcd65c
--- /dev/null
+++ b/web-console/src/views/explore-view/components/preview-pane/preview-pane.tsx
@@ -0,0 +1,72 @@
+/*
+ * 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 { Callout } from '@blueprintjs/core';
+import type { SqlQuery } from '@druid-toolkit/query';
+import { type QueryResult } from '@druid-toolkit/query';
+import React from 'react';
+
+import { useQueryManager } from '../../../../hooks';
+
+import './preview-pane.scss';
+
+function getPreviewValues(queryResult: QueryResult): any[] {
+ const headerNames = queryResult.getHeaderNames();
+ if (headerNames.length === 1) {
+ return queryResult.getColumnByName(headerNames[0]) || [];
+ } else {
+ return queryResult.rows[0] || [];
+ }
+}
+
+export interface PreviewPaneProps {
+ previewQuery: string | undefined;
+ runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
+}
+
+export const PreviewPane = React.memo(function PreviewPane(props: PreviewPaneProps) {
+ const { previewQuery, runSqlQuery } = props;
+
+ const [previewState] = useQueryManager({
+ query: previewQuery,
+ processQuery: runSqlQuery,
+ debounceIdle: 1000,
+ });
+
+ const previewValues = previewState.data ? getPreviewValues(previewState.data) : undefined;
+ return (
+ <Callout className="preview-pane" title="Preview">
+ {previewState.loading && 'Loading...'}
+ {previewState.error && <div className="preview-error">{previewState.getErrorMessage()}</div>}
+ {previewValues &&
+ (previewValues.length ? (
+ <div className="preview-values-wrapper">
+ <div className="preview-values">
+ {previewValues.map((v, i) => (
+ <div className="preview-value" key={i}>
+ {String(v)}
+ </div>
+ ))}
+ </div>
+ </div>
+ ) : (
+ 'No preview values'
+ ))}
+ </Callout>
+ );
+});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.scss
similarity index 62%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.scss
index dbb850df020..b1147dd1abc 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
+++ b/web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.scss
@@ -16,29 +16,42 @@
* limitations under the License.
*/
-@import '../../../../variables';
+@import '../../../../../variables';
-.filter-menu {
- width: 400px;
-
- &.main {
- padding: 15px;
+.column-dialog {
+ &.#{$bp-ns}-dialog {
+ width: 80vw;
}
- .controls .#{$ns}-form-content {
+ .#{$bp-ns}-dialog-body {
display: flex;
- gap: 15px;
+ gap: 12px;
- .type-selector {
+ .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%;
+ }
+ }
+ }
}
- }
- textarea {
- width: 100%;
+ .preview-pane {
+ width: 300px;
+ }
}
- .button-bar {
- text-align: right;
+ .#{$bp-ns}-dialog-footer {
+ margin-top: 0;
}
}
diff --git a/web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.tsx b/web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.tsx
new file mode 100644
index 00000000000..4cc4fcd674c
--- /dev/null
+++ b/web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.tsx
@@ -0,0 +1,169 @@
+/*
+ * 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, F, sql, SqlExpression, SqlQuery } from '@druid-toolkit/query';
+import React, { useMemo, useState } from 'react';
+
+import { AppToaster } from '../../../../../singletons';
+import { ExpressionMeta, QuerySource } from '../../../models';
+import type { Rename } from '../../../utils';
+import { PreviewPane } from '../../preview-pane/preview-pane';
+import { SqlInput } from '../../sql-input/sql-input';
+
+import './column-dialog.scss';
+
+export interface ColumnDialogProps {
+ initExpression: SqlExpression | undefined;
+ columnToDuplicate?: string;
+ onApply(newQuery: SqlQuery, rename: Rename | undefined): void;
+ querySource: QuerySource;
+ runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
+ onClose(): void;
+}
+
+export const ColumnDialog = React.memo(function ColumnDialog(props: ColumnDialogProps) {
+ const { initExpression, columnToDuplicate, onApply, querySource, runSqlQuery, onClose } = props;
+
+ const [outputName, setOutputName] = useState(initExpression?.getOutputName() || '');
+ const [formula, setFormula] = useState(String(initExpression?.getUnderlyingExpression() || ''));
+
+ const previewQuery = useMemo(() => {
+ const expression = SqlExpression.maybeParse(formula);
+ if (!expression) return;
+ return SqlQuery.from(QuerySource.stripToBaseSource(querySource.query))
+ .addSelect(F.cast(expression, 'VARCHAR').as('v'), { addToGroupBy: 'end' })
+ .applyIf(
+ querySource.baseColumns.find(column => column.isTimeColumn()),
+ q => q.addWhere(sql`MAX_DATA_TIME() - INTERVAL '14' DAY <= __time`),
+ )
+ .changeLimitValue(100)
+ .toString();
+ }, [querySource, formula]);
+
+ return (
+ <Dialog
+ className="column-dialog"
+ isOpen
+ onClose={onClose}
+ title={initExpression ? 'Edit column' : 'Add column'}
+ >
+ <div className={Classes.DIALOG_BODY}>
+ <div className="controls">
+ <FormGroup label="Name">
+ <InputGroup
+ value={outputName}
+ onChange={e => {
+ setOutputName(e.target.value.slice(0, ExpressionMeta.MAX_NAME_LENGTH));
+ }}
+ placeholder="Column name"
+ />
+ </FormGroup>
+ <FormGroup label="SQL expression" className="sql-expression-form-group">
+ <SqlInput
+ value={formula}
+ onValueChange={formula => {
+ setFormula(formula);
+ }}
+ columns={querySource.baseColumns}
+ placeholder="SQL expression"
+ editorHeight={400}
+ autoFocus
+ showGutter={false}
+ />
+ </FormGroup>
+ </div>
+ <PreviewPane previewQuery={previewQuery} runSqlQuery={runSqlQuery} />
+ </div>
+ <div className={Classes.DIALOG_FOOTER}>
+ <div className={Classes.DIALOG_FOOTER_ACTIONS}>
+ <div className="edit-column-dialog-buttons">
+ <Button text="Cancel" onClick={onClose} />
+ <Button
+ text="Apply"
+ intent={Intent.PRIMARY}
+ onClick={() => {
+ if (!outputName) {
+ AppToaster.show({
+ message: 'Must have a name',
+ intent: Intent.DANGER,
+ });
+ return;
+ }
+
+ if (
+ initExpression?.getOutputName() !== outputName &&
+ querySource.nameInUse(outputName)
+ ) {
+ const availableName = querySource.getAvailableName(outputName);
+ AppToaster.show({
+ message: (
+ <>
+ The name <Tag minimal>{outputName}</Tag> is already in use.
+ </>
+ ),
+ intent: Intent.WARNING,
+ action: {
+ text: (
+ <>
+ Change to <Tag minimal>{availableName}</Tag>
+ </>
+ ),
+ onClick: () => setOutputName(availableName),
+ },
+ });
+ return;
+ }
+
+ let newExpression: SqlExpression;
+ try {
+ newExpression = SqlExpression.parse(formula).as(outputName);
+ } catch (e) {
+ AppToaster.show({
+ message: e.message,
+ intent: Intent.DANGER,
+ });
+ return;
+ }
+
+ if (initExpression) {
+ const initExpressionName = initExpression.getOutputName()!;
+ if (columnToDuplicate) {
+ onApply(
+ querySource.addColumnAfter(columnToDuplicate, newExpression),
+ undefined,
+ );
+ } else {
+ onApply(
+ querySource.changeColumn(initExpressionName, newExpression),
+ new Map([[initExpression.getOutputName()!, newExpression.getOutputName()!]]),
+ );
+ }
+ } else {
+ onApply(querySource.addColumn(newExpression), undefined);
+ }
+
+ onClose();
+ }}
+ />
+ </div>
+ </div>
+ </div>
+ </Dialog>
+ );
+});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.scss
similarity index 62%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.scss
index dbb850df020..c7edf7eeafc 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
+++ b/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.scss
@@ -16,29 +16,42 @@
* limitations under the License.
*/
-@import '../../../../variables';
+@import '../../../../../variables';
-.filter-menu {
- width: 400px;
-
- &.main {
- padding: 15px;
+.measure-dialog {
+ &.#{$bp-ns}-dialog {
+ width: 80vw;
}
- .controls .#{$ns}-form-content {
+ .#{$bp-ns}-dialog-body {
display: flex;
- gap: 15px;
+ gap: 12px;
- .type-selector {
+ .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%;
+ }
+ }
+ }
}
- }
- textarea {
- width: 100%;
+ .preview-pane {
+ width: 300px;
+ }
}
- .button-bar {
- text-align: right;
+ .#{$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 00000000000..f02facf61f0
--- /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<QueryResult>;
+ 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 (
+ <Dialog
+ className="measure-dialog"
+ isOpen
+ onClose={onClose}
+ title={initMeasure ? 'Edit measure' : 'Add measure'}
+ >
+ <div className={Classes.DIALOG_BODY}>
+ <div className="controls">
+ <FormGroup label="Name">
+ <InputGroup
+ value={outputName}
+ onChange={e => {
+ setOutputName(e.target.value.slice(0, Measure.MAX_NAME_LENGTH));
+ }}
+ placeholder="Measure name"
+ />
+ </FormGroup>
+ <FormGroup label="SQL expression" className="sql-expression-form-group">
+ <SqlInput
+ value={formula}
+ onValueChange={formula => {
+ setFormula(formula);
+ }}
+ columns={querySource.baseColumns}
+ placeholder="SQL expression"
+ editorHeight={400}
+ autoFocus
+ showGutter={false}
+ />
+ </FormGroup>
+ </div>
+ <PreviewPane previewQuery={previewQuery} runSqlQuery={runSqlQuery} />
+ </div>
+ <div className={Classes.DIALOG_FOOTER}>
+ <div className={Classes.DIALOG_FOOTER_ACTIONS}>
+ <div className="edit-column-dialog-buttons">
+ <Button text="Cancel" onClick={onClose} />
+ <Button
+ text="Apply"
+ intent={Intent.PRIMARY}
+ onClick={() => {
+ if (!outputName) {
+ AppToaster.show({
+ message: 'Must have a name',
+ intent: Intent.DANGER,
+ });
+ return;
+ }
+
+ if (initMeasure?.name !== outputName && querySource.nameInUse(outputName)) {
+ const availableName = querySource.getAvailableName(outputName);
+ AppToaster.show({
+ message: (
+ <>
+ The name <Tag minimal>{outputName}</Tag> is already in use.
+ </>
+ ),
+ intent: Intent.WARNING,
+ action: {
+ text: (
+ <>
+ Change to <Tag minimal>{availableName}</Tag>
+ </>
+ ),
+ onClick: () => setOutputName(availableName),
+ },
+ });
+ return;
+ }
+
+ let newExpression: SqlExpression;
+ try {
+ newExpression = SqlExpression.parse(formula);
+ } catch (e) {
+ AppToaster.show({
+ message: e.message,
+ intent: Intent.DANGER,
+ });
+ return;
+ }
+
+ const newMeasure: Measure = new Measure({
+ as: outputName,
+ expression: newExpression,
+ });
+
+ if (initMeasure) {
+ const initMeasureName = initMeasure.name;
+ if (measureToDuplicate) {
+ onApply(querySource.addMeasureAfter(measureToDuplicate, newMeasure), undefined);
+ } else {
+ onApply(
+ querySource.changeMeasure(initMeasureName, newMeasure),
+ new Map([[initMeasureName, newExpression.getOutputName()!]]),
+ );
+ }
+ } else {
+ onApply(querySource.addMeasure(newMeasure), undefined);
+ }
+
+ onClose();
+ }}
+ />
+ </div>
+ </div>
+ </div>
+ </Dialog>
+ );
+});
diff --git a/web-console/src/views/explore-view/resource-pane/resource-pane.scss b/web-console/src/views/explore-view/components/resource-pane/resource-pane.scss
similarity index 61%
copy from web-console/src/views/explore-view/resource-pane/resource-pane.scss
copy to web-console/src/views/explore-view/components/resource-pane/resource-pane.scss
index 81395dae874..89a16606263 100644
--- a/web-console/src/views/explore-view/resource-pane/resource-pane.scss
+++ b/web-console/src/views/explore-view/components/resource-pane/resource-pane.scss
@@ -16,22 +16,48 @@
* limitations under the License.
*/
+@import '../../../../variables';
+
.resource-pane {
- position: relative;
+ display: flex;
+ flex-direction: column;
.search-input {
margin: 4px;
}
- .resource-items {
- position: absolute;
- top: 38px;
- bottom: 0;
- left: 0;
- right: 0;
+ .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;
- .resource-item {
+ .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 00000000000..f6489eb17e2
--- /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<QueryResult>;
+}
+
+export const ResourcePane = function ResourcePane(props: ResourcePaneProps) {
+ const { querySource, onQueryChange, onFilter, onShowColumn, onShowMeasure, runSqlQuery } = props;
+ const [columnSearch, setColumnSearch] = useState('');
+
+ const [columnEditorOpenOn, setColumnEditorOpenOn] = useState<ColumnEditorOpenOn | undefined>();
+ const [measureEditorOpenOn, setMeasureEditorOpenOn] = useState<MeasureEditorOpenOn | undefined>();
+
+ function applyUtil(nameTransform: (columnName: string) => string) {
+ if (!querySource) return;
+ const columnNameMap = querySource.getColumnNameMap(nameTransform);
+ onQueryChange(querySource.applyColumnNameMap(columnNameMap), columnNameMap);
+ }
+
+ return (
+ <div className="resource-pane">
+ <ClearableInput
+ className="search-input"
+ value={columnSearch}
+ onChange={setColumnSearch}
+ placeholder="Search"
+ />
+ <div className="list-header column-list-header">
+ Columns
+ <ButtonGroup className="header-buttons" minimal>
+ <Button
+ icon={IconNames.PLUS}
+ title="Add column"
+ onClick={() => setColumnEditorOpenOn({})}
+ />
+ <Popover
+ content={
+ <Menu>
+ <MenuItem
+ text="Uppercase columns"
+ onClick={() => applyUtil(x => x.toUpperCase())}
+ />
+ <MenuItem
+ text="Lowercase columns"
+ onClick={() => applyUtil(x => x.toLowerCase())}
+ />
+ </Menu>
+ }
+ >
+ <Button icon={IconNames.MORE} />
+ </Popover>
+ </ButtonGroup>
+ </div>
+ <div className="column-resource-list">
+ {filterMap(querySource.columns, (column, i) => {
+ const columnName = column.name;
+ if (!caseInsensitiveContains(columnName, columnSearch)) return;
+ return (
+ <Popover
+ className="column-resource"
+ key={i}
+ position="right"
+ content={
+ <Menu>
+ {onFilter && (
+ <MenuItem
+ icon={IconNames.FILTER}
+ text="Filter"
+ onClick={() => onFilter(column)}
+ />
+ )}
+ <MenuItem
+ icon={IconNames.EYE_OPEN}
+ text="Show"
+ onClick={() => onShowColumn(column)}
+ />
+ <MenuDivider />
+ <MenuItem
+ icon={IconNames.EDIT}
+ text="Edit"
+ onClick={() =>
+ setColumnEditorOpenOn({
+ expression: querySource.getSourceExpressionForColumn(columnName),
+ })
+ }
+ />
+ <MenuItem
+ icon={IconNames.DUPLICATE}
+ text="Duplicate"
+ onClick={() =>
+ setColumnEditorOpenOn({
+ columnToDuplicate: columnName,
+ expression: querySource
+ .getSourceExpressionForColumn(columnName)
+ .as(querySource.getAvailableName(columnName)),
+ })
+ }
+ />
+ <MenuItem
+ icon={IconNames.TRASH}
+ text="Delete"
+ intent={Intent.DANGER}
+ onClick={() => onQueryChange(querySource.deleteColumn(columnName), undefined)}
+ />
+ </Menu>
+ }
+ >
+ <div
+ className={Classes.MENU_ITEM}
+ draggable
+ onDragStart={e => {
+ e.dataTransfer.effectAllowed = 'all';
+ DragHelper.dragColumn = column;
+ DragHelper.createDragGhost(e.dataTransfer, columnName);
+ }}
+ >
+ <Icon
+ className={Classes.MENU_ITEM_ICON}
+ icon={columnToIcon(column) || IconNames.BLANK}
+ data-tooltip={`${columnName}\nSQL type: ${column.sqlType}\nNative type: ${column.nativeType}`}
+ />
+ <div className={classNames(Classes.FILL, Classes.TEXT_OVERFLOW_ELLIPSIS)}>
+ {columnName}
+ </div>
+ </div>
+ </Popover>
+ );
+ })}
+ </div>
+ <div className="list-header measure-list-header">
+ Measures
+ <ButtonGroup className="header-buttons" minimal>
+ <Button
+ icon={IconNames.PLUS}
+ title="Add measure"
+ onClick={() => setMeasureEditorOpenOn({})}
+ />
+ </ButtonGroup>
+ </div>
+ <div className="measure-resource-list">
+ {filterMap(querySource.measures, (measure, i) => {
+ const measureName = measure.name;
+ if (!caseInsensitiveContains(measureName, columnSearch)) return;
+ return (
+ <Popover
+ className="measure-resource"
+ key={i}
+ position="right"
+ content={
+ <Menu>
+ <MenuItem
+ icon={IconNames.EYE_OPEN}
+ text="Show"
+ onClick={() => onShowMeasure(measure)}
+ />
+ <MenuDivider />
+ <MenuItem
+ icon={IconNames.EDIT}
+ text="Edit"
+ onClick={() =>
+ setMeasureEditorOpenOn({
+ measure,
+ })
+ }
+ />
+ <MenuItem
+ icon={IconNames.DUPLICATE}
+ text="Duplicate"
+ onClick={() =>
+ setMeasureEditorOpenOn({
+ measureToDuplicate: measureName,
+ measure: measure.changeAs(querySource.getAvailableName(measureName)),
+ })
+ }
+ />
+ <MenuItem
+ icon={IconNames.TRASH}
+ text="Delete"
+ intent={Intent.DANGER}
+ onClick={() => onQueryChange(querySource.deleteMeasure(measureName), undefined)}
+ />
+ </Menu>
+ }
+ >
+ <div
+ className={Classes.MENU_ITEM}
+ draggable
+ onDragStart={e => {
+ e.dataTransfer.effectAllowed = 'all';
+ DragHelper.dragMeasure = measure.toAggregateBasedMeasure();
+ DragHelper.createDragGhost(e.dataTransfer, measure.name);
+ }}
+ >
+ <Icon className={Classes.MENU_ITEM_ICON} icon={IconNames.PULSE} />
+ <div className={classNames(Classes.FILL, Classes.TEXT_OVERFLOW_ELLIPSIS)}>
+ {measureName}
+ </div>
+ </div>
+ </Popover>
+ );
+ })}
+ </div>
+ {columnEditorOpenOn && (
+ <ColumnDialog
+ initExpression={columnEditorOpenOn.expression}
+ columnToDuplicate={columnEditorOpenOn.columnToDuplicate}
+ onApply={onQueryChange}
+ querySource={querySource}
+ runSqlQuery={runSqlQuery}
+ onClose={() => setColumnEditorOpenOn(undefined)}
+ />
+ )}
+ {measureEditorOpenOn && (
+ <MeasureDialog
+ initMeasure={measureEditorOpenOn.measure}
+ measureToDuplicate={measureEditorOpenOn.measureToDuplicate}
+ onApply={onQueryChange}
+ querySource={querySource}
+ runSqlQuery={runSqlQuery}
+ onClose={() => setMeasureEditorOpenOn(undefined)}
+ />
+ )}
+ </div>
+ );
+};
diff --git a/web-console/src/views/explore-view/modules/components/index.ts b/web-console/src/views/explore-view/components/source-pane/source-pane.scss
similarity index 92%
copy from web-console/src/views/explore-view/modules/components/index.ts
copy to web-console/src/views/explore-view/components/source-pane/source-pane.scss
index 4b7eeff07ef..e10b702cc48 100644
--- a/web-console/src/views/explore-view/modules/components/index.ts
+++ b/web-console/src/views/explore-view/components/source-pane/source-pane.scss
@@ -16,4 +16,7 @@
* limitations under the License.
*/
-export * from './generic-output-table/generic-output-table';
+.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 2208bc32a39..1524b24540f 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<string, string[]>({
+ const [tables] = useQueryManager<string, string[]>({
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={
- <Menu className="source-menu">
- {sources.loading && <MenuDivider title="Loading..." />}
- {sources.data?.map((s, i) => (
- <MenuItem key={i} text={s} onClick={() => onSelectedTableNameChange(s)} />
+ <Menu className="source-pane-menu">
+ {onShowSourceQuery && (
+ <MenuItem text="Show source query..." onClick={onShowSourceQuery} />
+ )}
+ {onShowSourceQuery && <MenuDivider />}
+ {tables.loading && <MenuDivider title="Loading..." />}
+ {tables.data?.map((table, i) => (
+ <MenuItem key={i} text={table} onClick={() => onSelectTable(table)} />
))}
- {!sources.data?.length && <MenuItem text="No sources" disabled />}
+ {!tables.data?.length && <MenuItem text="No tables" disabled />}
</Menu>
}
>
<Button
- text={`Source: ${selectedTableName}`}
+ icon={IconNames.TH}
+ text={formatQuerySource(selectedSource)}
rightIcon={IconNames.CARET_DOWN}
- fill
- minimal
+ fill={fill}
+ minimal={minimal}
disabled={disabled}
/>
</Popover>
diff --git a/web-console/src/views/explore-view/resource-pane/resource-pane.scss b/web-console/src/views/explore-view/components/source-query-pane/source-query-pane.scss
similarity index 73%
rename from web-console/src/views/explore-view/resource-pane/resource-pane.scss
rename to web-console/src/views/explore-view/components/source-query-pane/source-query-pane.scss
index 81395dae874..075dba60bd5 100644
--- a/web-console/src/views/explore-view/resource-pane/resource-pane.scss
+++ b/web-console/src/views/explore-view/components/source-query-pane/source-query-pane.scss
@@ -16,23 +16,31 @@
* limitations under the License.
*/
-.resource-pane {
+.source-query-pane {
position: relative;
- .search-input {
- margin: 4px;
+ .column-tree {
+ position: absolute;
+ width: 200px;
+ height: 100%;
}
- .resource-items {
+ .flexible-query-input {
position: absolute;
- top: 38px;
- bottom: 0;
- left: 0;
+ left: 204px;
right: 0;
- overflow: auto;
+ top: 0;
+ bottom: 42px;
+ }
- .resource-item {
- display: block;
- }
+ .button-bar {
+ position: absolute;
+ height: 34px;
+ left: 204px;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
}
}
diff --git a/web-console/src/views/explore-view/components/source-query-pane/source-query-pane.tsx b/web-console/src/views/explore-view/components/source-query-pane/source-query-pane.tsx
new file mode 100644
index 00000000000..dd3fbbd41dd
--- /dev/null
+++ b/web-console/src/views/explore-view/components/source-query-pane/source-query-pane.tsx
@@ -0,0 +1,83 @@
+/*
+ * 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, Intent } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import React, { useEffect, useState } from 'react';
+
+import { useQueryManager } from '../../../../hooks';
+import { type ColumnMetadata, queryDruidSql } from '../../../../utils';
+import { ColumnTree } from '../../../workbench-view/column-tree/column-tree';
+import { FlexibleQueryInput } from '../../../workbench-view/flexible-query-input/flexible-query-input';
+
+import './source-query-pane.scss';
+
+export interface SourceQueryPaneProps {
+ source: string;
+ onSourceChange(newSource: string): void;
+ onClose(): void;
+}
+
+export const SourceQueryPane = React.memo(function SourceQueryPane(props: SourceQueryPaneProps) {
+ const { source, onSourceChange, onClose } = props;
+ const [queryString, setQueryString] = useState(source?.toString() || '');
+
+ useEffect(() => {
+ if (source) {
+ setQueryString(source);
+ }
+ }, [source]);
+
+ const [columnMetadataState] = useQueryManager({
+ initQuery: '',
+ processQuery: async () => {
+ return await queryDruidSql<ColumnMetadata>({
+ query: `SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS`,
+ });
+ },
+ });
+
+ return (
+ <div className="source-query-pane">
+ {!columnMetadataState.isError() && (
+ <ColumnTree
+ getParsedQuery={() => undefined}
+ columnMetadataLoading={columnMetadataState.loading}
+ columnMetadata={columnMetadataState.data}
+ onQueryChange={q => setQueryString(String(q))}
+ />
+ )}
+ <FlexibleQueryInput
+ queryString={queryString}
+ onQueryStringChange={setQueryString}
+ columnMetadata={columnMetadataState.data}
+ />
+ <div className="button-bar">
+ <Button
+ intent={Intent.PRIMARY}
+ icon={IconNames.CARET_RIGHT}
+ text="Set source query"
+ onClick={() => {
+ onSourceChange(queryString);
+ }}
+ />
+ <Button text="Hide" onClick={() => onClose()} />
+ </div>
+ </div>
+ );
+});
diff --git a/web-console/src/views/explore-view/components/sql-input/sql-input.tsx b/web-console/src/views/explore-view/components/sql-input/sql-input.tsx
new file mode 100644
index 00000000000..80534d36d6b
--- /dev/null
+++ b/web-console/src/views/explore-view/components/sql-input/sql-input.tsx
@@ -0,0 +1,167 @@
+/*
+ * 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 { C } from '@druid-toolkit/query';
+import type { Ace } from 'ace-builds';
+import ace from 'ace-builds';
+import React from 'react';
+import AceEditor from 'react-ace';
+
+import type { RowColumn } from '../../../../utils';
+import { uniq } from '../../../../utils';
+
+const langTools = ace.require('ace/ext/language_tools');
+
+const V_PADDING = 10;
+
+export interface SqlInputProps {
+ value: string;
+ onValueChange?: (newValue: string) => void;
+ placeholder?: string;
+ editorHeight?: number;
+ columns?: readonly Column[];
+ autoFocus?: boolean;
+ showGutter?: boolean;
+}
+
+export interface SqlInputState {
+ quotedCompletions: Ace.Completion[];
+ unquotedCompletions: Ace.Completion[];
+ prevColumns?: readonly Column[];
+}
+
+export class SqlInput extends React.PureComponent<SqlInputProps, SqlInputState> {
+ static aceTheme = 'solarized_dark';
+
+ private aceEditor: Ace.Editor | undefined;
+
+ private readonly aceCompleters: Ace.Completer[] = [
+ // Prepend with default completers to ensure completion data from
+ // editing mode (e.g. 'dsql') is included in addition to local completions
+ langTools.snippetCompleter,
+ langTools.keyWordCompleter,
+ langTools.textCompleter,
+ // Local completions
+ {
+ getCompletions: (_state, session, pos, prefix, callback) => {
+ const charBeforePrefix = session.getLine(pos.row)[pos.column - prefix.length - 1];
+ callback(
+ null,
+ charBeforePrefix === '"' ? this.state.unquotedCompletions : this.state.quotedCompletions,
+ );
+ },
+ },
+ ];
+
+ static getCompletions(columns: readonly Column[], quote: boolean): Ace.Completion[] {
+ return ([] as Ace.Completion[]).concat(
+ uniq(columns.map(column => column.name)).map(v => ({
+ value: quote ? String(C(v)) : v,
+ score: 50,
+ meta: 'column',
+ })),
+ );
+ }
+
+ static getDerivedStateFromProps(
+ props: SqlInputProps,
+ state: SqlInputState,
+ ): Partial<SqlInputState> | null {
+ const { columns } = props;
+
+ if (columns && columns !== state.prevColumns) {
+ return {
+ quotedCompletions: SqlInput.getCompletions(columns, true),
+ unquotedCompletions: SqlInput.getCompletions(columns, false),
+ prevColumns: columns,
+ };
+ }
+ return null;
+ }
+
+ constructor(props: SqlInputProps) {
+ super(props);
+ this.state = {
+ quotedCompletions: [],
+ unquotedCompletions: [],
+ };
+ }
+
+ componentWillUnmount() {
+ delete this.aceEditor;
+ }
+
+ private readonly handleChange = (value: string) => {
+ const { onValueChange } = this.props;
+ if (!onValueChange) return;
+ onValueChange(value);
+ };
+
+ public goToPosition(rowColumn: RowColumn) {
+ const { aceEditor } = this;
+ if (!aceEditor) return;
+ aceEditor.focus(); // Grab the focus
+ aceEditor.getSelection().moveCursorTo(rowColumn.row, rowColumn.column);
+ // If we had an end we could also do
+ // aceEditor.getSelection().selectToPosition({ row: endRow, column: endColumn });
+ }
+
+ render() {
+ const { value, onValueChange, placeholder, autoFocus, editorHeight, showGutter } = this.props;
+
+ return (
+ <AceEditor
+ mode="dsql"
+ theme={SqlInput.aceTheme}
+ className="sql-input placeholder-padding"
+ // 'react-ace' types are incomplete. Completion options can accept completers array.
+ enableBasicAutocompletion={this.aceCompleters as any}
+ enableLiveAutocompletion={this.aceCompleters as any}
+ name="ace-editor"
+ onChange={this.handleChange}
+ focus
+ fontSize={12}
+ width="100%"
+ height={editorHeight ? `${editorHeight}px` : '100%'}
+ showGutter={Boolean(showGutter)}
+ showPrintMargin={false}
+ tabSize={2}
+ value={value}
+ readOnly={!onValueChange}
+ editorProps={{
+ $blockScrolling: Infinity,
+ }}
+ setOptions={{
+ showLineNumbers: true,
+ newLineMode: 'unix' as any, // This type is specified incorrectly in AceEditor
+ }}
+ placeholder={placeholder || 'SELECT * FROM ...'}
+ onLoad={(editor: Ace.Editor) => {
+ editor.renderer.setPadding(V_PADDING);
+ editor.renderer.setScrollMargin(V_PADDING, V_PADDING, 0, 0);
+ this.aceEditor = editor;
+
+ if (autoFocus) {
+ editor.focus();
+ }
+ }}
+ />
+ );
+ }
+}
diff --git a/web-console/src/views/explore-view/components/utc-date-input/utc-date-input.tsx b/web-console/src/views/explore-view/components/utc-date-input/utc-date-input.tsx
new file mode 100644
index 00000000000..216dcaa2a18
--- /dev/null
+++ b/web-console/src/views/explore-view/components/utc-date-input/utc-date-input.tsx
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { InputGroup } from '@blueprintjs/core';
+import React, { useState } from 'react';
+
+function utcParseDate(dateString: string): Date | undefined {
+ const dateParts = dateString.split(/[-T:. ]/g);
+
+ // Extract the individual date and time components
+ const year = parseInt(dateParts[0], 10);
+ if (!(1000 < year && year < 4000)) return;
+
+ const month = parseInt(dateParts[1], 10);
+ if (month > 12) return;
+
+ const day = parseInt(dateParts[2], 10);
+ if (day > 31) return;
+
+ const hour = parseInt(dateParts[3], 10);
+ if (hour > 23) return;
+
+ const minute = parseInt(dateParts[4], 10);
+ if (minute > 59) return;
+
+ const second = parseInt(dateParts[5], 10);
+ if (second > 59) return;
+
+ const millisecond = parseInt(dateParts[6], 10);
+ if (millisecond >= 1000) return;
+
+ const value = Date.UTC(year, month - 1, day, hour, minute, second); // Month is zero-based
+ if (isNaN(value)) return;
+
+ return new Date(value);
+}
+
+function normalizeDateString(dateString: string): string {
+ return dateString.replace(/[^\-0-9T:./Z ]/g, '');
+}
+
+function formatDate(date: Date): string {
+ return date.toISOString().replace(/Z$/, '').replace('.000', '').replace(/T/g, ' ');
+}
+
+export interface UtcDateInputProps {
+ date: Date;
+ onChange(newDate: Date): void;
+}
+
+export function UtcDateInput(props: UtcDateInputProps) {
+ const { date, onChange } = props;
+ const [dateString, setDateString] = useState<string | undefined>();
+
+ return (
+ <InputGroup
+ placeholder="yyyy-MM-dd HH:mm:ss"
+ value={dateString ?? formatDate(date)}
+ onChange={e => {
+ const v = normalizeDateString(e.target.value);
+ const parsedDate = utcParseDate(v);
+ if (parsedDate && formatDate(parsedDate) === v) {
+ onChange(parsedDate);
+ setDateString(undefined);
+ } else {
+ setDateString(v);
+ }
+ }}
+ />
+ );
+}
diff --git a/web-console/src/views/explore-view/control-pane/aggregate-menu.tsx b/web-console/src/views/explore-view/control-pane/aggregate-menu.tsx
deleted file mode 100644
index a91ac6374b7..00000000000
--- a/web-console/src/views/explore-view/control-pane/aggregate-menu.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- * 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 { InputGroup, Menu, MenuItem } from '@blueprintjs/core';
-import { SqlFunction } from '@druid-toolkit/query';
-import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
-import React, { useState } from 'react';
-
-import { caseInsensitiveContains } from '../../../utils';
-
-import { getPossibleAggregateForColumn } from './helpers';
-
-import './aggregate-menu.scss';
-
-const COUNT_AGG: ExpressionMeta = {
- name: 'Count',
- expression: SqlFunction.count(),
- sqlType: 'BIGINT',
-};
-
-export interface AggregateMenuProps {
- columns: ExpressionMeta[];
- onSelectAggregate(aggregate: ExpressionMeta): void;
- onSelectNone?: () => void;
- shouldDismissPopover?: boolean;
-}
-
-export const AggregateMenu = function AggregateMenu(props: AggregateMenuProps) {
- const { columns, onSelectAggregate, onSelectNone, shouldDismissPopover } = props;
- const [columnSearch, setColumnSearch] = useState('');
-
- return (
- <div className="aggregate-menu">
- <InputGroup
- className="search-input"
- value={columnSearch}
- onChange={e => setColumnSearch(e.target.value)}
- placeholder="Search..."
- autoFocus
- />
- <Menu className="inner-menu">
- {onSelectNone && (
- <MenuItem
- text="None"
- onClick={onSelectNone}
- shouldDismissPopover={shouldDismissPopover}
- />
- )}
- <MenuItem text={COUNT_AGG.name} onClick={() => onSelectAggregate(COUNT_AGG)} />
- {columns.map((c, i) => {
- if (!caseInsensitiveContains(c.name, columnSearch)) return;
- const possibleAggregateForColumn = getPossibleAggregateForColumn(c);
- if (!possibleAggregateForColumn.length) return;
- if (possibleAggregateForColumn.length === 1) {
- const a = possibleAggregateForColumn[0];
- return <MenuItem key={i} text={a.name} onClick={() => onSelectAggregate(a)} />;
- } else {
- return (
- <MenuItem key={i} text={`${c.name}...`}>
- {possibleAggregateForColumn.map((a, j) => (
- <MenuItem key={j} text={a.name} onClick={() => onSelectAggregate(a)} />
- ))}
- </MenuItem>
- );
- }
- })}
- </Menu>
- </div>
- );
-};
diff --git a/web-console/src/views/explore-view/control-pane/aggregates-input.tsx b/web-console/src/views/explore-view/control-pane/aggregates-input.tsx
deleted file mode 100644
index a2b2485e22f..00000000000
--- a/web-console/src/views/explore-view/control-pane/aggregates-input.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * 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 { 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 React from 'react';
-
-import { AggregateMenu } from './aggregate-menu';
-
-export interface AggregatesInputProps {
- columns: ExpressionMeta[];
- value: ExpressionMeta[];
- onValueChange(value: ExpressionMeta[]): void;
- allowDuplicates?: boolean;
-}
-
-export const AggregatesInput = function AggregatesInput(props: AggregatesInputProps) {
- const { columns, value, onValueChange, allowDuplicates } = props;
-
- const availableColumn = allowDuplicates
- ? columns
- : columns.filter(o => !value.find(_ => _.name === o.name));
-
- return (
- <div className={classNames('aggregates-input', Classes.INPUT, Classes.TAG_INPUT, Classes.FILL)}>
- <div className={Classes.TAG_INPUT_VALUES}>
- {value.map((c, i) => (
- <Tag
- interactive
- key={i}
- onRemove={() => {
- onValueChange(value.filter(v => v !== c));
- }}
- >
- {c.name}
- </Tag>
- ))}
- <Popover
- position={Position.BOTTOM}
- content={
- <AggregateMenu
- columns={availableColumn}
- onSelectAggregate={c => onValueChange(value.concat(c))}
- />
- }
- >
- <Tag icon={IconNames.PLUS} interactive />
- </Popover>
- </div>
- </div>
- );
-};
diff --git a/web-console/src/views/explore-view/control-pane/control-pane.tsx b/web-console/src/views/explore-view/control-pane/control-pane.tsx
deleted file mode 100644
index 029f020cd76..00000000000
--- a/web-console/src/views/explore-view/control-pane/control-pane.tsx
+++ /dev/null
@@ -1,348 +0,0 @@
-/*
- * 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,
- InputGroup,
- Intent,
- Menu,
- MenuItem,
- NumericInput,
- Popover,
-} from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
-import type {
- ExpressionMeta,
- OptionValue,
- ParameterDefinition,
- RegisteredVisualModule,
-} from '@druid-toolkit/visuals-core';
-import { getPluginOptionLabel } from '@druid-toolkit/visuals-core';
-import type { JSX } from 'react';
-import React from 'react';
-
-import { AutoForm, FormGroupWithInfo, PopoverText } from '../../../components';
-import { AppToaster } from '../../../singletons';
-import { ColumnPickerMenu } from '../column-picker-menu/column-picker-menu';
-import { DroppableContainer } from '../droppable-container/droppable-container';
-
-import { AggregateMenu } from './aggregate-menu';
-import { ColumnsInput } from './columns-input';
-import { getPossibleAggregateForColumn } from './helpers';
-import { OptionsInput } from './options-input';
-
-import './control-pane.scss';
-
-export interface ControlPaneProps {
- columns: ExpressionMeta[];
- onUpdateParameterValues(params: Record<string, unknown>): void;
- parameterValues: Record<string, unknown>;
- visualModule: RegisteredVisualModule;
-}
-
-export const ControlPane = function ControlPane(props: ControlPaneProps) {
- const { columns, onUpdateParameterValues, parameterValues, visualModule } = props;
-
- function renderOptionsPropInput(
- parameter: ParameterDefinition,
- value: any,
- onValueChange: (value: any) => void,
- ): {
- element: JSX.Element;
- onDropColumn?: (column: ExpressionMeta) => void;
- } {
- switch (parameter.type) {
- case 'boolean': {
- return {
- element: (
- <ButtonGroup>
- <Button
- active={value === false}
- onClick={() => {
- onValueChange(false);
- }}
- >
- False
- </Button>
- <Button
- active={value === true}
- onClick={() => {
- onValueChange(true);
- }}
- >
- True
- </Button>
- </ButtonGroup>
- ),
- };
- }
-
- case 'number':
- return {
- element: (
- <NumericInput
- value={(value as string) ?? ''}
- onValueChange={v => onValueChange(v)}
- placeholder={parameter.control?.placeholder}
- fill
- min={parameter.min}
- max={parameter.max}
- />
- ),
- };
-
- case 'string':
- return {
- element: (
- <InputGroup
- value={(value as string) || ''}
- onChange={e => 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: (
- <Popover
- fill
- position="bottom-left"
- minimal
- content={
- <Menu>
- {controlOptions.map((o, i) => (
- <MenuItem
- key={i}
- text={getPluginOptionLabel(o, parameter)}
- onClick={() => onValueChange(o)}
- />
- ))}
- </Menu>
- }
- >
- <InputGroup
- value={
- typeof selectedOption === 'undefined'
- ? String(value)
- : getPluginOptionLabel(selectedOption, parameter)
- }
- readOnly
- fill
- rightElement={<Button icon={IconNames.CARET_DOWN} minimal />}
- />
- </Popover>
- ),
- };
- }
-
- case 'options': {
- return {
- element: (
- <OptionsInput
- options={parameter.options || []}
- value={(value as OptionValue[]) || []}
- onValueChange={onValueChange}
- parameter={parameter}
- />
- ),
- };
- }
-
- case 'column':
- return {
- element: (
- <Popover
- fill
- position="bottom-left"
- minimal
- content={
- <ColumnPickerMenu
- columns={columns}
- onSelectNone={
- parameter.control?.required ? undefined : () => onValueChange(undefined)
- }
- onSelectColumn={onValueChange}
- />
- }
- >
- <InputGroup
- value={(value as ExpressionMeta)?.name || 'None'}
- readOnly
- fill
- rightElement={<Button icon={IconNames.CARET_DOWN} minimal />}
- />
- </Popover>
- ),
- onDropColumn: onValueChange,
- };
-
- case 'columns': {
- return {
- element: (
- <ColumnsInput
- columns={columns}
- allowReordering
- value={(value as ExpressionMeta[]) || []}
- onValueChange={onValueChange}
- allowDuplicates={parameter.allowDuplicates}
- />
- ),
- 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: (
- <Popover
- fill
- position="bottom-left"
- minimal
- content={
- <AggregateMenu
- columns={columns}
- onSelectAggregate={onValueChange}
- onSelectNone={
- parameter.control?.required ? undefined : () => onValueChange(undefined)
- }
- />
- }
- >
- <InputGroup
- value={value ? (value as { name: string }).name : 'None'}
- readOnly
- fill
- rightElement={<Button icon={IconNames.CARET_DOWN} minimal />}
- />
- </Popover>
- ),
- onDropColumn: column => {
- const aggregates = getPossibleAggregateForColumn(column);
- if (!aggregates.length) return;
- onValueChange(aggregates[0]);
- },
- };
- }
-
- case 'aggregates': {
- return {
- element: (
- <ColumnsInput
- columns={columns}
- value={(value as ExpressionMeta[]) || []}
- onValueChange={onValueChange}
- allowReordering
- pickerMenu={availableColumns => (
- <AggregateMenu
- columns={availableColumns}
- onSelectAggregate={c => 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: (
- <Button
- icon={IconNames.ERROR}
- text={`Type not supported: ${(parameter as { type: string }).type}`}
- disabled
- fill
- />
- ),
- };
- }
- }
-
- const namedParameters = Object.entries(visualModule.parameterDefinitions ?? {});
-
- return (
- <div className="control-pane">
- {namedParameters.map(([name, parameter], i) => {
- const visible = parameter.control?.visible;
- if (
- visible === false ||
- (typeof visible === 'function' && !visible({ params: parameterValues }))
- ) {
- return;
- }
-
- const value = parameterValues[name];
- function onValueChange(newValue: unknown) {
- onUpdateParameterValues({ [name]: newValue });
- }
-
- const { element, onDropColumn } = renderOptionsPropInput(parameter, value, onValueChange);
-
- const formGroup = (
- <FormGroupWithInfo
- key={i}
- label={parameter.control?.label || AutoForm.makeLabelName(name)}
- info={
- parameter.control?.description ? (
- <PopoverText>{parameter.control.description}</PopoverText>
- ) : undefined
- }
- >
- {element}
- </FormGroupWithInfo>
- );
-
- if (!onDropColumn) {
- return formGroup;
- }
-
- return (
- <DroppableContainer key={i} onDropColumn={onDropColumn}>
- {formGroup}
- </DroppableContainer>
- );
- })}
- </div>
- );
-};
diff --git a/web-console/src/views/explore-view/control-pane/helpers.ts b/web-console/src/views/explore-view/control-pane/helpers.ts
deleted file mode 100644
index 36d011190b2..00000000000
--- a/web-console/src/views/explore-view/control-pane/helpers.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-/*
- * 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 { C, F, SqlFunction } from '@druid-toolkit/query';
-import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
-
-export function getPossibleAggregateForColumn(column: ExpressionMeta): ExpressionMeta[] {
- switch (column.sqlType) {
- case 'TIMESTAMP':
- return [
- {
- name: `Max ${column.name}`,
- expression: F.max(C(column.name)),
- sqlType: column.sqlType,
- },
- {
- name: `Min ${column.name}`,
- expression: F.min(C(column.name)),
- sqlType: column.sqlType,
- },
- ];
-
- case 'BIGINT':
- case 'FLOAT':
- case 'DOUBLE':
- return [
- {
- name: `Sum ${column.name}`,
- expression: F.sum(C(column.name)),
- sqlType: column.sqlType,
- },
- {
- name: `Max ${column.name}`,
- expression: F.max(C(column.name)),
- sqlType: column.sqlType,
- },
- {
- name: `Min ${column.name}`,
- expression: F.min(C(column.name)),
- sqlType: column.sqlType,
- },
- {
- name: `Unique ${column.name}`,
- expression: SqlFunction.countDistinct(C(column.name)),
- sqlType: 'BIGINT',
- },
- {
- name: `P98 ${column.name}`,
- expression: F('APPROX_QUANTILE_DS', C(column.name), 0.98),
- sqlType: 'DOUBLE',
- },
- ];
-
- case 'VARCHAR':
- case 'COMPLEX':
- case 'COMPLEX<hyperUnique>':
- return [
- {
- name: `Unique ${column.name}`,
- expression: SqlFunction.countDistinct(C(column.name)),
- sqlType: 'BIGINT',
- },
- ];
-
- case 'COMPLEX<HLLSketch>':
- return [
- {
- name: `Unique ${column.name}`,
- expression: F('APPROX_COUNT_DISTINCT_DS_HLL', C(column.name)),
- sqlType: 'BIGINT',
- },
- ];
-
- case 'COMPLEX<quantilesDoublesSketch>':
- return [
- {
- name: `Median ${column.name}`,
- expression: F('APPROX_QUANTILE_DS', C(column.name), 0.5),
- sqlType: 'DOUBLE',
- },
- {
- name: `P95 ${column.name}`,
- expression: F('APPROX_QUANTILE_DS', C(column.name), 0.95),
- sqlType: 'DOUBLE',
- },
- {
- name: `P98 ${column.name}`,
- expression: F('APPROX_QUANTILE_DS', C(column.name), 0.98),
- sqlType: 'DOUBLE',
- },
- ];
-
- default:
- return [];
- }
-}
diff --git a/web-console/src/views/explore-view/drag-helper.ts b/web-console/src/views/explore-view/drag-helper.ts
index 0df433efe4e..a9e1188e989 100644
--- a/web-console/src/views/explore-view/drag-helper.ts
+++ b/web-console/src/views/explore-view/drag-helper.ts
@@ -16,10 +16,13 @@
* limitations under the License.
*/
-import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
+import type { Column } from '@druid-toolkit/query';
+
+import type { Measure } from './models';
export class DragHelper {
- static dragColumn: ExpressionMeta | undefined;
+ static dragColumn: Column | undefined;
+ static dragMeasure: Measure | undefined;
static createDragGhost(dataTransfer: DataTransfer, text: string): void {
const dragGhost = document.createElement('div');
diff --git a/web-console/src/views/explore-view/droppable-container/droppable-container.tsx b/web-console/src/views/explore-view/droppable-container/droppable-container.tsx
deleted file mode 100644
index 3e0d7dc4a88..00000000000
--- a/web-console/src/views/explore-view/droppable-container/droppable-container.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * 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 { ExpressionMeta } from '@druid-toolkit/visuals-core';
-import classNames from 'classnames';
-import React, { forwardRef, useState } from 'react';
-
-import { DragHelper } from '../drag-helper';
-
-import './droppable-container.scss';
-
-export interface DroppableContainerProps extends React.HTMLAttributes<HTMLDivElement> {
- onDropColumn(column: ExpressionMeta): void;
- children?: React.ReactNode;
-}
-
-export const DroppableContainer = forwardRef(function DroppableContainer(
- props: DroppableContainerProps,
- ref,
-) {
- const { className, onDropColumn, children, ...rest } = props;
- const [dropHover, setDropHover] = useState(false);
-
- return (
- <div
- ref={ref as any}
- className={classNames('droppable-container', className, { 'drop-hover': dropHover })}
- {...rest}
- onDragOver={e => {
- if (!DragHelper.dragColumn) 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) return;
- const dragColumn = DragHelper.dragColumn;
- DragHelper.dragColumn = undefined;
- setDropHover(false);
- onDropColumn(dragColumn);
- }}
- >
- {children}
- </div>
- );
-});
diff --git a/web-console/src/views/explore-view/explore-state.ts b/web-console/src/views/explore-view/explore-state.ts
new file mode 100644
index 00000000000..9ca74f5d7dd
--- /dev/null
+++ b/web-console/src/views/explore-view/explore-state.ts
@@ -0,0 +1,232 @@
+/*
+ * 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 {
+ filterPatternToExpression,
+ SqlExpression,
+ SqlLiteral,
+ SqlQuery,
+} from '@druid-toolkit/query';
+
+import { isEmpty } from '../../utils';
+
+import type { Measure, ParameterValues } from './models';
+import {
+ ExpressionMeta,
+ inflateParameterValues,
+ QuerySource,
+ renameColumnsInParameterValues,
+} from './models';
+import { ModuleRepository } from './module-repository/module-repository';
+import type { Rename } from './utils';
+import { renameColumnsInExpression } from './utils';
+
+interface ExploreStateValue {
+ source: string;
+ showSourceQuery?: boolean;
+ where: SqlExpression;
+ moduleId: string;
+ parameterValues: Record<string, any>;
+}
+
+export class ExploreState {
+ static DEFAULT_STATE: ExploreState;
+
+ static fromJS(js: any) {
+ const inflatedParameterValues = inflateParameterValues(
+ js.parameterValues,
+ ModuleRepository.getModule(js.moduleId)?.parameters || {},
+ );
+ return new ExploreState({
+ ...js,
+ where: SqlExpression.maybeParse(js.where) || SqlLiteral.TRUE,
+ parameterValues: inflatedParameterValues,
+ });
+ }
+
+ public readonly source: string;
+ public readonly showSourceQuery: boolean;
+ public readonly where: SqlExpression;
+ public readonly moduleId: string;
+ public readonly parameterValues: Record<string, any>;
+
+ public readonly parsedSource: SqlQuery | undefined;
+ public readonly parseError: string | undefined;
+
+ constructor(value: ExploreStateValue) {
+ this.source = value.source;
+ this.showSourceQuery = Boolean(value.showSourceQuery);
+ this.where = value.where;
+ this.moduleId = value.moduleId;
+ this.parameterValues = value.parameterValues;
+
+ if (this.source === '') {
+ this.parseError = 'Please select source or enter a source query';
+ } else {
+ try {
+ this.parsedSource = SqlQuery.parse(this.source);
+ } catch (e) {
+ this.parseError = e.message;
+ }
+ }
+ }
+
+ valueOf(): ExploreStateValue {
+ return {
+ source: this.source,
+ showSourceQuery: this.showSourceQuery,
+ where: this.where,
+ moduleId: this.moduleId,
+ parameterValues: this.parameterValues,
+ };
+ }
+
+ public change(newValues: Partial<ExploreStateValue>): ExploreState {
+ return new ExploreState({
+ ...this.valueOf(),
+ ...newValues,
+ });
+ }
+
+ public changeSource(newSource: SqlQuery | string, rename: Rename | undefined): ExploreState {
+ const toChange: Partial<ExploreStateValue> = {
+ source: String(newSource),
+ };
+
+ if (rename) {
+ toChange.where = renameColumnsInExpression(this.where, rename);
+
+ const module = ModuleRepository.getModule(this.moduleId);
+ if (module) {
+ toChange.parameterValues = renameColumnsInParameterValues(
+ this.parameterValues,
+ module.parameters,
+ rename,
+ );
+ }
+ }
+
+ return this.change(toChange);
+ }
+
+ public changeToTable(tableName: string): ExploreState {
+ return this.changeSource(SqlQuery.create(tableName), undefined);
+ }
+
+ public addInitTimeFilterIfNeeded(columns: readonly Column[]): ExploreState {
+ if (!this.parsedSource) return this;
+ if (!QuerySource.isSingleStarQuery(this.parsedSource)) return this; // Only trigger for `SELECT * FROM ...` queries
+ if (!this.where.equal(SqlLiteral.TRUE)) return this;
+
+ const timeColumn = columns.find(c => c.isTimeColumn());
+ if (!timeColumn) return this;
+
+ return this.change({
+ where: filterPatternToExpression({
+ type: 'timeRelative',
+ column: timeColumn.name,
+ negated: false,
+ anchor: 'maxDataTime',
+ rangeDuration: 'P1D',
+ startBound: '[',
+ endBound: ')',
+ }),
+ });
+ }
+
+ public restrictToQuerySource(querySource: QuerySource): ExploreState {
+ const { where, moduleId, parameterValues } = this;
+ const module = ModuleRepository.getModule(moduleId);
+ if (!module) return this;
+ const newWhere = querySource.restrictWhere(where);
+ const newParameterValues = querySource.restrictParameterValues(
+ parameterValues,
+ module.parameters,
+ );
+ if (where === newWhere && parameterValues === newParameterValues) return this;
+
+ return this.change({
+ where: newWhere,
+ parameterValues: newParameterValues,
+ });
+ }
+
+ public applyShowColumn(column: Column): ExploreState {
+ let moduleId: string;
+ let parameterValues: ParameterValues;
+ if (column.sqlType === 'TIMESTAMP') {
+ moduleId = 'time-chart';
+ parameterValues = {};
+ } else {
+ moduleId = 'grouping-table';
+ parameterValues = this.moduleId === moduleId ? this.parameterValues : {};
+ parameterValues.splitColumns = [ExpressionMeta.fromColumn(column)];
+ }
+
+ return this.change({
+ moduleId,
+ parameterValues,
+ });
+ }
+
+ public applyShowMeasure(measure: Measure): ExploreState {
+ const module = ModuleRepository.getModule(this.moduleId);
+ if (module) {
+ const p = Object.entries(module.parameters).find(
+ ([_, def]) => def.type === 'measure' || def.type === 'measures',
+ );
+ if (p) {
+ const [paramName, def] = p;
+ const { parameterValues } = this;
+ return this.change({
+ parameterValues: {
+ ...parameterValues,
+ [paramName]:
+ def.type === 'measures'
+ ? (parameterValues[paramName] || []).concat(measure)
+ : measure,
+ },
+ });
+ }
+ }
+
+ return this.change({
+ moduleId: 'grouping-table',
+ parameterValues: {
+ measures: [measure],
+ },
+ });
+ }
+
+ public isInitState(): boolean {
+ return (
+ this.moduleId === 'record-table' &&
+ this.source === '' &&
+ this.where instanceof SqlLiteral &&
+ isEmpty(this.parameterValues)
+ );
+ }
+}
+
+ExploreState.DEFAULT_STATE = new ExploreState({
+ moduleId: 'record-table',
+ source: '',
+ where: SqlLiteral.TRUE,
+ parameterValues: {},
+});
diff --git a/web-console/src/views/explore-view/explore-view.scss b/web-console/src/views/explore-view/explore-view.scss
index d3ec7b0c5c0..09627e0c9bd 100644
--- a/web-console/src/views/explore-view/explore-view.scss
+++ b/web-console/src/views/explore-view/explore-view.scss
@@ -24,31 +24,78 @@ $resources-width: 240px;
height: 100%;
position: relative;
- display: grid;
- grid-template-columns: $resources-width 1fr $resources-width;
- grid-template-rows: auto 1fr;
- gap: 8px;
-
- .source-pane,
- .filter-pane,
- .tile-picker,
- .control-pane-cnt {
+ .source-query-pane {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ height: 192px;
@include card-like;
- padding: 8px;
- height: 100%;
- overflow: auto;
}
- .resource-pane-cnt,
- .module-holder {
+ .source-error {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ top: 0;
@include card-like;
+ padding: 20px;
+
+ .source-pane {
+ margin-top: 15px;
+ }
+ }
+
+ &.show-source-query .explore-container {
+ top: 200px;
}
+ &.show-source-query .source-error {
+ top: 200px;
+ }
+
+ .explore-container {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ top: 0;
+
+ display: grid;
+ grid-template-columns: $resources-width 1fr $resources-width;
+ grid-template-rows: auto 1fr;
+ gap: 8px;
- .resource-pane-cnt {
- height: 100%;
+ .source-pane,
+ .filter-pane,
+ .module-picker,
+ .control-pane-cnt {
+ @include card-like;
+ padding: 8px;
+ height: 100%;
+ overflow: auto;
+ }
+
+ .resource-pane-cnt,
+ .module-holder {
+ @include card-like;
+ }
- .resource-pane {
+ .resource-pane-cnt {
+ position: relative;
height: 100%;
+
+ .resource-pane {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ }
+ }
+
+ .main-cnt {
+ .module-pane {
+ height: 100%;
+ }
}
}
}
diff --git a/web-console/src/views/explore-view/explore-view.tsx b/web-console/src/views/explore-view/explore-view.tsx
index 885e272b411..9e80f3ae06a 100644
--- a/web-console/src/views/explore-view/explore-view.tsx
+++ b/web-console/src/views/explore-view/explore-view.tsx
@@ -18,83 +18,42 @@
import './modules';
-import { Intent, Menu, MenuItem } from '@blueprintjs/core';
+import { Button, Intent, Menu, MenuDivider, MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
-import type { SqlExpression } from '@druid-toolkit/query';
-import { C, L, sql, SqlLiteral, SqlQuery, SqlTable, T } from '@druid-toolkit/query';
-import type { ExpressionMeta, TransferValue } from '@druid-toolkit/visuals-core';
-import {
- useModuleContainer,
- useParameterValues,
- useSingleHost,
-} from '@druid-toolkit/visuals-react';
+import type { Column, QueryResult, SqlExpression } from '@druid-toolkit/query';
+import { QueryRunner, SqlQuery } from '@druid-toolkit/query';
+import classNames from 'classnames';
import copy from 'copy-to-clipboard';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useStore } from 'zustand';
import { ShowValueDialog } from '../../dialogs/show-value-dialog/show-value-dialog';
-import { useLocalStorageState, useQueryManager } from '../../hooks';
-import { AppToaster } from '../../singletons';
-import { deepGet, filterMap, LocalStorageKeys, oneOf, queryDruidSql } from '../../utils';
-
-import { ControlPane } from './control-pane/control-pane';
-import { DroppableContainer } from './droppable-container/droppable-container';
-import { FilterPane } from './filter-pane/filter-pane';
-import { HighlightBubble } from './highlight-bubble/highlight-bubble';
+import { useHashAndLocalStorageHybridState, useQueryManager } from '../../hooks';
+import { Api, AppToaster } from '../../singletons';
+import { DruidError, LocalStorageKeys, queryDruidSql } from '../../utils';
+
+import {
+ ControlPane,
+ DroppableContainer,
+ FilterPane,
+ HighlightBubble,
+ ModulePane,
+ ModulePicker,
+ ResourcePane,
+ SourcePane,
+ SourceQueryPane,
+} from './components';
+import { ExploreState } from './explore-state';
import { highlightStore } from './highlight-store/highlight-store';
-import BarChartEcharts from './modules/bar-chart-echarts-module';
-import MultiAxisChartEcharts from './modules/multi-axis-chart-echarts-module';
-import PieChartEcharts from './modules/pie-chart-echarts-module';
-import TableReact from './modules/table-react-module';
-import TimeChartEcharts from './modules/time-chart-echarts-module';
-import { ResourcePane } from './resource-pane/resource-pane';
-import { SourcePane } from './source-pane/source-pane';
-import { TilePicker } from './tile-picker/tile-picker';
-import type { Dataset } from './utils';
+import type { Measure, ParameterValues } from './models';
+import { QuerySource } from './models';
+import { ModuleRepository } from './module-repository/module-repository';
+import { rewriteAggregate, rewriteMaxDataTime } from './query-macros';
+import type { Rename } from './utils';
import { adjustTransferValue, normalizeType } from './utils';
import './explore-view.scss';
-const VISUAL_MODULES = [
- {
- moduleName: 'time_chart_echarts',
- icon: IconNames.TIMELINE_LINE_CHART,
- label: 'Time chart',
- module: TimeChartEcharts,
- transfer: ['splitColumn', 'metric'],
- },
- {
- moduleName: 'bar_chart_echarts',
- icon: IconNames.TIMELINE_BAR_CHART,
- label: 'Bar chart',
- module: BarChartEcharts,
- transfer: ['splitColumn', 'metric'],
- },
- {
- moduleName: 'table_react',
- icon: IconNames.TH,
- label: 'Table',
- module: TableReact,
- transfer: ['splitColumns', 'metrics'],
- },
- {
- moduleName: 'pie_chart_echarts',
- icon: IconNames.PIE_CHART,
- label: 'Pie chart',
- module: PieChartEcharts,
- transfer: ['splitColumn', 'metric'],
- },
- {
- moduleName: 'multi-axis_chart_echarts',
- icon: IconNames.SERIES_ADD,
- label: 'Multi-axis chart',
- module: MultiAxisChartEcharts,
- transfer: ['metrics'],
- },
-] as const;
-
-type ModuleType = (typeof VISUAL_MODULES)[number]['moduleName'];
-
// ---------------------------------------
interface QueryHistoryEntry {
@@ -118,297 +77,395 @@ function getFormattedQueryHistory(): string {
// ---------------------------------------
-async function introspect(tableName: SqlTable): Promise<Dataset> {
- const columns = await queryDruidSql({
- query: `SELECT COLUMN_NAME AS "name", DATA_TYPE AS "sqlType" FROM INFORMATION_SCHEMA.COLUMNS
- WHERE TABLE_SCHEMA = 'druid' AND TABLE_NAME = ${L(tableName.getName())}`,
- });
-
- return {
- table: tableName,
- columns: columns.map(({ name, sqlType }) => ({ name, expression: C(name), sqlType })),
- };
-}
+const queryRunner = new QueryRunner({
+ inflateDateStrategy: 'fromSqlTypes',
+ executor: async (sqlQueryPayload, isSql, cancelToken) => {
+ if (!isSql) throw new Error('should never get here');
+ addQueryToHistory(sqlQueryPayload.query);
+ return Api.instance.post('/druid/v2/sql', sqlQueryPayload, { cancelToken });
+ },
+});
-// micro-cache
-const MAX_TIME_TTL = 60000;
-let lastMaxTimeTable: string | undefined;
-let lastMaxTimeValue: Date | undefined;
-let lastMaxTimeTimestamp = 0;
-
-async function getMaxTimeForTable(tableName: string): Promise<Date | undefined> {
- // micro-cache get
- if (
- lastMaxTimeTable === tableName &&
- lastMaxTimeValue &&
- Date.now() < lastMaxTimeTimestamp + MAX_TIME_TTL
- ) {
- return lastMaxTimeValue;
+async function runSqlQuery(query: string | SqlQuery): Promise<QueryResult> {
+ try {
+ return await queryRunner.runQuery({
+ query,
+ });
+ } catch (e) {
+ throw new DruidError(e);
}
-
- const d = await queryDruidSql({
- query: sql`SELECT MAX(__time) AS "maxTime" FROM ${T(tableName)}`,
- });
-
- let maxTime = new Date(deepGet(d, '0.maxTime'));
- if (isNaN(maxTime.valueOf())) return;
-
- // Add 1ms to the maxTime date so as to allow filters like `"__time" < {maxTime}" to capture the last event which might also be the only event
- maxTime = new Date(maxTime.valueOf() + 1);
-
- // micro-cache set
- lastMaxTimeTable = tableName;
- lastMaxTimeValue = maxTime;
- lastMaxTimeTimestamp = Date.now();
-
- return maxTime;
}
-function getFirstTableName(q: SqlQuery): string | undefined {
- let tableName: string | undefined;
- q.walk(ex => {
- if (ex instanceof SqlTable) {
- tableName = ex.getName();
- return;
- }
- return ex;
- });
- return tableName;
-}
+async function introspectSource(source: string): Promise<QuerySource> {
+ const query = SqlQuery.parse(source);
+ const introspectResult = await runSqlQuery(QuerySource.makeLimitZeroIntrospectionQuery(query));
-async function extendedQueryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]> {
- if (sqlQueryPayload.query.includes('MAX_DATA_TIME()')) {
- const parsed = SqlQuery.parse(sqlQueryPayload.query);
- const tableName = getFirstTableName(parsed);
- if (tableName) {
- const maxTime = await getMaxTimeForTable(tableName);
- if (maxTime) {
- sqlQueryPayload = {
- ...sqlQueryPayload,
- query: sqlQueryPayload.query.replace(/MAX_DATA_TIME\(\)/g, L(maxTime)),
- };
- }
- }
- }
-
- addQueryToHistory(sqlQueryPayload.query);
- console.debug(`Running query:\n${sqlQueryPayload.query}`);
+ const baseIntrospectResult = QuerySource.isSingleStarQuery(query)
+ ? introspectResult
+ : await runSqlQuery(
+ QuerySource.makeLimitZeroIntrospectionQuery(QuerySource.stripToBaseSource(query)),
+ );
- return queryDruidSql(sqlQueryPayload);
+ return QuerySource.fromIntrospectResult(
+ query,
+ baseIntrospectResult.header,
+ introspectResult.header,
+ );
}
export const ExploreView = React.memo(function ExploreView() {
const [shownText, setShownText] = useState<string | undefined>();
- const filterPane = useRef<{ filterOn(column: ExpressionMeta): void }>();
-
- const [moduleName, setModuleName] = useLocalStorageState<ModuleType>(
- LocalStorageKeys.EXPLORE_CONTENT,
- VISUAL_MODULES[0].moduleName,
+ const filterPane = useRef<{ filterOn(column: Column): void }>();
+ const containerRef = useRef<HTMLDivElement | null>(null);
+
+ const [exploreState, setExploreState] = useHashAndLocalStorageHybridState<ExploreState>(
+ '#explore/v/',
+ LocalStorageKeys.EXPLORE_STATE,
+ ExploreState.DEFAULT_STATE,
+ s => {
+ return ExploreState.fromJS(s);
+ },
);
const { dropHighlight } = useStore(highlightStore);
- const [timezone] = useState('Etc/UTC');
+ // -------------------------------------------------------
+ // If no table selected, change to first table if possible
+ async function initWithFirstTable() {
+ const tables = await queryDruidSql<{ TABLE_NAME: string }>({
+ query: `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'TABLE' LIMIT 1`,
+ });
- const [columns, setColumns] = useState<ExpressionMeta[]>([]);
+ const firstTableName = tables[0].TABLE_NAME;
+ if (firstTableName) {
+ setTable(firstTableName);
+ }
+ }
- const { host, where, table, visualModule, updateWhere, updateTable } = useSingleHost({
- sqlQuery: extendedQueryDruidSql,
- persist: { name: LocalStorageKeys.EXPLORE_ESSENCE, storage: 'localStorage' },
- visualModules: Object.fromEntries(VISUAL_MODULES.map(v => [v.moduleName, v.module])),
- selectedModule: moduleName,
- moduleState: {
- parameterValues: {},
- table: T('select source'),
- where: SqlLiteral.TRUE,
- },
+ useEffect(() => {
+ if (exploreState.isInitState()) {
+ void initWithFirstTable();
+ }
});
- useEffect(() => {
- host.store.setState({ context: { timezone } });
- }, [timezone, host.store]);
+ // -------------------------------------------------------
- const { parameterValues, updateParameterValues, resetParameterValues } = useParameterValues({
- host,
- selectedModule: moduleName,
- columns,
- });
+ const { moduleId, source, parsedSource, parseError, where, parameterValues, showSourceQuery } =
+ exploreState;
+ const module = ModuleRepository.getModule(moduleId);
- const [datasetState] = useQueryManager<SqlExpression, Dataset>({
- query: table,
- processQuery: tableName => introspect(tableName as SqlTable),
+ const [querySourceState] = useQueryManager<string, QuerySource>({
+ query: parsedSource ? String(parsedSource) : undefined,
+ processQuery: introspectSource,
});
- const onShow = useMemo(() => {
- const currentShowTransfers =
- VISUAL_MODULES.find(vm => vm.moduleName === moduleName)?.transfer || [];
- if (currentShowTransfers.length) {
- const paramName = currentShowTransfers[0];
- const showControlType = visualModule?.parameterDefinitions?.[paramName]?.type;
-
- if (paramName && oneOf(showControlType, 'column', 'columns')) {
- return (column: ExpressionMeta) => {
- updateParameterValues({ [paramName]: showControlType === 'column' ? column : [column] });
- };
- }
+ // -------------------------------------------------------
+ // If we have a __time::TIMESTAMP column and no filter add a filter
+
+ useEffect(() => {
+ const columns = querySourceState.data?.columns;
+ if (!columns) return;
+ const newExploreState = exploreState.addInitTimeFilterIfNeeded(columns);
+ if (exploreState !== newExploreState) {
+ setExploreState(newExploreState);
}
- return;
- }, [updateParameterValues, moduleName, visualModule?.parameterDefinitions]);
+ }, [querySourceState.data]);
- const dataset = datasetState.getSomeData();
+ // -------------------------------------------------------
useEffect(() => {
- setColumns(dataset?.columns ?? []);
- }, [dataset?.columns]);
+ const querySource = querySourceState.data;
+ if (!querySource || !module) return;
+ const newExploreState = exploreState.restrictToQuerySource(querySource);
+ if (exploreState !== newExploreState) {
+ setExploreState(newExploreState);
+ }
+ }, [module, parameterValues, querySourceState.data]);
+
+ function setModuleId(moduleId: string, parameterValues: Record<string, any>) {
+ if (exploreState.moduleId === moduleId) return;
+ setExploreState(exploreState.change({ moduleId, parameterValues }));
+ }
- const [containerRef] = useModuleContainer({ host, selectedModule: moduleName, columns });
+ function setParameterValues(newParameterValues: ParameterValues) {
+ if (newParameterValues === parameterValues) return;
+ setExploreState(exploreState.change({ parameterValues: newParameterValues }));
+ }
+
+ function resetParameterValues() {
+ setParameterValues({});
+ }
+
+ function updateParameterValues(newParameterValues: ParameterValues) {
+ setParameterValues({ ...parameterValues, ...newParameterValues });
+ }
+
+ function setSource(source: SqlQuery | string, rename?: Rename) {
+ setExploreState(exploreState.changeSource(source, rename));
+ }
+
+ function setTable(tableName: string) {
+ setExploreState(exploreState.changeToTable(tableName));
+ }
+
+ function setWhere(where: SqlExpression) {
+ setExploreState(exploreState.change({ where }));
+ }
+
+ function onShowColumn(column: Column) {
+ setExploreState(exploreState.applyShowColumn(column));
+ }
+
+ function onShowMeasure(measure: Measure) {
+ setExploreState(exploreState.applyShowMeasure(measure));
+ }
+
+ function onShowSourceQuery() {
+ setExploreState(exploreState.change({ showSourceQuery: true }));
+ }
+
+ const querySource = querySourceState.getSomeData();
+
+ const runSqlPlusQuery = useMemo(() => {
+ return async (query: string | SqlQuery) => {
+ if (!querySource) throw new Error('no querySource');
+ return await runSqlQuery(
+ await rewriteMaxDataTime(rewriteAggregate(SqlQuery.parse(query), querySource.measures)),
+ );
+ };
+ }, [querySource]);
return (
- <>
- <div className="explore-view">
- <SourcePane
- selectedTableName={table ? (table as SqlTable).getName() : '-'}
- onSelectedTableNameChange={t => updateTable(T(t))}
- disabled={Boolean(dataset && datasetState.loading)}
- />
- <FilterPane
- ref={filterPane}
- dataset={dataset}
- filter={where}
- onFilterChange={updateWhere}
- queryDruidSql={extendedQueryDruidSql}
+ <div className={classNames('explore-view', { 'show-source-query': showSourceQuery })}>
+ {showSourceQuery && (
+ <SourceQueryPane
+ source={source}
+ onSourceChange={setSource}
+ onClose={() => setExploreState(exploreState.change({ showSourceQuery: false }))}
/>
- <TilePicker<ModuleType>
- modules={VISUAL_MODULES}
- selectedTileName={moduleName}
- onSelectedTileNameChange={m => {
- const currentParameterDefinitions = visualModule?.parameterDefinitions || {};
- const valuesToTransfer: TransferValue[] = filterMap(
- VISUAL_MODULES.find(vm => vm.moduleName === visualModule?.moduleName)?.transfer || [],
- paramName => {
- const parameterDefinition = currentParameterDefinitions[paramName];
- if (!parameterDefinition) return;
- const parameterValue = parameterValues[paramName];
- if (typeof parameterValue === 'undefined') return;
- return [parameterDefinition.type, parameterValue];
- },
- );
-
- dropHighlight();
- setModuleName(m);
- resetParameterValues();
-
- const newModuleDef = VISUAL_MODULES.find(vm => vm.moduleName === m);
- if (newModuleDef) {
- const newParameters: any = newModuleDef.module?.parameters || {};
- const transferParameterValues: [name: string, value: any][] = filterMap(
- newModuleDef.transfer || [],
- t => {
- const p = newParameters[t];
- if (!p) return;
- const normalizedTargetType = normalizeType(p.type);
- const transferSource = valuesToTransfer.find(
- ([t]) => normalizeType(t) === normalizedTargetType,
- );
- if (!transferSource) return;
- const targetValue = adjustTransferValue(
- transferSource[1],
- transferSource[0],
- p.type,
- );
- if (typeof targetValue === 'undefined') return;
- return [t, targetValue];
- },
+ )}
+ {parseError && (
+ <div className="source-error">
+ <p>{parseError}</p>
+ {source === '' && (
+ <p>
+ <SourcePane
+ selectedSource={undefined}
+ onSelectTable={setTable}
+ disabled={Boolean(querySource && querySourceState.loading)}
+ />
+ </p>
+ )}
+ {!showSourceQuery && (
+ <p>
+ <Button text="Show source query" onClick={onShowSourceQuery} />
+ </p>
+ )}
+ </div>
+ )}
+ {parsedSource && (
+ <div className="explore-container">
+ <SourcePane
+ selectedSource={parsedSource}
+ onSelectTable={setTable}
+ onShowSourceQuery={onShowSourceQuery}
+ fill
+ minimal
+ disabled={Boolean(querySource && querySourceState.loading)}
+ />
+ <FilterPane
+ ref={filterPane}
+ querySource={querySource}
+ filter={where}
+ onFilterChange={setWhere}
+ runSqlQuery={runSqlPlusQuery}
+ onAddToSourceQueryAsColumn={expression => {
+ if (!querySource) return;
+ setExploreState(
+ exploreState.changeSource(
+ querySource.addColumn(querySource.transformToBaseColumns(expression)),
+ undefined,
+ ),
+ );
+ }}
+ onMoveToSourceQueryAsClause={(expression, changeWhere) => {
+ if (!querySource) return;
+ setExploreState(
+ exploreState
+ .change({ where: changeWhere })
+ .changeSource(
+ querySource.addWhereClause(querySource.transformToBaseColumns(expression)),
+ undefined,
+ ),
);
+ }}
+ />
+ <ModulePicker
+ modules={[
+ { id: 'grouping-table', icon: IconNames.PANEL_TABLE, label: 'Grouping table' },
+ { id: 'record-table', icon: IconNames.TH, label: 'Record table' },
+ { id: 'time-chart', icon: IconNames.TIMELINE_LINE_CHART, label: 'Time chart' },
+ { id: 'bar-chart', icon: IconNames.VERTICAL_BAR_CHART_DESC, label: 'Bar chart' },
+ { id: 'pie-chart', icon: IconNames.PIE_CHART, label: 'Pie chart' },
+ { id: 'multi-axis-chart', icon: IconNames.SERIES_ADD, label: 'Multi-axis chart' },
+ ]}
+ selectedModuleId={moduleId}
+ onSelectedModuleIdChange={newModuleId => {
+ const newParameterValues: ParameterValues = {};
+ const oldModule = ModuleRepository.getModule(moduleId);
+ const newModule = ModuleRepository.getModule(newModuleId);
+ if (oldModule && newModule) {
+ const oldModuleParameters = oldModule.parameters || {};
+ const newModuleParameters = newModule.parameters || {};
+ for (const paramName in oldModuleParameters) {
+ const parameterValue = parameterValues[paramName];
+ if (typeof parameterValue === 'undefined') continue;
+
+ const oldParameterDefinition = oldModuleParameters[paramName];
+ const transferGroup = oldParameterDefinition.transferGroup;
+ if (typeof transferGroup !== 'string') continue;
+
+ const normalizedType = normalizeType(oldParameterDefinition.type);
+ const target = Object.entries(newModuleParameters).find(
+ ([_, def]) =>
+ def.transferGroup === transferGroup &&
+ normalizeType(def.type) === normalizedType,
+ );
+ if (!target) continue;
- if (transferParameterValues.length) {
- updateParameterValues(Object.fromEntries(transferParameterValues));
+ newParameterValues[target[0]] = adjustTransferValue(
+ parameterValue,
+ oldParameterDefinition.type,
+ target[1].type,
+ );
+ }
}
+
+ dropHighlight();
+ setModuleId(newModuleId, newParameterValues);
+ }}
+ moreMenu={
+ <Menu>
+ <MenuItem
+ icon={IconNames.DUPLICATE}
+ text="Copy last query"
+ disabled={!QUERY_HISTORY.length}
+ onClick={() => {
+ copy(QUERY_HISTORY[0]?.sqlQuery, { format: 'text/plain' });
+ AppToaster.show({
+ message: `Copied query to clipboard`,
+ intent: Intent.SUCCESS,
+ });
+ }}
+ />
+ <MenuItem
+ icon={IconNames.HISTORY}
+ text="Show query history"
+ onClick={() => {
+ setShownText(getFormattedQueryHistory());
+ }}
+ />
+ <MenuItem
+ icon={IconNames.RESET}
+ text="Reset visualization parameters"
+ onClick={() => {
+ resetParameterValues();
+ }}
+ />
+ <MenuDivider />
+ <MenuItem
+ icon={IconNames.TRASH}
+ text="Clear all view state"
+ intent={Intent.DANGER}
+ onClick={() => {
+ localStorage.removeItem(LocalStorageKeys.EXPLORE_STATE);
+ location.hash = '#explore';
+ location.reload();
+ }}
+ />
+ </Menu>
}
- }}
- moreMenu={
- <Menu>
- <MenuItem
- icon={IconNames.DUPLICATE}
- text="Copy last query"
- disabled={!QUERY_HISTORY.length}
- onClick={() => {
- copy(QUERY_HISTORY[0]?.sqlQuery, { format: 'text/plain' });
- AppToaster.show({
- message: `Copied query to clipboard`,
- intent: Intent.SUCCESS,
- });
+ />
+ <div className="resource-pane-cnt">
+ {!querySource && querySourceState.loading && 'Loading...'}
+ {querySource && (
+ <ResourcePane
+ querySource={querySource}
+ onQueryChange={setSource}
+ onFilter={c => {
+ filterPane.current?.filterOn(c);
}}
+ runSqlQuery={runSqlPlusQuery}
+ onShowColumn={onShowColumn}
+ onShowMeasure={onShowMeasure}
/>
- <MenuItem
- icon={IconNames.HISTORY}
- text="Show query history"
- onClick={() => {
- setShownText(getFormattedQueryHistory());
- }}
+ )}
+ </div>
+ <DroppableContainer
+ className="main-cnt"
+ ref={containerRef}
+ onDropColumn={onShowColumn}
+ onDropMeasure={onShowMeasure}
+ >
+ {querySourceState.error && (
+ <div className="error-display">{querySourceState.getErrorMessage()}</div>
+ )}
+ {querySource && (
+ <ModulePane
+ moduleId={moduleId}
+ querySource={querySource}
+ where={where}
+ setWhere={setWhere}
+ parameterValues={parameterValues}
+ setParameterValues={updateParameterValues}
+ runSqlQuery={runSqlPlusQuery}
/>
- <MenuItem
- icon={IconNames.RESET}
- text="Reset visualization state"
- onClick={() => {
- resetParameterValues();
+ )}
+ </DroppableContainer>
+ <div className="control-pane-cnt">
+ {module && (
+ <ControlPane
+ querySource={querySource}
+ onUpdateParameterValues={updateParameterValues}
+ parameters={module.parameters}
+ parameterValues={parameterValues}
+ onAddToSourceQueryAsColumn={expression => {
+ if (!querySource) return;
+ setExploreState(
+ exploreState.changeSource(
+ querySource.addColumn(querySource.transformToBaseColumns(expression)),
+ undefined,
+ ),
+ );
+ }}
+ onAddToSourceQueryAsMeasure={measure => {
+ if (!querySource) return;
+ setExploreState(
+ exploreState.changeSource(
+ querySource.addMeasure(
+ measure.changeExpression(
+ querySource.transformToBaseColumns(measure.expression),
+ ),
+ ),
+ undefined,
+ ),
+ );
}}
/>
- </Menu>
- }
- />
- <div className="resource-pane-cnt">
- {!dataset && datasetState.loading && 'Loading...'}
- {dataset && (
- <ResourcePane
- dataset={dataset}
- onFilter={c => {
- filterPane.current?.filterOn(c);
+ )}
+ </div>
+ {shownText && (
+ <ShowValueDialog
+ title="Query history"
+ str={shownText}
+ onClose={() => {
+ setShownText(undefined);
}}
- onShow={onShow}
/>
)}
</div>
- <DroppableContainer
- ref={containerRef}
- onDropColumn={column => {
- let nextModuleName: ModuleType;
- if (column.sqlType === 'TIMESTAMP') {
- nextModuleName = 'time_chart_echarts';
- } else {
- nextModuleName = 'table_react';
- }
-
- setModuleName(nextModuleName);
-
- if (column.sqlType === 'TIMESTAMP') {
- resetParameterValues();
- } else {
- updateParameterValues({ splitColumns: [column] });
- }
- }}
- />
- <div className="control-pane-cnt">
- {dataset && visualModule?.parameterDefinitions && (
- <ControlPane
- columns={dataset.columns}
- onUpdateParameterValues={updateParameterValues}
- parameterValues={parameterValues}
- visualModule={visualModule}
- />
- )}
- </div>
- {shownText && (
- <ShowValueDialog
- title="Query history"
- str={shownText}
- onClose={() => {
- setShownText(undefined);
- }}
- />
- )}
- </div>
+ )}
<HighlightBubble referenceContainer={containerRef.current} />
- </>
+ </div>
);
});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx b/web-console/src/views/explore-view/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx
deleted file mode 100644
index 6cec4234f68..00000000000
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-/*
- * 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, FormGroup, InputGroup, Intent, Menu, MenuItem } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
-import type { ContainsFilterPattern } from '@druid-toolkit/query';
-import { C, filterPatternToExpression, SqlExpression, SqlLiteral } from '@druid-toolkit/query';
-import React, { useMemo, useState } from 'react';
-
-import { useQueryManager } from '../../../../../hooks';
-import { ColumnPicker } from '../../../column-picker/column-picker';
-import type { Dataset } from '../../../utils';
-
-import './contains-filter-control.scss';
-
-export interface ContainsFilterControlProps {
- dataset: Dataset;
- filter: SqlExpression | undefined;
- initFilterPattern: ContainsFilterPattern;
- negated: boolean;
- setFilterPattern(filterPattern: ContainsFilterPattern): void;
- queryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]>;
-}
-
-export const ContainsFilterControl = React.memo(function ContainsFilterControl(
- props: ContainsFilterControlProps,
-) {
- const { dataset, filter, initFilterPattern, negated, setFilterPattern, queryDruidSql } = props;
- const [column, setColumn] = useState<string>(initFilterPattern.column);
- const [contains, setContains] = useState(initFilterPattern.contains);
-
- function makePattern(): ContainsFilterPattern {
- return {
- type: 'contains',
- negated,
- column,
- contains,
- };
- }
-
- const previewQuery = useMemo(() => {
- const columnRef = C(column);
- const queryParts: string[] = [`SELECT ${columnRef.as('c')}`, `FROM ${dataset.table}`];
-
- const filterEx = SqlExpression.and(
- filter,
- contains ? 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');
- // eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps
- }, [dataset.table, filter, column, contains, negated]);
-
- const [previewState] = useQueryManager<string, string[]>({
- query: previewQuery,
- debounceIdle: 100,
- debounceLoading: 500,
- processQuery: async query => {
- const vs = await queryDruidSql<{ c: any }>({
- query,
- });
-
- return vs.map(d => String(d.c));
- },
- });
-
- return (
- <div className="contains-filter-control">
- <FormGroup label="Column">
- <ColumnPicker
- availableColumns={dataset.columns}
- selectedColumnName={column}
- onSelectedColumnNameChange={setColumn}
- />
- </FormGroup>
- <FormGroup>
- <InputGroup
- value={contains}
- onChange={e => setContains(e.target.value)}
- placeholder="Search string"
- />
- </FormGroup>
- <FormGroup label="Preview">
- <Menu className="preview-list">
- {previewState.data?.map((v, i) => (
- <MenuItem
- key={i}
- className="preview-item"
- text={String(v)}
- shouldDismissPopover={false}
- />
- ))}
- {previewState.loading && <MenuItem disabled text="Loading..." />}
- {previewState.error && (
- <MenuItem icon={IconNames.ERROR} disabled text={previewState.getErrorMessage()} />
- )}
- </Menu>
- </FormGroup>
- <div className="button-bar">
- <Button
- intent={Intent.PRIMARY}
- text="OK"
- onClick={() => {
- const newPattern = makePattern();
- // TODO check if valid
- // if (!isFilterPatternValid(newPattern)) return;
- setFilterPattern(newPattern);
- }}
- />
- </div>
- </div>
- );
-});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/custom-filter-control/custom-filter-control.tsx b/web-console/src/views/explore-view/filter-pane/filter-menu/custom-filter-control/custom-filter-control.tsx
deleted file mode 100644
index b78a15b825e..00000000000
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/custom-filter-control/custom-filter-control.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * 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, FormGroup, Intent, TextArea } from '@blueprintjs/core';
-import type { CustomFilterPattern } from '@druid-toolkit/query';
-import { SqlExpression } from '@druid-toolkit/query';
-import React, { useState } from 'react';
-
-export interface CustomFilterControlProps {
- initFilterPattern: CustomFilterPattern;
- negated: boolean;
- setFilterPattern(filterPattern: CustomFilterPattern): void;
-}
-
-export const CustomFilterControl = React.memo(function CustomFilterControl(
- props: CustomFilterControlProps,
-) {
- const { initFilterPattern, negated, setFilterPattern } = props;
- const [formula, setFormula] = useState<string>(String(initFilterPattern.expression || ''));
-
- function makePattern(): CustomFilterPattern {
- return {
- type: 'custom',
- negated,
- expression: SqlExpression.maybeParse(formula),
- };
- }
-
- return (
- <div className="custom-filter-control">
- <FormGroup>
- <TextArea
- value={formula}
- onChange={e => setFormula(e.target.value)}
- placeholder="SQL expression"
- />
- </FormGroup>
- <div className="button-bar">
- <Button
- intent={Intent.PRIMARY}
- text="OK"
- onClick={() => {
- const newPattern = makePattern();
- // TODO check if valid
- // if (!isFilterPatternValid(newPattern)) return;
- setFilterPattern(newPattern);
- }}
- />
- </div>
- </div>
- );
-});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.tsx b/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.tsx
deleted file mode 100644
index ccf07949f2f..00000000000
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.tsx
+++ /dev/null
@@ -1,191 +0,0 @@
-/*
- * 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 } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
-import type { FilterPattern, FilterPatternType, SqlExpression } from '@druid-toolkit/query';
-import { changeFilterPatternType, FILTER_PATTERN_TYPES } from '@druid-toolkit/query';
-import type { JSX } from 'react';
-import React, { useState } from 'react';
-
-import { ColumnPickerMenu } from '../../column-picker-menu/column-picker-menu';
-import type { Dataset } from '../../utils';
-import { initPatternForColumn } from '../pattern-helpers';
-
-import { ContainsFilterControl } from './contains-filter-control/contains-filter-control';
-import { CustomFilterControl } from './custom-filter-control/custom-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 PATTERN_TYPE_TO_NAME: Record<FilterPatternType, string> = {
- values: 'Values',
- contains: 'Contains',
- custom: 'Custom',
- mvContains: 'Multi-value contains',
- numberRange: 'Number range',
- regexp: 'Regular expression',
- timeInterval: 'Time interval',
- timeRelative: 'Time relative',
-};
-
-export interface FilterMenuProps {
- dataset: Dataset;
- filter: SqlExpression;
- initPattern?: FilterPattern;
- onPatternChange(newPattern: FilterPattern): void;
- onClose(): void;
- queryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]>;
-}
-
-export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps) {
- const { dataset, filter, initPattern, onPatternChange, onClose, queryDruidSql } = props;
-
- const [pattern, setPattern] = useState<FilterPattern | undefined>(initPattern);
- const [negated, setNegated] = useState(Boolean(pattern?.negated));
-
- const { columns } = dataset;
-
- if (!pattern) {
- return (
- <ColumnPickerMenu
- className="filter-menu"
- columns={columns}
- onSelectColumn={c => setPattern(initPatternForColumn(c))}
- iconForColumn={c => (filter.containsColumnName(c.name) ? IconNames.FILTER : undefined)}
- shouldDismissPopover={false}
- />
- );
- }
-
- function onAcceptPattern(pattern: FilterPattern) {
- onPatternChange({ ...pattern, negated });
- onClose();
- }
-
- let cont: JSX.Element;
- switch (pattern.type) {
- case 'values':
- cont = (
- <ValuesFilterControl
- dataset={dataset}
- filter={filter.removeColumnFromAnd(pattern.column)}
- initFilterPattern={pattern}
- negated={negated}
- setFilterPattern={onAcceptPattern}
- onClose={onClose}
- queryDruidSql={queryDruidSql}
- />
- );
- break;
-
- case 'contains':
- cont = (
- <ContainsFilterControl
- dataset={dataset}
- filter={filter.removeColumnFromAnd(pattern.column)}
- initFilterPattern={pattern}
- negated={negated}
- setFilterPattern={onAcceptPattern}
- queryDruidSql={queryDruidSql}
- />
- );
- break;
-
- case 'regexp':
- cont = (
- <RegexpFilterControl
- dataset={dataset}
- filter={filter.removeColumnFromAnd(pattern.column)}
- initFilterPattern={pattern}
- negated={negated}
- setFilterPattern={onAcceptPattern}
- queryDruidSql={queryDruidSql}
- />
- );
- break;
-
- case 'timeInterval':
- cont = (
- <TimeIntervalFilterControl
- dataset={dataset}
- initFilterPattern={pattern}
- negated={negated}
- setFilterPattern={onAcceptPattern}
- />
- );
- break;
-
- case 'timeRelative':
- cont = (
- <TimeRelativeFilterControl
- dataset={dataset}
- initFilterPattern={pattern}
- negated={negated}
- setFilterPattern={onAcceptPattern}
- />
- );
- break;
-
- case 'custom':
- cont = (
- <CustomFilterControl
- initFilterPattern={pattern}
- negated={negated}
- setFilterPattern={onAcceptPattern}
- />
- );
- break;
-
- default:
- cont = <div />; // TODO fix
- break;
- }
-
- return (
- <div className="filter-menu main">
- <FormGroup className="controls">
- <HTMLSelect
- className="type-selector"
- value={pattern.type}
- onChange={e =>
- setPattern(changeFilterPatternType(pattern, e.target.value as FilterPatternType))
- }
- >
- {FILTER_PATTERN_TYPES.map(type => (
- <option key={type} value={type}>
- {PATTERN_TYPE_TO_NAME[type]}
- </option>
- ))}
- </HTMLSelect>
- <ButtonGroup>
- <Button icon={IconNames.FILTER} active={!negated} onClick={() => setNegated(false)} />
- <Button
- icon={IconNames.FILTER_REMOVE}
- active={negated}
- onClick={() => setNegated(true)}
- />
- </ButtonGroup>
- </FormGroup>
- {cont}
- </div>
- );
-});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx b/web-console/src/views/explore-view/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx
deleted file mode 100644
index 3a24bf69074..00000000000
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-/*
- * 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, FormGroup, InputGroup, Intent } from '@blueprintjs/core';
-import type { TimeIntervalFilterPattern } from '@druid-toolkit/query';
-import React, { useState } from 'react';
-
-import { ColumnPicker } from '../../../column-picker/column-picker';
-import type { Dataset } from '../../../utils';
-
-function utcParseDate(dateString: string): Date | undefined {
- const dateParts = dateString.split(/[-T:. ]/g);
-
- // Extract the individual date and time components
- const year = parseInt(dateParts[0], 10);
- if (!(1000 < year && year < 4000)) return;
-
... 96081 lines suppressed ...
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@druid.apache.org
For additional commands, e-mail: commits-help@druid.apache.org