From 9db4d6e633e837f6b87ffdccd338f716646c9862 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Tue, 4 Jun 2024 15:33:55 -0700 Subject: [PATCH 1/8] explore revamp --- licenses.yaml | 47 +- web-console/README.md | 3 +- web-console/package-lock.json | 112 +- web-console/package.json | 4 +- .../__snapshots__/table-cell.spec.tsx.snap | 2 +- .../src/components/table-cell/table-cell.tsx | 6 +- web-console/src/entry.scss | 7 + web-console/src/hooks/index.ts | 1 + ...use-hash-and-local-storage-hybrid-state.ts | 65 + web-console/src/hooks/use-query-manager.ts | 23 +- web-console/src/utils/base64-url.spec.ts | 39 + web-console/src/utils/base64-url.ts | 34 + web-console/src/utils/date.ts | 30 +- web-console/src/utils/general.tsx | 66 +- web-console/src/utils/index.tsx | 1 + web-console/src/utils/local-storage-keys.tsx | 3 +- web-console/src/utils/table-helpers.ts | 3 +- web-console/src/utils/types.ts | 6 + .../column-picker/column-picker.tsx | 47 - .../column-picker-menu.scss | 0 .../column-picker-menu/column-picker-menu.tsx | 26 +- .../column-picker/column-picker.tsx | 84 + .../control-pane/control-pane.scss | 4 +- .../components/control-pane/control-pane.tsx | 382 + .../control-pane/expression-menu.scss | 57 + .../control-pane/expression-menu.tsx | 212 + .../components/control-pane/measure-menu.scss | 70 + .../components/control-pane/measure-menu.tsx | 291 + .../named-expressions-input.scss} | 6 +- .../control-pane/named-expressions-input.tsx} | 122 +- .../control-pane/options-input.tsx | 68 +- .../droppable-container.scss | 2 +- .../droppable-container.tsx | 74 + .../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 | 385 + .../number-range-filter-control.tsx | 69 + .../regexp-filter-control.scss | 0 .../regexp-filter-control.tsx | 92 +- .../time-interval-filter-control.scss | 26 + .../time-interval-filter-control.tsx | 53 + .../time-relative-filter-control.tsx | 61 +- .../values-filter-control.scss | 0 .../values-filter-control.tsx | 120 + .../filter-pane/filter-pane.scss | 4 +- .../filter-pane/filter-pane.tsx | 80 +- .../generic-output-table.scss | 8 +- .../generic-output-table.tsx | 246 +- .../highlight-bubble/highlight-bubble.scss | 0 .../highlight-bubble/highlight-bubble.tsx | 4 +- .../views/explore-view/components/index.ts | 31 + .../explore-view/components/issue/issue.scss | 23 + .../explore-view/components/issue/issue.tsx | 37 + .../components/module-pane/module-pane.scss | 45 + .../components/module-pane/module-pane.tsx | 100 + .../module-picker/module-picker.scss} | 10 +- .../module-picker/module-picker.tsx} | 28 +- .../components/preview-pane/preview-pane.scss | 43 + .../components/preview-pane/preview-pane.tsx | 72 + .../column-dialog/column-dialog.scss | 57 + .../column-dialog/column-dialog.tsx | 166 + .../measure-dialog/measure-dialog.scss | 57 + .../measure-dialog/measure-dialog.tsx | 173 + .../resource-pane/resource-pane.scss | 64 + .../resource-pane/resource-pane.tsx | 283 + .../source-pane/source-pane.scss | 10 +- .../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 | 164 + .../utc-date-input/utc-date-input.tsx | 86 + .../control-pane/aggregate-menu.tsx | 85 - .../control-pane/aggregates-input.tsx | 69 - .../control-pane/control-pane.tsx | 348 - .../explore-view/control-pane/helpers.ts | 111 - .../src/views/explore-view/drag-helper.ts | 7 +- .../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 +- .../explore-view/models/expression-meta.ts | 102 + .../views/explore-view/models/highlight.ts | 60 + .../src/views/explore-view/models/index.ts | 25 + .../explore-view/models/measure-pattern.ts | 148 + .../src/views/explore-view/models/measure.ts | 234 + .../views/explore-view/models/parameter.ts | 273 + .../views/explore-view/models/query-source.ts | 364 + .../src/views/explore-view/models/stage.ts | 31 + .../module-repository/module-repository.ts | 57 + .../modules/bar-chart-echarts-module.ts | 155 - .../explore-view/modules/bar-chart-module.tsx | 204 + ...module.scss => grouping-table-module.scss} | 5 +- .../modules/grouping-table-module.tsx | 303 + .../src/views/explore-view/modules/index.ts | 11 +- .../multi-axis-chart-echarts-module.ts | 266 - .../modules/multi-axis-chart-module.tsx | 311 + .../modules/pie-chart-echarts-module.ts | 201 - .../explore-view/modules/pie-chart-module.tsx | 252 + .../record-table-module.scss} | 11 +- .../modules/record-table-module.tsx | 96 + .../modules/table-react-module.tsx | 304 - .../modules/time-chart-echarts-module.ts | 368 - .../modules/time-chart-module.tsx | 417 + .../__snapshots__/table-query.spec.ts.snap | 39502 ------------- .../explore-view/modules/utils/utils.spec.ts | 101 - .../views/explore-view/modules/utils/utils.ts | 111 - .../explore-view/query-macros/aggregate.ts | 58 + .../components => query-macros}/index.ts | 3 +- .../query-macros/max-data-time.ts | 38 + .../resource-pane/resource-pane.tsx | 93 - .../__snapshots__/table-query.spec.ts.snap | 46048 ++++++++++++++++ .../views/explore-view/utils/date-format.ts | 1 + .../views/explore-view/utils/duration.spec.ts | 39 + .../src/views/explore-view/utils/duration.ts | 46 + .../filter-pattern-helpers.ts} | 38 +- .../src/views/explore-view/utils/general.ts | 54 + .../src/views/explore-view/utils/index.ts | 6 + .../explore-view/utils/max-time-for-table.ts | 52 + .../src/views/explore-view/utils/misc.ts | 48 +- .../{modules => }/utils/table-query.spec.ts | 345 +- .../{modules => }/utils/table-query.ts | 400 +- .../utils/time-manipulation.spec.ts | 225 + .../explore-view/utils/time-manipulation.ts | 193 + .../__snapshots__/filter-table.spec.tsx.snap | 6 +- .../parse-time-table.spec.tsx.snap | 6 +- .../__snapshots__/schema-table.spec.tsx.snap | 6 +- .../transform-table.spec.tsx.snap | 6 +- 136 files changed, 54980 insertions(+), 43824 deletions(-) create mode 100644 web-console/src/hooks/use-hash-and-local-storage-hybrid-state.ts create mode 100644 web-console/src/utils/base64-url.spec.ts create mode 100644 web-console/src/utils/base64-url.ts delete mode 100644 web-console/src/views/explore-view/column-picker/column-picker.tsx rename web-console/src/views/explore-view/{ => components}/column-picker-menu/column-picker-menu.scss (100%) rename web-console/src/views/explore-view/{ => components}/column-picker-menu/column-picker-menu.tsx (80%) create mode 100644 web-console/src/views/explore-view/components/column-picker/column-picker.tsx rename web-console/src/views/explore-view/{ => components}/control-pane/control-pane.scss (91%) create mode 100644 web-console/src/views/explore-view/components/control-pane/control-pane.tsx create mode 100644 web-console/src/views/explore-view/components/control-pane/expression-menu.scss create mode 100644 web-console/src/views/explore-view/components/control-pane/expression-menu.tsx create mode 100644 web-console/src/views/explore-view/components/control-pane/measure-menu.scss create mode 100644 web-console/src/views/explore-view/components/control-pane/measure-menu.tsx rename web-console/src/views/explore-view/{control-pane/columns-input.scss => components/control-pane/named-expressions-input.scss} (92%) rename web-console/src/views/explore-view/{control-pane/columns-input.tsx => components/control-pane/named-expressions-input.tsx} (50%) rename web-console/src/views/explore-view/{ => components}/control-pane/options-input.tsx (51%) rename web-console/src/views/explore-view/{ => components}/droppable-container/droppable-container.scss (97%) create mode 100644 web-console/src/views/explore-view/components/droppable-container/droppable-container.tsx rename web-console/src/views/explore-view/{ => components}/filter-pane/column-value/column-value.scss (100%) rename web-console/src/views/explore-view/{ => components}/filter-pane/column-value/column-value.tsx (100%) rename web-console/src/views/explore-view/{ => components}/filter-pane/filter-menu/contains-filter-control/contains-filter-control.scss (100%) create mode 100644 web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx rename web-console/src/views/explore-view/{ => components}/filter-pane/filter-menu/filter-menu.scss (78%) create mode 100644 web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx create mode 100644 web-console/src/views/explore-view/components/filter-pane/filter-menu/number-range-filter-control/number-range-filter-control.tsx rename web-console/src/views/explore-view/{ => components}/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.scss (100%) rename web-console/src/views/explore-view/{ => components}/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.tsx (51%) create mode 100644 web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.scss create mode 100644 web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx rename web-console/src/views/explore-view/{ => components}/filter-pane/filter-menu/time-relative-filter-control/time-relative-filter-control.tsx (70%) rename web-console/src/views/explore-view/{ => components}/filter-pane/filter-menu/values-filter-control/values-filter-control.scss (100%) create mode 100644 web-console/src/views/explore-view/components/filter-pane/filter-menu/values-filter-control/values-filter-control.tsx rename web-console/src/views/explore-view/{ => components}/filter-pane/filter-pane.scss (96%) rename web-console/src/views/explore-view/{ => components}/filter-pane/filter-pane.tsx (66%) rename web-console/src/views/explore-view/{modules => }/components/generic-output-table/generic-output-table.scss (94%) rename web-console/src/views/explore-view/{modules => }/components/generic-output-table/generic-output-table.tsx (70%) rename web-console/src/views/explore-view/{ => components}/highlight-bubble/highlight-bubble.scss (100%) rename web-console/src/views/explore-view/{ => components}/highlight-bubble/highlight-bubble.tsx (95%) create mode 100644 web-console/src/views/explore-view/components/index.ts create mode 100644 web-console/src/views/explore-view/components/issue/issue.scss create mode 100644 web-console/src/views/explore-view/components/issue/issue.tsx create mode 100644 web-console/src/views/explore-view/components/module-pane/module-pane.scss create mode 100644 web-console/src/views/explore-view/components/module-pane/module-pane.tsx rename web-console/src/views/explore-view/{tile-picker/tile-picker.scss => components/module-picker/module-picker.scss} (84%) rename web-console/src/views/explore-view/{tile-picker/tile-picker.tsx => components/module-picker/module-picker.tsx} (71%) create mode 100644 web-console/src/views/explore-view/components/preview-pane/preview-pane.scss create mode 100644 web-console/src/views/explore-view/components/preview-pane/preview-pane.tsx create mode 100644 web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.scss create mode 100644 web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.tsx create mode 100644 web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.scss create mode 100644 web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.tsx create mode 100644 web-console/src/views/explore-view/components/resource-pane/resource-pane.scss create mode 100644 web-console/src/views/explore-view/components/resource-pane/resource-pane.tsx rename web-console/src/views/explore-view/{ => components}/source-pane/source-pane.scss (88%) rename web-console/src/views/explore-view/{ => components}/source-pane/source-pane.tsx (52%) rename web-console/src/views/explore-view/{resource-pane/resource-pane.scss => components/source-query-pane/source-query-pane.scss} (73%) create mode 100644 web-console/src/views/explore-view/components/source-query-pane/source-query-pane.tsx create mode 100644 web-console/src/views/explore-view/components/sql-input/sql-input.tsx create mode 100644 web-console/src/views/explore-view/components/utc-date-input/utc-date-input.tsx delete mode 100644 web-console/src/views/explore-view/control-pane/aggregate-menu.tsx delete mode 100644 web-console/src/views/explore-view/control-pane/aggregates-input.tsx delete mode 100644 web-console/src/views/explore-view/control-pane/control-pane.tsx delete mode 100644 web-console/src/views/explore-view/control-pane/helpers.ts delete mode 100644 web-console/src/views/explore-view/droppable-container/droppable-container.tsx create mode 100644 web-console/src/views/explore-view/explore-state.ts delete mode 100644 web-console/src/views/explore-view/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx delete mode 100644 web-console/src/views/explore-view/filter-pane/filter-menu/custom-filter-control/custom-filter-control.tsx delete mode 100644 web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.tsx delete mode 100644 web-console/src/views/explore-view/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx delete mode 100644 web-console/src/views/explore-view/filter-pane/filter-menu/values-filter-control/values-filter-control.tsx create mode 100644 web-console/src/views/explore-view/models/expression-meta.ts create mode 100644 web-console/src/views/explore-view/models/highlight.ts create mode 100644 web-console/src/views/explore-view/models/index.ts create mode 100644 web-console/src/views/explore-view/models/measure-pattern.ts create mode 100644 web-console/src/views/explore-view/models/measure.ts create mode 100644 web-console/src/views/explore-view/models/parameter.ts create mode 100644 web-console/src/views/explore-view/models/query-source.ts create mode 100644 web-console/src/views/explore-view/models/stage.ts create mode 100644 web-console/src/views/explore-view/module-repository/module-repository.ts delete mode 100644 web-console/src/views/explore-view/modules/bar-chart-echarts-module.ts create mode 100644 web-console/src/views/explore-view/modules/bar-chart-module.tsx rename web-console/src/views/explore-view/modules/{table-react-module.scss => grouping-table-module.scss} (94%) create mode 100644 web-console/src/views/explore-view/modules/grouping-table-module.tsx delete mode 100644 web-console/src/views/explore-view/modules/multi-axis-chart-echarts-module.ts create mode 100644 web-console/src/views/explore-view/modules/multi-axis-chart-module.tsx delete mode 100644 web-console/src/views/explore-view/modules/pie-chart-echarts-module.ts create mode 100644 web-console/src/views/explore-view/modules/pie-chart-module.tsx rename web-console/src/views/explore-view/{control-pane/aggregate-menu.scss => modules/record-table-module.scss} (88%) create mode 100644 web-console/src/views/explore-view/modules/record-table-module.tsx delete mode 100644 web-console/src/views/explore-view/modules/table-react-module.tsx delete mode 100644 web-console/src/views/explore-view/modules/time-chart-echarts-module.ts create mode 100644 web-console/src/views/explore-view/modules/time-chart-module.tsx delete mode 100644 web-console/src/views/explore-view/modules/utils/__snapshots__/table-query.spec.ts.snap delete mode 100644 web-console/src/views/explore-view/modules/utils/utils.spec.ts delete mode 100644 web-console/src/views/explore-view/modules/utils/utils.ts create mode 100644 web-console/src/views/explore-view/query-macros/aggregate.ts rename web-console/src/views/explore-view/{modules/components => query-macros}/index.ts (92%) create mode 100644 web-console/src/views/explore-view/query-macros/max-data-time.ts delete mode 100644 web-console/src/views/explore-view/resource-pane/resource-pane.tsx create mode 100644 web-console/src/views/explore-view/utils/__snapshots__/table-query.spec.ts.snap create mode 100644 web-console/src/views/explore-view/utils/duration.spec.ts create mode 100644 web-console/src/views/explore-view/utils/duration.ts rename web-console/src/views/explore-view/{filter-pane/pattern-helpers.ts => utils/filter-pattern-helpers.ts} (69%) create mode 100644 web-console/src/views/explore-view/utils/general.ts create mode 100644 web-console/src/views/explore-view/utils/max-time-for-table.ts rename web-console/src/views/explore-view/{modules => }/utils/table-query.spec.ts (56%) rename web-console/src/views/explore-view/{modules => }/utils/table-query.ts (68%) create mode 100644 web-console/src/views/explore-view/utils/time-manipulation.spec.ts create mode 100644 web-console/src/views/explore-view/utils/time-manipulation.ts diff --git a/licenses.yaml b/licenses.yaml index 0ecffdb0a0ad..54bab473ac93 100644 --- a/licenses.yaml +++ b/licenses.yaml @@ -5155,24 +5155,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 @@ -5273,15 +5255,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 @@ -5641,6 +5614,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 @@ -6627,16 +6610,6 @@ license_file_path: licenses/bin/upper-case.MIT --- -name: "use-resize-observer" -license_category: binary -module: web-console -license_name: MIT License -copyright: Viktor Hubert -version: 9.1.0 -license_file_path: licenses/bin/use-resize-observer.MIT - ---- - name: "use-sync-external-store" license_category: binary module: web-console diff --git a/web-console/README.md b/web-console/README.md index ff5012344bd4..f9a7f7abbb5d 100644 --- a/web-console/README.md +++ b/web-console/README.md @@ -59,7 +59,8 @@ The console relies on [eslint](https://eslint.org) (and various plugins), [sass- - Install `dbaeumer.vscode-eslint` extension - Install `esbenp.prettier-vscode` extension -- Open User Settings (JSON) and set the following: +- Select `Open User Settings (JSON)` from the editor commnads (`Ctrl+Shift+P` or `Comand+Shift+P`) and set the following: + ```json "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, diff --git a/web-console/package-lock.json b/web-console/package-lock.json index 961630ad011b..968588e2e3e2 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 d0e864edb8fa..26103552e3a3 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/table-cell/__snapshots__/table-cell.spec.tsx.snap b/web-console/src/components/table-cell/__snapshots__/table-cell.spec.tsx.snap index 37c7ed6964ef..75814e8c7f01 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 @@ -14,7 +14,7 @@ exports[`TableCell matches snapshot Date 1`] = ` class="table-cell timestamp" data-tooltip="1645664523000" > - 2022-02-24T01:02:03.000Z + 2022-02-24 01:02:03 `; diff --git a/web-console/src/components/table-cell/table-cell.tsx b/web-console/src/components/table-cell/table-cell.tsx index ebf3cfd6863a..6bd53271d6a9 100644 --- a/web-console/src/components/table-cell/table-cell.tsx +++ b/web-console/src/components/table-cell/table-cell.tsx @@ -21,7 +21,7 @@ import * as JSONBig from 'json-bigint-native'; import React, { useState } from 'react'; import { ShowValueDialog } from '../../dialogs/show-value-dialog/show-value-dialog'; -import { isSimpleArray } from '../../utils'; +import { isSimpleArray, prettyFormatIsoDateWithMsIfNeeded } from '../../utils'; import { ActionIcon } from '../action-icon/action-icon'; import './table-cell.scss'; @@ -97,8 +97,8 @@ export const TableCell = React.memo(function TableCell(props: TableCellProps) { } else if (value instanceof Date) { const dateValue = value.valueOf(); return ( -
- {isNaN(dateValue) ? 'Invalid date' : value.toISOString()} +
+ {isNaN(dateValue) ? 'Invalid date' : prettyFormatIsoDateWithMsIfNeeded(value.toISOString())}
); } else if (isSimpleArray(value)) { diff --git a/web-console/src/entry.scss b/web-console/src/entry.scss index 5acd978d6ebf..73cbb238eb16 100644 --- a/web-console/src/entry.scss +++ b/web-console/src/entry.scss @@ -75,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/hooks/index.ts b/web-console/src/hooks/index.ts index 62b22443f3bb..515fbe0db361 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 000000000000..03e51924cfa0 --- /dev/null +++ b/web-console/src/hooks/use-hash-and-local-storage-hybrid-state.ts @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as JSONBig from 'json-bigint-native'; +import type { Dispatch, SetStateAction } from 'react'; +import { 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( + prefix: string, + key: LocalStorageKeys, + initialValue?: T, + inflateFn?: (x: any) => T, +): [T, Dispatch>] { + 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> = (value: T | ((prevState: T) => T)) => { + const valueToStore = value instanceof Function ? value(state) : value; + location.hash = encodeHashState(prefix, value); + setState(valueToStore); + localStorageSetJson(key, valueToStore); + }; + return [state, setValue]; +} diff --git a/web-console/src/hooks/use-query-manager.ts b/web-console/src/hooks/use-query-manager.ts index 384443745838..36bf83f6ecdd 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( backgroundStatusCheck || ((() => {}) as any), ); - const [resultState, setResultState] = useState>(initState || QueryState.INIT); + const resultStateRef = useRef>(initState || QueryState.INIT); + const [_, setResultState] = useState>(initState || QueryState.INIT); function makeQueryManager() { return new QueryManager({ @@ -47,7 +48,10 @@ export function useQueryManager( initState, processQuery: concreteProcessQuery, backgroundStatusCheck: backgroundStatusCheck ? concreteBackgroundStatusCheck : undefined, - onStateChange: setResultState, + onStateChange: s => { + resultStateRef.current = s; + setResultState(s); + }, }); } @@ -75,12 +79,11 @@ export function useQueryManager( // 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(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/utils/base64-url.spec.ts b/web-console/src/utils/base64-url.spec.ts new file mode 100644 index 000000000000..e509779f4aef --- /dev/null +++ b/web-console/src/utils/base64-url.spec.ts @@ -0,0 +1,39 @@ +/* + * 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 { base64UrlDecode, base64UrlEncode } from './base64-url'; + +describe('base64-url', () => { + it('works in simple case', () => { + const originalString = 'Hello, World!'; + const encoded = base64UrlEncode(originalString); + expect(encoded).toEqual('SGVsbG8sIFdvcmxkIQ'); + + const decoded = base64UrlDecode(encoded); + expect(decoded).toEqual(originalString); + }); + + 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); + } + }); +}); diff --git a/web-console/src/utils/base64-url.ts b/web-console/src/utils/base64-url.ts new file mode 100644 index 000000000000..d93b9ff48970 --- /dev/null +++ b/web-console/src/utils/base64-url.ts @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export 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 '=' +} + +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/date.ts b/web-console/src/utils/date.ts index f6960b96677e..1d5aa4f61e95 100644 --- a/web-console/src/utils/date.ts +++ b/web-console/src/utils/date.ts @@ -27,11 +27,35 @@ export function isNonNullRange(range: DateRange): range is NonNullDateRange { export function dateToIsoDateString(date: Date): string { return date.toISOString().slice(0, 10); } - -export function prettyFormatIsoDate(isoDate: string | Date): string { +export function prettyFormatIsoDateWithMsIfNeeded(isoDate: string | Date): string { return (typeof isoDate === 'string' ? isoDate : isoDate.toISOString()) .replace('T', ' ') - .replace(/\.\d\d\dZ$/, ''); + .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 + } + + 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 4fe2e0b63987..c6885ca43379 100644 --- a/web-console/src/utils/general.tsx +++ b/web-console/src/utils/general.tsx @@ -184,11 +184,30 @@ export function mapRecord( const newRecord: Record = {}; 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( + record: Record, + fn: (value: T, key: string) => T, +): Record { + const newRecord: Record = {}; + 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( array: readonly T[], keyFn: (x: T, index: number) => string, @@ -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,20 +331,27 @@ 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) ); } @@ -324,17 +360,17 @@ export function formatDurationWithMsIfNeeded(ms: NumberLike): string { } 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); } } @@ -378,6 +414,16 @@ export function filterMap(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(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( 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 57c95c6dc65a..edea5ad0a520 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'; diff --git a/web-console/src/utils/local-storage-keys.tsx b/web-console/src/utils/local-storage-keys.tsx index 7c797d2c89ad..d4efec06e224 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/utils/table-helpers.ts b/web-console/src/utils/table-helpers.ts index 006d56dff48e..90df7fa10648 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 8ebbd6938b3c..7f48655cb1c4 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': return IconNames.SORT_NUMERICAL_DESC; + case 'COMPLEX': + return IconNames.LABEL; + + case 'COMPLEX': + 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 237e84b99e4e..000000000000 --- 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 ( - { - onSelectedColumnNameChange(e.target.value); - }} - > - {availableColumns?.map((column, i) => ( - - )) || } - - ); -}); 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 16d3cbee9b13..e25bbd6b7884 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 /> @@ -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 ( } 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 000000000000..81c88fbf9b78 --- /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 ( + + {availableColumns?.map((column, i) => ( + { + setIsOpen(false); + onSelectedColumnNameChange(column.name); + }} + shouldDismissPopover={shouldDismissPopover} + /> + )) || } + + } + > +
+ + )} + + ); +}; diff --git a/web-console/src/views/explore-view/components/control-pane/measure-menu.scss b/web-console/src/views/explore-view/components/control-pane/measure-menu.scss new file mode 100644 index 000000000000..f649a7c839c7 --- /dev/null +++ b/web-console/src/views/explore-view/components/control-pane/measure-menu.scss @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import '../../../../variables'; + +.measure-menu { + width: 500px; + height: 47vh; + padding: 15px; + display: flex; + flex-direction: column; + + .tab-bar { + & > * { + flex: 1 !important; + } + } + + & > .#{$bp-ns}-menu { + flex: 1; + padding: 0; + overflow: auto; + + &:not(:last-child) { + margin-bottom: 15px; + } + } + + .editor-container { + flex: 1; + margin-bottom: 15px; + } + + .measure-column-line { + display: flex; + gap: 8px; + + .column-group { + flex: 1; + } + } + + .expander { + flex: 1; + } + + .button-bar { + display: flex; + gap: 10px; + + .button-separator { + flex: 1; + } + } +} diff --git a/web-console/src/views/explore-view/components/control-pane/measure-menu.tsx b/web-console/src/views/explore-view/components/control-pane/measure-menu.tsx new file mode 100644 index 000000000000..f1296dd8ffc0 --- /dev/null +++ b/web-console/src/views/explore-view/components/control-pane/measure-menu.tsx @@ -0,0 +1,291 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Button, + ButtonGroup, + FormGroup, + HTMLSelect, + Icon, + InputGroup, + Intent, + Menu, + MenuItem, + Popover, + Position, +} from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import type { Column } from '@druid-toolkit/query'; +import { SqlAlias, SqlExpression } from '@druid-toolkit/query'; +import React, { useState } from 'react'; + +import { AppToaster } from '../../../../singletons'; +import { columnToIcon } from '../../../../utils'; +import { Measure, MeasurePattern } from '../../models'; +import { ColumnPicker } from '../column-picker/column-picker'; +import { SqlInput } from '../sql-input/sql-input'; + +import './measure-menu.scss'; + +type MeasureMenuTab = 'saved' | 'compose' | 'sql'; + +export interface MeasureMenuProps { + columns: readonly Column[]; + measures: readonly Measure[]; + initMeasure: Measure | undefined; + onSelectMeasure(measure: Measure): void; + onClose(): void; + onAddToSourceQueryAsMeasure?(measure: Measure): void; +} + +export const MeasureMenu = function MeasureMenu(props: MeasureMenuProps) { + const { columns, measures, initMeasure, onSelectMeasure, onClose, onAddToSourceQueryAsMeasure } = + props; + + const [tab, setTab] = useState(() => { + if (!initMeasure) return 'compose'; + if (measures.some(measure => measure.equivalent(initMeasure))) return 'saved'; + return MeasurePattern.fit(initMeasure.expression) ? 'compose' : 'sql'; + }); + const [outputName, setOutputName] = useState(initMeasure?.as || ''); + const [measurePattern, setMeasurePattern] = useState( + initMeasure ? MeasurePattern.fit(initMeasure.expression) : undefined, + ); + const [formula, setFormula] = useState(initMeasure ? String(initMeasure.expression) : ''); + + function getMeasure(): Measure | undefined { + switch (tab) { + case 'saved': + return; + + case 'compose': { + if (!measurePattern) throw new Error('no measure pattern'); + const expression = measurePattern.toExpression(); + return new Measure({ + expression, + as: outputName, + }); + } + + case 'sql': { + if (!formula) { + AppToaster.show({ + message: 'Formula is empty', + intent: Intent.DANGER, + }); + return; + } + + let parsedFormula: SqlExpression; + try { + parsedFormula = SqlExpression.parse(formula); + } catch (e) { + AppToaster.show({ + message: `Could not parse formula: ${e.message}`, + intent: Intent.DANGER, + }); + return; + } + + if (parsedFormula instanceof SqlAlias) { + return new Measure({ + expression: parsedFormula.getUnderlyingExpression(), + as: outputName || parsedFormula.getAliasName(), + }); + } + + return new Measure({ + expression: parsedFormula, + as: outputName, + }); + } + } + } + + const actionDisabled = + (tab === 'compose' && !measurePattern) || (tab === 'sql' && formula === ''); + return ( +
+ + +
+ )} + + ); +}; diff --git a/web-console/src/views/explore-view/control-pane/columns-input.scss b/web-console/src/views/explore-view/components/control-pane/named-expressions-input.scss similarity index 92% rename from web-console/src/views/explore-view/control-pane/columns-input.scss rename to web-console/src/views/explore-view/components/control-pane/named-expressions-input.scss index 2f7fc37e554d..5dec6be7511e 100644 --- a/web-console/src/views/explore-view/control-pane/columns-input.scss +++ b/web-console/src/views/explore-view/components/control-pane/named-expressions-input.scss @@ -16,10 +16,10 @@ * limitations under the License. */ -@import '../../../variables'; +@import '../../../../variables'; -.columns-input { - .#{$ns}-tag-input-values .#{$ns}-tag { +.named-expressions-input { + .#{$bp-ns}-tag-input-values .#{$bp-ns}-tag { &.drop-before::after { content: ''; height: 24px; diff --git a/web-console/src/views/explore-view/control-pane/columns-input.tsx b/web-console/src/views/explore-view/components/control-pane/named-expressions-input.tsx similarity index 50% rename from web-console/src/views/explore-view/control-pane/columns-input.tsx rename to web-console/src/views/explore-view/components/control-pane/named-expressions-input.tsx index 50e6b88c83ef..ae498341ecd6 100644 --- a/web-console/src/views/explore-view/control-pane/columns-input.tsx +++ b/web-console/src/views/explore-view/components/control-pane/named-expressions-input.tsx @@ -18,27 +18,13 @@ import { Classes, Popover, Position, Tag } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import type { ExpressionMeta } from '@druid-toolkit/visuals-core'; import classNames from 'classnames'; import type { JSX } from 'react'; import React, { useCallback, useState } from 'react'; -import { ColumnPickerMenu } from '../column-picker-menu/column-picker-menu'; +import type { ExpressionMeta, Measure } from '../../models'; -import './columns-input.scss'; - -export interface ColumnsInputProps { - columns: ExpressionMeta[]; - value: ExpressionMeta[]; - onValueChange(value: ExpressionMeta[]): void; - allowDuplicates?: boolean; - allowReordering?: boolean; - - /** - * If you want to take control of the way new columns are picked and added - */ - pickerMenu?: (columns: ExpressionMeta[]) => JSX.Element; -} +import './named-expressions-input.scss'; function moveInArray(arr: any[], fromIndex: number, toIndex: number) { arr = arr.concat(); @@ -49,16 +35,24 @@ function moveInArray(arr: any[], fromIndex: number, toIndex: number) { return arr; } -export const ColumnsInput = function ColumnsInput(props: ColumnsInputProps) { - const { columns, value, onValueChange, allowDuplicates, allowReordering, pickerMenu } = props; +export interface NamesExpressionsInputProps { + values: M[]; + onValuesChange(value: M[]): void; + allowReordering?: boolean; + singleton?: boolean; + nonEmpty?: boolean; + itemMenu: (item: M | undefined, onClose: () => void) => JSX.Element; +} - const availableColumns = allowDuplicates - ? columns - : columns.filter(o => !value.find(_ => _.name === o.name)); +export const NamedExpressionsInput = function NamedExpressionsInput< + M extends ExpressionMeta | Measure, +>(props: NamesExpressionsInputProps) { + const { values, onValuesChange, allowReordering, singleton, nonEmpty, itemMenu } = props; const [dragIndex, setDragIndex] = useState(-1); const [dropBefore, setDropBefore] = useState(false); const [dropIndex, setDropIndex] = useState(-1); + const [menuOpenOn, setMenuOpenOn] = useState<{ openOn?: M }>(); const startDrag = useCallback((e: React.DragEvent, i: number) => { e.dataTransfer.effectAllowed = 'move'; @@ -89,52 +83,72 @@ export const ColumnsInput = function ColumnsInput(props: ColumnsInputProps) { if (correctedDropIndex > dragIndex) correctedDropIndex--; if (correctedDropIndex !== dragIndex) { - onValueChange(moveInArray(value, dragIndex, correctedDropIndex)); + onValuesChange(moveInArray(values, dragIndex, correctedDropIndex)); } } setDragIndex(-1); setDropIndex(-1); setDropBefore(false); }, - [dropIndex, dragIndex, onValueChange, value, dropBefore], + [dropIndex, dragIndex, onValuesChange, values, dropBefore], ); + const menuOnClose = () => { + setMenuOpenOn(undefined); + }; + + const canRemove = !nonEmpty || values.length > 1; return ( -
+
- {value.map((c, i) => ( - onDragOver(e, i)} + {values.map((c, i) => ( + startDrag(e, i)} - onRemove={() => { - onValueChange(value.filter(v => v !== c)); - }} + isOpen={Boolean(menuOpenOn && menuOpenOn.openOn === c)} + position={Position.BOTTOM} + onClose={menuOnClose} + content={itemMenu(c, menuOnClose)} > - {c.name} - + setMenuOpenOn({ openOn: c })} + draggable={allowReordering} + onDragOver={e => onDragOver(e, i)} + onDragStart={e => startDrag(e, i)} + onRemove={ + canRemove + ? () => { + onValuesChange(values.filter(v => v !== c)); + } + : undefined + } + > + {c.name} + + ))} - onValueChange(value.concat(c))} - /> - ) - } - > - - + {(!singleton || !values.length) && ( + + setMenuOpenOn({})} /> + + )}
); diff --git a/web-console/src/views/explore-view/control-pane/options-input.tsx b/web-console/src/views/explore-view/components/control-pane/options-input.tsx similarity index 51% rename from web-console/src/views/explore-view/control-pane/options-input.tsx rename to web-console/src/views/explore-view/components/control-pane/options-input.tsx index 9eec12da8305..286889bf6b23 100644 --- a/web-console/src/views/explore-view/control-pane/options-input.tsx +++ b/web-console/src/views/explore-view/components/control-pane/options-input.tsx @@ -16,57 +16,83 @@ * limitations under the License. */ -import { Classes, Menu, MenuItem, Popover, Position, Tag } from '@blueprintjs/core'; +import { Classes, Icon, Menu, MenuItem, Popover, Position, Tag } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import type { OptionValue, ParameterDefinition } from '@druid-toolkit/visuals-core'; -import { getPluginOptionLabel } from '@druid-toolkit/visuals-core'; import classNames from 'classnames'; import React from 'react'; +import type { OptionValue } from '../../models'; + export interface OptionsInputProps { - parameter: ParameterDefinition; options: readonly OptionValue[]; value: OptionValue[]; onValueChange(value: OptionValue[]): void; + optionLabel?(o: OptionValue): string; + allowDuplicates?: boolean; + nonEmpty?: boolean; } export const OptionsInput = function OptionsInput(props: OptionsInputProps) { - const { options, value, onValueChange, parameter } = props; - - if (parameter.type !== 'options') { - return null; - } + const { options, value, onValueChange, optionLabel = String, allowDuplicates, nonEmpty } = props; const selectedOptions: OptionValue[] = value.filter(v => options.includes(v)); - const availableOptions = parameter.allowDuplicates + const availableOptions = allowDuplicates ? options : options.filter(o => !value.find(v => v === o)); + const canRemove = !nonEmpty || options.length > 1; return (
- {selectedOptions.map((o, i) => ( - ( + { - onValueChange(value.filter(v => v !== o)); - }} + position={Position.BOTTOM} + content={ + + {(allowDuplicates + ? options + : options.filter(o => o === selectedOption || !value.find(v => v === o)) + ).map((ao, j) => ( + : undefined + } + onClick={() => { + onValueChange(value.map(v => (v === selectedOption ? ao : v))); + }} + /> + ))} + + } > - {getPluginOptionLabel(o, parameter)} - + { + onValueChange(value.filter(v => v !== selectedOption)); + } + : undefined + } + > + {optionLabel(selectedOption)} + + ))} - {availableOptions.map((o, i) => ( + {availableOptions.map((ao, i) => ( { - onValueChange(value.concat(o)); + onValueChange(value.concat(ao)); }} /> ))} diff --git a/web-console/src/views/explore-view/droppable-container/droppable-container.scss b/web-console/src/views/explore-view/components/droppable-container/droppable-container.scss similarity index 97% rename from web-console/src/views/explore-view/droppable-container/droppable-container.scss rename to web-console/src/views/explore-view/components/droppable-container/droppable-container.scss index 74b8a3973843..0283c69be7b4 100644 --- a/web-console/src/views/explore-view/droppable-container/droppable-container.scss +++ b/web-console/src/views/explore-view/components/droppable-container/droppable-container.scss @@ -16,7 +16,7 @@ * limitations under the License. */ -@import '../../../variables'; +@import '../../../../variables'; .droppable-container { position: relative; diff --git a/web-console/src/views/explore-view/components/droppable-container/droppable-container.tsx b/web-console/src/views/explore-view/components/droppable-container/droppable-container.tsx new file mode 100644 index 000000000000..ef8f36d25aa0 --- /dev/null +++ b/web-console/src/views/explore-view/components/droppable-container/droppable-container.tsx @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Column } from '@druid-toolkit/query'; +import classNames from 'classnames'; +import React, { forwardRef, useState } from 'react'; + +import { DragHelper } from '../../drag-helper'; +import type { Measure } from '../../models'; + +import './droppable-container.scss'; + +export interface DroppableContainerProps extends React.HTMLAttributes { + onDropColumn(column: Column): void; + onDropMeasure?(measure: Measure): void; + children?: React.ReactNode; +} + +export const DroppableContainer = forwardRef( + function DroppableContainer(props, ref) { + const { className, onDropColumn, onDropMeasure, children, ...rest } = props; + const [dropHover, setDropHover] = useState(false); + + return ( +
{ + if (!DragHelper.dragColumn && !(onDropMeasure && DragHelper.dragMeasure)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDropHover(true); + }} + onDragLeave={e => { + const currentTarget = e.currentTarget; + const relatedTarget = e.relatedTarget; + if (currentTarget.contains(relatedTarget as any)) return; + setDropHover(false); + }} + onDrop={() => { + if (!DragHelper.dragColumn && !(onDropMeasure && DragHelper.dragMeasure)) return; + const dragColumn = DragHelper.dragColumn; + const dragMeasure = DragHelper.dragMeasure; + setDropHover(false); + if (dragColumn) { + DragHelper.dragColumn = undefined; + onDropColumn(dragColumn); + } else if (dragMeasure && onDropMeasure) { + DragHelper.dragMeasure = undefined; + onDropMeasure(dragMeasure); + } + }} + > + {children} +
+ ); + }, +); diff --git a/web-console/src/views/explore-view/filter-pane/column-value/column-value.scss b/web-console/src/views/explore-view/components/filter-pane/column-value/column-value.scss similarity index 100% rename from web-console/src/views/explore-view/filter-pane/column-value/column-value.scss rename to web-console/src/views/explore-view/components/filter-pane/column-value/column-value.scss diff --git a/web-console/src/views/explore-view/filter-pane/column-value/column-value.tsx b/web-console/src/views/explore-view/components/filter-pane/column-value/column-value.tsx similarity index 100% rename from web-console/src/views/explore-view/filter-pane/column-value/column-value.tsx rename to web-console/src/views/explore-view/components/filter-pane/column-value/column-value.tsx diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/contains-filter-control/contains-filter-control.scss b/web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.scss similarity index 100% rename from web-console/src/views/explore-view/filter-pane/filter-menu/contains-filter-control/contains-filter-control.scss rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.scss diff --git a/web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx new file mode 100644 index 000000000000..353179c905e4 --- /dev/null +++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FormGroup, InputGroup, Menu, MenuItem } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import type { ContainsFilterPattern, QueryResult } from '@druid-toolkit/query'; +import { C, F, filterPatternToExpression, SqlExpression, SqlQuery } from '@druid-toolkit/query'; +import React, { useMemo } from 'react'; + +import { useQueryManager } from '../../../../../../hooks'; +import type { QuerySource } from '../../../../models'; + +import './contains-filter-control.scss'; + +export interface ContainsFilterControlProps { + querySource: QuerySource; + filter: SqlExpression | undefined; + filterPattern: ContainsFilterPattern; + setFilterPattern(filterPattern: ContainsFilterPattern): void; + runSqlQuery(query: string | SqlQuery): Promise; +} + +export const ContainsFilterControl = React.memo(function ContainsFilterControl( + props: ContainsFilterControlProps, +) { + const { querySource, filter, filterPattern, setFilterPattern, runSqlQuery } = props; + const { column, negated, contains } = filterPattern; + + const previewQuery = useMemo( + () => + SqlQuery.from(querySource.query) + .addSelect(F.cast(C(column), 'VARCHAR').as('c'), { addToGroupBy: 'end' }) + .changeWhereExpression( + SqlExpression.and( + filter, + contains ? filterPatternToExpression(filterPattern) : undefined, + ), + ) + .changeOrderByExpression(F.count().toOrderByExpression('DESC')) + .toString(), + // eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps + [querySource.query, filter, column, contains, negated], + ); + + const [previewState] = useQueryManager({ + query: previewQuery, + debounceIdle: 100, + debounceLoading: 500, + processQuery: async query => { + const vs = await runSqlQuery(query); + return (vs.getColumnByName('c') || []).map(String); + }, + }); + + return ( +
+ + setFilterPattern({ ...filterPattern, contains: e.target.value })} + placeholder="Search string" + /> + + + + {previewState.data?.map((v, i) => ( + + ))} + {previewState.loading && } + {previewState.error && ( + + )} + + +
+ ); +}); diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.scss similarity index 78% rename from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.scss index dbb850df020d..b5659da421c1 100644 --- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss +++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.scss @@ -16,20 +16,22 @@ * limitations under the License. */ -@import '../../../../variables'; - .filter-menu { - width: 400px; + width: 500px; + padding: 15px; - &.main { - padding: 15px; + .tab-bar { + & > * { + flex: 1 !important; + } } - .controls .#{$ns}-form-content { + .controls { display: flex; - gap: 15px; + gap: 10px; + align-items: flex-end; - .type-selector { + .column-form-group { flex: 1; } } @@ -39,6 +41,11 @@ } .button-bar { - text-align: right; + display: flex; + gap: 10px; + + .button-separator { + flex: 1; + } } } diff --git a/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx new file mode 100644 index 000000000000..7c48850d71d0 --- /dev/null +++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx @@ -0,0 +1,385 @@ +/* + * 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 { 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 PATTERN_TYPE_TO_NAME: Partial> = { + values: 'Values', + contains: 'Contains', + regexp: 'Regular expression', + // mvContains: 'Multi-value contains', + numberRange: 'Number range', + timeInterval: 'Time interval', + timeRelative: 'Time relative', +}; + +type FilterMenuTab = 'compose' | 'sql'; + +export interface FilterMenuProps { + querySource: QuerySource; + filter: SqlExpression; + initPattern?: FilterPattern; + onPatternChange(newPattern: FilterPattern): void; + onClose(): void; + runSqlQuery(query: string | SqlQuery): Promise; + onAddToSourceQueryAsColumn?(expression: SqlExpression): void; + onMoveToSourceQueryAsClause?(expression: SqlExpression): void; +} + +export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps) { + const { + querySource, + filter, + initPattern, + onPatternChange, + onClose, + runSqlQuery, + onAddToSourceQueryAsColumn, + onMoveToSourceQueryAsClause, + } = props; + + const [tab, setTab] = useState(initPattern?.type === 'custom' ? 'sql' : 'compose'); + const [formula, setFormula] = useState( + initPattern?.type === 'custom' ? filterPatternToExpression(initPattern).toString() : '', + ); + const [pattern, setPattern] = useState(initPattern); + const { columns } = querySource; + + function onAcceptPattern(pattern: FilterPattern) { + onPatternChange(pattern); + onClose(); + } + + const negated = Boolean(pattern?.negated); + + let cont: JSX.Element; + switch (pattern?.type) { + case 'values': + cont = ( + + ); + break; + + case 'contains': + cont = ( + + ); + break; + + case 'regexp': + cont = ( + + ); + break; + + case 'numberRange': + cont = ( + + ); + break; + + case 'timeInterval': + cont = ( + + ); + break; + + case 'timeRelative': + cont = ( + + ); + break; + + case 'mvContains': + case 'custom': { + const columnName: string | undefined = + pattern.type === 'custom' + ? pattern.expression?.getFirstColumnName() + : (pattern as any).column; + const column = columns.find(({ name }) => name === columnName); + cont = ( + + +

The current filter can only be edited as SQL.

+

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

+ {column && ( +

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

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