mirror of https://github.com/apache/druid.git
Web console: add explore view (#14602)
This PR adds a simple, stateless, SQL backed, data exploration view to the web console. The idea is to let users explore data in Druid with point-and-click interaction and visualizations (instead of writing SQL and looking at a table). This can provide faster time-to-value for a user new to Druid and can allow a Druid veteran to quickly chart some data that they care about.
This commit is contained in:
parent
295653648b
commit
f5784e66d3
|
@ -5367,6 +5367,24 @@ version: 0.20.5
|
|||
|
||||
---
|
||||
|
||||
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
|
||||
|
@ -5935,6 +5953,15 @@ license_file_path: licenses/bin/dot-case.MIT
|
|||
|
||||
---
|
||||
|
||||
name: "echarts"
|
||||
license_category: binary
|
||||
module: web-console
|
||||
license_name: Apache License version 2.0
|
||||
copyright: Apache Software Foundation
|
||||
version: 5.4.2
|
||||
|
||||
---
|
||||
|
||||
name: "emotion"
|
||||
license_category: binary
|
||||
module: web-console
|
||||
|
@ -6809,7 +6836,7 @@ license_category: binary
|
|||
module: web-console
|
||||
license_name: Zero-Clause BSD
|
||||
copyright: Microsoft Corp.
|
||||
version: 2.5.3
|
||||
version: 2.3.0
|
||||
license_file_path: licenses/bin/tslib.0BSD
|
||||
|
||||
---
|
||||
|
@ -6844,6 +6871,16 @@ 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
|
||||
|
@ -6884,6 +6921,16 @@ license_file_path: licenses/bin/yaml.ISC
|
|||
|
||||
---
|
||||
|
||||
name: "zrender"
|
||||
license_category: binary
|
||||
module: web-console
|
||||
license_name: BSD-3-Clause License
|
||||
copyright: Baidu Inc.
|
||||
version: 5.4.3
|
||||
license_file_path: licenses/bin/zrender.BSD3
|
||||
|
||||
---
|
||||
|
||||
name: "zustand"
|
||||
license_category: binary
|
||||
module: web-console
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright 2018 Viktor Hubert <rpgmorpheus@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
|
@ -0,0 +1,29 @@
|
|||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2017, Baidu Inc.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@ -15,6 +15,8 @@
|
|||
"@blueprintjs/icons": "^4.16.0",
|
||||
"@blueprintjs/popover2": "^1.14.9",
|
||||
"@druid-toolkit/query": "^0.20.5",
|
||||
"@druid-toolkit/visuals-core": "^0.3.3",
|
||||
"@druid-toolkit/visuals-react": "^0.3.3",
|
||||
"ace-builds": "~1.4.14",
|
||||
"axios": "^0.26.1",
|
||||
"classnames": "^2.2.6",
|
||||
|
@ -24,6 +26,7 @@
|
|||
"d3-axis": "^2.1.0",
|
||||
"d3-scale": "^3.3.0",
|
||||
"d3-selection": "^2.0.0",
|
||||
"echarts": "^5.4.1",
|
||||
"file-saver": "^2.0.2",
|
||||
"follow-redirects": "^1.14.7",
|
||||
"fontsource-open-sans": "^3.0.9",
|
||||
|
@ -2586,6 +2589,30 @@
|
|||
"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",
|
||||
|
@ -8129,6 +8156,20 @@
|
|||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.4.2.tgz",
|
||||
"integrity": "sha512-2W3vw3oI2tWJdyAz+b8DuWS0nfXtSDqlDmqgin/lfzbkB01cuMEN66KWBlmur3YMp5nEDEEt5s23pllnAzB4EA==",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "5.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
|
@ -23217,6 +23258,18 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
@ -24400,6 +24453,19 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "5.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.4.3.tgz",
|
||||
"integrity": "sha512-DRUM4ZLnoaT0PBVvGBDO9oWIDBKFdAVieNWxWwK0niYzJCMwGchRk21/hsE+RKkIveH3XHCyvXcJDkgLVvfizQ==",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.3.2.tgz",
|
||||
|
@ -26633,6 +26699,27 @@
|
|||
"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",
|
||||
|
@ -31038,6 +31125,22 @@
|
|||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
|
||||
"dev": true
|
||||
},
|
||||
"echarts": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.4.2.tgz",
|
||||
"integrity": "sha512-2W3vw3oI2tWJdyAz+b8DuWS0nfXtSDqlDmqgin/lfzbkB01cuMEN66KWBlmur3YMp5nEDEEt5s23pllnAzB4EA==",
|
||||
"requires": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "5.4.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
|
@ -42509,6 +42612,14 @@
|
|||
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==",
|
||||
"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",
|
||||
|
@ -43417,6 +43528,21 @@
|
|||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"dev": true
|
||||
},
|
||||
"zrender": {
|
||||
"version": "5.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.4.3.tgz",
|
||||
"integrity": "sha512-DRUM4ZLnoaT0PBVvGBDO9oWIDBKFdAVieNWxWwK0niYzJCMwGchRk21/hsE+RKkIveH3XHCyvXcJDkgLVvfizQ==",
|
||||
"requires": {
|
||||
"tslib": "2.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"zustand": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.3.2.tgz",
|
||||
|
|
|
@ -69,6 +69,8 @@
|
|||
"@blueprintjs/icons": "^4.16.0",
|
||||
"@blueprintjs/popover2": "^1.14.9",
|
||||
"@druid-toolkit/query": "^0.20.5",
|
||||
"@druid-toolkit/visuals-core": "^0.3.3",
|
||||
"@druid-toolkit/visuals-react": "^0.3.3",
|
||||
"ace-builds": "~1.4.14",
|
||||
"axios": "^0.26.1",
|
||||
"classnames": "^2.2.6",
|
||||
|
@ -78,6 +80,7 @@
|
|||
"d3-axis": "^2.1.0",
|
||||
"d3-scale": "^3.3.0",
|
||||
"d3-selection": "^2.0.0",
|
||||
"echarts": "^5.4.1",
|
||||
"file-saver": "^2.0.2",
|
||||
"follow-redirects": "^1.14.7",
|
||||
"fontsource-open-sans": "^3.0.9",
|
||||
|
|
|
@ -187,6 +187,7 @@ checker.init(
|
|||
if (name === 'asap') publisher = 'Contributors';
|
||||
if (name === 'diff-match-patch') publisher = 'Google';
|
||||
if (name === 'esutils') publisher = 'Yusuke Suzuki'; // https://github.com/estools/esutils#license
|
||||
if (name === 'echarts') publisher = 'Apache Software Foundation';
|
||||
}
|
||||
|
||||
if (!publisher) {
|
||||
|
|
|
@ -158,6 +158,19 @@ exports[`HeaderBar matches snapshot 1`] = `
|
|||
shouldDismissPopover={true}
|
||||
text="Lookups"
|
||||
/>
|
||||
<Blueprint4.MenuDivider />
|
||||
<Blueprint4.MenuItem
|
||||
active={false}
|
||||
disabled={false}
|
||||
href="#explore"
|
||||
icon="compass"
|
||||
label="(experimental)"
|
||||
multiline={false}
|
||||
popoverProps={Object {}}
|
||||
selected={false}
|
||||
shouldDismissPopover={true}
|
||||
text="Explore"
|
||||
/>
|
||||
</Blueprint4.Menu>
|
||||
}
|
||||
defaultIsOpen={false}
|
||||
|
|
|
@ -70,6 +70,7 @@ export type HeaderActiveTab =
|
|||
| 'services'
|
||||
| 'workbench'
|
||||
| 'sql-data-loader'
|
||||
| 'explore'
|
||||
| 'lookups';
|
||||
|
||||
const DruidLogo = React.memo(function DruidLogo() {
|
||||
|
@ -286,6 +287,15 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
|
|||
disabled={!capabilities.hasCoordinatorAccess()}
|
||||
selected={active === 'lookups'}
|
||||
/>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
icon={IconNames.COMPASS}
|
||||
text="Explore"
|
||||
label="(experimental)"
|
||||
href="#explore"
|
||||
disabled={!capabilities.hasSql()}
|
||||
selected={active === 'explore'}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
|
|
|
@ -36,6 +36,10 @@
|
|||
padding: 10px;
|
||||
}
|
||||
|
||||
&.thinner {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.app-view {
|
||||
position: relative;
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
import { HotkeysProvider, Intent } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import classNames from 'classnames';
|
||||
import type { JSX } from 'react';
|
||||
import React from 'react';
|
||||
import type { RouteComponentProps } from 'react-router';
|
||||
import { Redirect } from 'react-router';
|
||||
|
@ -34,6 +35,7 @@ import { AppToaster } from './singletons';
|
|||
import { compact, localStorageGetJson, LocalStorageKeys, QueryManager } from './utils';
|
||||
import {
|
||||
DatasourcesView,
|
||||
ExploreView,
|
||||
HomeView,
|
||||
LoadDataView,
|
||||
LookupsView,
|
||||
|
@ -220,7 +222,7 @@ export class ConsoleApplication extends React.PureComponent<
|
|||
private readonly wrapInViewContainer = (
|
||||
active: HeaderActiveTab,
|
||||
el: JSX.Element,
|
||||
classType: 'normal' | 'narrow-pad' | 'thin' = 'normal',
|
||||
classType: 'normal' | 'narrow-pad' | 'thin' | 'thinner' = 'normal',
|
||||
) => {
|
||||
const { capabilities } = this.state;
|
||||
|
||||
|
@ -414,6 +416,10 @@ export class ConsoleApplication extends React.PureComponent<
|
|||
);
|
||||
};
|
||||
|
||||
private readonly wrappedExploreView = () => {
|
||||
return this.wrapInViewContainer('explore', <ExploreView />, 'thinner');
|
||||
};
|
||||
|
||||
render() {
|
||||
const { capabilities, capabilitiesLoading } = this.state;
|
||||
|
||||
|
@ -470,6 +476,11 @@ export class ConsoleApplication extends React.PureComponent<
|
|||
{capabilities.hasCoordinatorAccess() && (
|
||||
<Route path={pathWithFilter('lookups')} component={this.wrappedLookupsView} />
|
||||
)}
|
||||
|
||||
{capabilities.hasSql() && (
|
||||
<Route path="/explore" component={this.wrappedExploreView} />
|
||||
)}
|
||||
|
||||
<Route component={this.wrappedHomeView} />
|
||||
</Switch>
|
||||
</div>
|
||||
|
|
|
@ -55,6 +55,9 @@ export const LocalStorageKeys = {
|
|||
WORKBENCH_TASK_PANEL: 'workbench-task-panel' as const,
|
||||
|
||||
SQL_DATA_LOADER_CONTENT: 'sql-data-loader-content' as const,
|
||||
|
||||
EXPLORE_CONTENT: 'explore-content' as const,
|
||||
EXPLORE_ESSENCE: 'explore-essence' as const,
|
||||
};
|
||||
export type LocalStorageKeys = (typeof LocalStorageKeys)[keyof typeof LocalStorageKeys];
|
||||
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.column-picker-menu {
|
||||
.search-input {
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.column-menu {
|
||||
height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* 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 { 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 classNames from 'classnames';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { caseInsensitiveContains, dataTypeToIcon, filterMap } from '../../../utils';
|
||||
|
||||
import './column-picker-menu.scss';
|
||||
|
||||
export interface ColumnPickerMenuProps {
|
||||
className?: string;
|
||||
columns: ExpressionMeta[];
|
||||
onSelectColumn(column: ExpressionMeta): void;
|
||||
iconForColumn?: (column: ExpressionMeta) => IconName | undefined;
|
||||
onSelectNone?: () => void;
|
||||
shouldDismissPopover?: boolean;
|
||||
}
|
||||
|
||||
export const ColumnPickerMenu = function ColumnPickerMenu(props: ColumnPickerMenuProps) {
|
||||
const { className, columns, onSelectColumn, iconForColumn, onSelectNone, shouldDismissPopover } =
|
||||
props;
|
||||
const [columnSearch, setColumnSearch] = useState('');
|
||||
|
||||
return (
|
||||
<div className={classNames('column-picker-menu', className)}>
|
||||
<InputGroup
|
||||
className="search-input"
|
||||
value={columnSearch}
|
||||
onChange={e => setColumnSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
autoFocus
|
||||
/>
|
||||
<Menu className="column-menu">
|
||||
{onSelectNone && (
|
||||
<MenuItem
|
||||
icon={IconNames.BLANK}
|
||||
text="None"
|
||||
onClick={onSelectNone}
|
||||
shouldDismissPopover={shouldDismissPopover}
|
||||
/>
|
||||
)}
|
||||
{filterMap(columns, (c, i) => {
|
||||
if (!caseInsensitiveContains(c.name, columnSearch)) return;
|
||||
const iconName = iconForColumn?.(c);
|
||||
return (
|
||||
<MenuItem
|
||||
key={i}
|
||||
icon={c.sqlType ? dataTypeToIcon(c.sqlType) : IconNames.BLANK}
|
||||
text={c.name}
|
||||
labelElement={iconName && <Icon icon={iconName} />}
|
||||
onClick={() => onSelectColumn(c)}
|
||||
shouldDismissPopover={shouldDismissPopover}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { HTMLSelect } from '@blueprintjs/core';
|
||||
import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
|
||||
import React from 'react';
|
||||
|
||||
export interface ColumnPickerProps {
|
||||
availableColumns: ExpressionMeta[] | undefined;
|
||||
selectedColumnName: string;
|
||||
onSelectedColumnNameChange(selectedColumnName: string): void;
|
||||
}
|
||||
|
||||
export const ColumnPicker = React.memo(function ColumnPicker(props: ColumnPickerProps) {
|
||||
const { availableColumns, selectedColumnName, onSelectedColumnNameChange } = props;
|
||||
|
||||
return (
|
||||
<HTMLSelect
|
||||
className="column-picker"
|
||||
value={selectedColumnName}
|
||||
onChange={e => {
|
||||
onSelectedColumnNameChange(e.target.value);
|
||||
}}
|
||||
>
|
||||
{availableColumns?.map((column, i) => (
|
||||
<option key={i} value={column.name}>
|
||||
{column.name}
|
||||
</option>
|
||||
)) || <option value="loading">Loading...</option>}
|
||||
</HTMLSelect>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.aggregate-menu {
|
||||
.search-input {
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.inner-menu {
|
||||
height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { InputGroup, Menu, MenuItem } from '@blueprintjs/core';
|
||||
import { SqlFunction } from '@druid-toolkit/query';
|
||||
import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { caseInsensitiveContains } from '../../../utils';
|
||||
|
||||
import { getPossibleAggregateForColumn } from './helpers';
|
||||
|
||||
import './aggregate-menu.scss';
|
||||
|
||||
const COUNT_AGG: ExpressionMeta = {
|
||||
name: 'Count',
|
||||
expression: SqlFunction.count(),
|
||||
sqlType: 'BIGINT',
|
||||
};
|
||||
|
||||
export interface AggregateMenuProps {
|
||||
columns: ExpressionMeta[];
|
||||
onSelectAggregate(aggregate: ExpressionMeta): void;
|
||||
onSelectNone?: () => void;
|
||||
shouldDismissPopover?: boolean;
|
||||
}
|
||||
|
||||
export const AggregateMenu = function AggregateMenu(props: AggregateMenuProps) {
|
||||
const { columns, onSelectAggregate, onSelectNone, shouldDismissPopover } = props;
|
||||
const [columnSearch, setColumnSearch] = useState('');
|
||||
|
||||
return (
|
||||
<div className="aggregate-menu">
|
||||
<InputGroup
|
||||
className="search-input"
|
||||
value={columnSearch}
|
||||
onChange={e => setColumnSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
autoFocus
|
||||
/>
|
||||
<Menu className="inner-menu">
|
||||
{onSelectNone && (
|
||||
<MenuItem
|
||||
text="None"
|
||||
onClick={onSelectNone}
|
||||
shouldDismissPopover={shouldDismissPopover}
|
||||
/>
|
||||
)}
|
||||
<MenuItem text={COUNT_AGG.name} onClick={() => onSelectAggregate(COUNT_AGG)} />
|
||||
{columns.map((c, i) => {
|
||||
if (!caseInsensitiveContains(c.name, columnSearch)) return;
|
||||
const possibleAggregateForColumn = getPossibleAggregateForColumn(c);
|
||||
if (!possibleAggregateForColumn.length) return;
|
||||
if (possibleAggregateForColumn.length === 1) {
|
||||
const a = possibleAggregateForColumn[0];
|
||||
return <MenuItem key={i} text={a.name} onClick={() => onSelectAggregate(a)} />;
|
||||
} else {
|
||||
return (
|
||||
<MenuItem key={i} text={`${c.name}...`}>
|
||||
{possibleAggregateForColumn.map((a, j) => (
|
||||
<MenuItem key={j} text={a.name} onClick={() => onSelectAggregate(a)} />
|
||||
))}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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 { Classes, Position, Tag } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import { Popover2 } from '@blueprintjs/popover2';
|
||||
import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { AggregateMenu } from './aggregate-menu';
|
||||
|
||||
export interface AggregatesInputProps {
|
||||
columns: ExpressionMeta[];
|
||||
value: ExpressionMeta[];
|
||||
onValueChange(value: ExpressionMeta[]): void;
|
||||
allowDuplicates?: boolean;
|
||||
}
|
||||
|
||||
export const AggregatesInput = function AggregatesInput(props: AggregatesInputProps) {
|
||||
const { columns, value, onValueChange, allowDuplicates } = props;
|
||||
|
||||
const availableColumn = allowDuplicates
|
||||
? columns
|
||||
: columns.filter(o => !value.find(_ => _.name === o.name));
|
||||
|
||||
return (
|
||||
<div className={classNames('aggregates-input', Classes.INPUT, Classes.TAG_INPUT, Classes.FILL)}>
|
||||
<div className={Classes.TAG_INPUT_VALUES}>
|
||||
{value.map((c, i) => (
|
||||
<Tag
|
||||
interactive
|
||||
key={i}
|
||||
onRemove={() => {
|
||||
onValueChange(value.filter(v => v !== c));
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
</Tag>
|
||||
))}
|
||||
<Popover2
|
||||
position={Position.BOTTOM}
|
||||
content={
|
||||
<AggregateMenu
|
||||
columns={availableColumn}
|
||||
onSelectAggregate={c => onValueChange(value.concat(c))}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tag icon={IconNames.PLUS} interactive />
|
||||
</Popover2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.columns-input {
|
||||
.bp4-tag-input-values .bp4-tag {
|
||||
&.drop-before::after {
|
||||
content: '';
|
||||
height: 24px;
|
||||
width: 1px;
|
||||
position: absolute;
|
||||
left: -3px;
|
||||
top: 50%;
|
||||
transform: translate(0, -50%);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
&.drop-after::after {
|
||||
content: '';
|
||||
height: 24px;
|
||||
width: 1px;
|
||||
position: absolute;
|
||||
right: -3px;
|
||||
top: 50%;
|
||||
transform: translate(0, -50%);
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Classes, Position, Tag } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import { Popover2 } from '@blueprintjs/popover2';
|
||||
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 './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;
|
||||
}
|
||||
|
||||
function moveInArray(arr: any[], fromIndex: number, toIndex: number) {
|
||||
arr = arr.concat();
|
||||
const element = arr[fromIndex];
|
||||
arr.splice(fromIndex, 1);
|
||||
arr.splice(toIndex, 0, element);
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
export const ColumnsInput = function ColumnsInput(props: ColumnsInputProps) {
|
||||
const { columns, value, onValueChange, allowDuplicates, allowReordering, pickerMenu } = props;
|
||||
|
||||
const availableColumns = allowDuplicates
|
||||
? columns
|
||||
: columns.filter(o => !value.find(_ => _.name === o.name));
|
||||
|
||||
const [dragIndex, setDragIndex] = useState(-1);
|
||||
const [dropBefore, setDropBefore] = useState(false);
|
||||
const [dropIndex, setDropIndex] = useState(-1);
|
||||
|
||||
const startDrag = useCallback((e: React.DragEvent, i: number) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
setDragIndex(i);
|
||||
}, []);
|
||||
|
||||
const onDragOver = useCallback(
|
||||
(e: React.DragEvent, i: number) => {
|
||||
const targetRect = e.currentTarget.getBoundingClientRect();
|
||||
const before = e.clientX - targetRect.left <= targetRect.width / 2;
|
||||
setDropBefore(before);
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (i === dropIndex) return;
|
||||
|
||||
setDropIndex(i);
|
||||
},
|
||||
[dropIndex],
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (dropIndex > -1) {
|
||||
let correctedDropIndex = dropIndex + (dropBefore ? 0 : 1);
|
||||
if (correctedDropIndex > dragIndex) correctedDropIndex--;
|
||||
|
||||
if (correctedDropIndex !== dragIndex) {
|
||||
onValueChange(moveInArray(value, dragIndex, correctedDropIndex));
|
||||
}
|
||||
}
|
||||
setDragIndex(-1);
|
||||
setDropIndex(-1);
|
||||
setDropBefore(false);
|
||||
},
|
||||
[dropIndex, dragIndex, onValueChange, value, dropBefore],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames('columns-input', Classes.INPUT, Classes.TAG_INPUT, Classes.FILL)}>
|
||||
<div className={Classes.TAG_INPUT_VALUES} onDragEnd={onDrop}>
|
||||
{value.map((c, i) => (
|
||||
<Tag
|
||||
className={classNames({
|
||||
'drop-before': dropIndex === i && dropBefore,
|
||||
'drop-after': dropIndex === i && !dropBefore,
|
||||
})}
|
||||
interactive
|
||||
draggable={allowReordering}
|
||||
onDragOver={e => onDragOver(e, i)}
|
||||
key={i}
|
||||
onDragStart={e => startDrag(e, i)}
|
||||
onRemove={() => {
|
||||
onValueChange(value.filter(v => v !== c));
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
</Tag>
|
||||
))}
|
||||
<Popover2
|
||||
position={Position.BOTTOM}
|
||||
content={
|
||||
pickerMenu ? (
|
||||
pickerMenu(availableColumns)
|
||||
) : (
|
||||
<ColumnPickerMenu
|
||||
columns={availableColumns}
|
||||
onSelectColumn={c => onValueChange(value.concat(c))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Tag icon={IconNames.PLUS} interactive />
|
||||
</Popover2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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.
|
||||
*/
|
||||
|
||||
.control-pane {
|
||||
.bp4-tag-input-values .bp4-tag {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,348 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
InputGroup,
|
||||
Intent,
|
||||
Menu,
|
||||
MenuItem,
|
||||
NumericInput,
|
||||
} from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import { Popover2 } from '@blueprintjs/popover2';
|
||||
import type {
|
||||
ExpressionMeta,
|
||||
OptionValue,
|
||||
ParameterDefinition,
|
||||
RegisteredVisualModule,
|
||||
} from '@druid-toolkit/visuals-core';
|
||||
import { getPluginOptionLabel } from '@druid-toolkit/visuals-core';
|
||||
import type { JSX } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { AutoForm, FormGroupWithInfo, PopoverText } from '../../../components';
|
||||
import { AppToaster } from '../../../singletons';
|
||||
import { ColumnPickerMenu } from '../column-picker-menu/column-picker-menu';
|
||||
import { DroppableContainer } from '../droppable-container/droppable-container';
|
||||
|
||||
import { AggregateMenu } from './aggregate-menu';
|
||||
import { ColumnsInput } from './columns-input';
|
||||
import { getPossibleAggregateForColumn } from './helpers';
|
||||
import { OptionsInput } from './options-input';
|
||||
|
||||
import './control-pane.scss';
|
||||
|
||||
export interface ControlPaneProps {
|
||||
columns: ExpressionMeta[];
|
||||
onUpdateParameterValues(params: Record<string, unknown>): void;
|
||||
parameterValues: Record<string, unknown>;
|
||||
visualModule: RegisteredVisualModule;
|
||||
}
|
||||
|
||||
export const ControlPane = function ControlPane(props: ControlPaneProps) {
|
||||
const { columns, onUpdateParameterValues, parameterValues, visualModule } = props;
|
||||
|
||||
function renderOptionsPropInput(
|
||||
parameter: ParameterDefinition,
|
||||
value: any,
|
||||
onValueChange: (value: any) => void,
|
||||
): {
|
||||
element: JSX.Element;
|
||||
onDropColumn?: (column: ExpressionMeta) => void;
|
||||
} {
|
||||
switch (parameter.type) {
|
||||
case 'boolean': {
|
||||
return {
|
||||
element: (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
active={value === false}
|
||||
onClick={() => {
|
||||
onValueChange(false);
|
||||
}}
|
||||
>
|
||||
False
|
||||
</Button>
|
||||
<Button
|
||||
active={value === true}
|
||||
onClick={() => {
|
||||
onValueChange(true);
|
||||
}}
|
||||
>
|
||||
True
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
case 'number':
|
||||
return {
|
||||
element: (
|
||||
<NumericInput
|
||||
value={(value as string) ?? ''}
|
||||
onValueChange={v => onValueChange(v)}
|
||||
placeholder={parameter.control?.placeholder}
|
||||
fill
|
||||
min={parameter.min}
|
||||
max={parameter.max}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
case 'string':
|
||||
return {
|
||||
element: (
|
||||
<InputGroup
|
||||
value={(value as string) || ''}
|
||||
onChange={e => onValueChange(e.target.value)}
|
||||
placeholder={parameter.control?.placeholder}
|
||||
fill
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
case 'option': {
|
||||
const controlOptions = parameter.options || [];
|
||||
const selectedOption: OptionValue | undefined = controlOptions.find(o => o === value);
|
||||
return {
|
||||
element: (
|
||||
<Popover2
|
||||
fill
|
||||
position="bottom-left"
|
||||
minimal
|
||||
content={
|
||||
<Menu>
|
||||
{controlOptions.map((o, i) => (
|
||||
<MenuItem
|
||||
key={i}
|
||||
text={getPluginOptionLabel(o, parameter)}
|
||||
onClick={() => onValueChange(o)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<InputGroup
|
||||
value={
|
||||
typeof selectedOption === 'undefined'
|
||||
? String(value)
|
||||
: getPluginOptionLabel(selectedOption, parameter)
|
||||
}
|
||||
readOnly
|
||||
fill
|
||||
rightElement={<Button icon={IconNames.CARET_DOWN} minimal />}
|
||||
/>
|
||||
</Popover2>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
case 'options': {
|
||||
return {
|
||||
element: (
|
||||
<OptionsInput
|
||||
options={parameter.options || []}
|
||||
value={(value as OptionValue[]) || []}
|
||||
onValueChange={onValueChange}
|
||||
parameter={parameter}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
case 'column':
|
||||
return {
|
||||
element: (
|
||||
<Popover2
|
||||
fill
|
||||
position="bottom-left"
|
||||
minimal
|
||||
content={
|
||||
<ColumnPickerMenu
|
||||
columns={columns}
|
||||
onSelectNone={
|
||||
parameter.control?.required ? undefined : () => onValueChange(undefined)
|
||||
}
|
||||
onSelectColumn={onValueChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InputGroup
|
||||
value={(value as ExpressionMeta)?.name || 'None'}
|
||||
readOnly
|
||||
fill
|
||||
rightElement={<Button icon={IconNames.CARET_DOWN} minimal />}
|
||||
/>
|
||||
</Popover2>
|
||||
),
|
||||
onDropColumn: onValueChange,
|
||||
};
|
||||
|
||||
case 'columns': {
|
||||
return {
|
||||
element: (
|
||||
<ColumnsInput
|
||||
columns={columns}
|
||||
allowReordering
|
||||
value={(value as ExpressionMeta[]) || []}
|
||||
onValueChange={onValueChange}
|
||||
allowDuplicates={parameter.allowDuplicates}
|
||||
/>
|
||||
),
|
||||
onDropColumn: (column: ExpressionMeta) => {
|
||||
value = value || [];
|
||||
const columnName = column.name;
|
||||
if (
|
||||
!parameter.allowDuplicates &&
|
||||
value.find((v: ExpressionMeta) => v.name === columnName)
|
||||
) {
|
||||
AppToaster.show({
|
||||
intent: Intent.WARNING,
|
||||
message: `"${columnName}" already selected`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
onValueChange(value.concat(column));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'aggregate': {
|
||||
return {
|
||||
element: (
|
||||
<Popover2
|
||||
fill
|
||||
position="bottom-left"
|
||||
minimal
|
||||
content={
|
||||
<AggregateMenu
|
||||
columns={columns}
|
||||
onSelectAggregate={onValueChange}
|
||||
onSelectNone={
|
||||
parameter.control?.required ? undefined : () => onValueChange(undefined)
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InputGroup
|
||||
value={value ? (value as { name: string }).name : 'None'}
|
||||
readOnly
|
||||
fill
|
||||
rightElement={<Button icon={IconNames.CARET_DOWN} minimal />}
|
||||
/>
|
||||
</Popover2>
|
||||
),
|
||||
onDropColumn: column => {
|
||||
const aggregates = getPossibleAggregateForColumn(column);
|
||||
if (!aggregates.length) return;
|
||||
onValueChange(aggregates[0]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'aggregates': {
|
||||
return {
|
||||
element: (
|
||||
<ColumnsInput
|
||||
columns={columns}
|
||||
value={(value as ExpressionMeta[]) || []}
|
||||
onValueChange={onValueChange}
|
||||
allowReordering
|
||||
pickerMenu={availableColumns => (
|
||||
<AggregateMenu
|
||||
columns={availableColumns}
|
||||
onSelectAggregate={c => onValueChange((value as ExpressionMeta[]).concat(c))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
onDropColumn: column => {
|
||||
value = value || [];
|
||||
const aggregates = getPossibleAggregateForColumn(column).filter(
|
||||
p => !value.some((v: ExpressionMeta) => v.name === p.name),
|
||||
);
|
||||
if (!aggregates.length) return;
|
||||
onValueChange(value.concat(aggregates[0]));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
element: (
|
||||
<Button
|
||||
icon={IconNames.ERROR}
|
||||
text={`Type not supported: ${(parameter as { type: string }).type}`}
|
||||
disabled
|
||||
fill
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const namedParameters = Object.entries(visualModule.parameterDefinitions ?? {});
|
||||
|
||||
return (
|
||||
<div className="control-pane">
|
||||
{namedParameters.map(([name, parameter], i) => {
|
||||
const visible = parameter.control?.visible;
|
||||
if (
|
||||
visible === false ||
|
||||
(typeof visible === 'function' && !visible({ params: parameterValues }))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = parameterValues[name];
|
||||
function onValueChange(newValue: unknown) {
|
||||
onUpdateParameterValues({ [name]: newValue });
|
||||
}
|
||||
|
||||
const { element, onDropColumn } = renderOptionsPropInput(parameter, value, onValueChange);
|
||||
|
||||
const formGroup = (
|
||||
<FormGroupWithInfo
|
||||
key={i}
|
||||
label={parameter.control?.label || AutoForm.makeLabelName(name)}
|
||||
info={
|
||||
parameter.control?.description ? (
|
||||
<PopoverText>{parameter.control.description}</PopoverText>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{element}
|
||||
</FormGroupWithInfo>
|
||||
);
|
||||
|
||||
if (!onDropColumn) {
|
||||
return formGroup;
|
||||
}
|
||||
|
||||
return (
|
||||
<DroppableContainer key={i} onDropColumn={onDropColumn}>
|
||||
{formGroup}
|
||||
</DroppableContainer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { C, F, SqlFunction } from '@druid-toolkit/query';
|
||||
import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
|
||||
|
||||
export function getPossibleAggregateForColumn(column: ExpressionMeta): ExpressionMeta[] {
|
||||
switch (column.sqlType) {
|
||||
case 'TIMESTAMP':
|
||||
return [
|
||||
{
|
||||
name: `Max ${column.name}`,
|
||||
expression: F.max(C(column.name)),
|
||||
sqlType: column.sqlType,
|
||||
},
|
||||
{
|
||||
name: `Min ${column.name}`,
|
||||
expression: F.min(C(column.name)),
|
||||
sqlType: column.sqlType,
|
||||
},
|
||||
];
|
||||
|
||||
case 'BIGINT':
|
||||
case 'FLOAT':
|
||||
case 'DOUBLE':
|
||||
return [
|
||||
{
|
||||
name: `Sum ${column.name}`,
|
||||
expression: F.sum(C(column.name)),
|
||||
sqlType: column.sqlType,
|
||||
},
|
||||
{
|
||||
name: `Max ${column.name}`,
|
||||
expression: F.max(C(column.name)),
|
||||
sqlType: column.sqlType,
|
||||
},
|
||||
{
|
||||
name: `Min ${column.name}`,
|
||||
expression: F.min(C(column.name)),
|
||||
sqlType: column.sqlType,
|
||||
},
|
||||
{
|
||||
name: `Unique ${column.name}`,
|
||||
expression: SqlFunction.countDistinct(C(column.name)),
|
||||
sqlType: 'BIGINT',
|
||||
},
|
||||
{
|
||||
name: `P98 ${column.name}`,
|
||||
expression: F('APPROX_QUANTILE_DS', C(column.name), 0.98),
|
||||
sqlType: 'DOUBLE',
|
||||
},
|
||||
];
|
||||
|
||||
case 'VARCHAR':
|
||||
case 'COMPLEX':
|
||||
case 'COMPLEX<hyperUnique>':
|
||||
return [
|
||||
{
|
||||
name: `Unique ${column.name}`,
|
||||
expression: SqlFunction.countDistinct(C(column.name)),
|
||||
sqlType: 'BIGINT',
|
||||
},
|
||||
];
|
||||
|
||||
case 'COMPLEX<HLLSketch>':
|
||||
return [
|
||||
{
|
||||
name: `Unique ${column.name}`,
|
||||
expression: F('APPROX_COUNT_DISTINCT_DS_HLL', C(column.name)),
|
||||
sqlType: 'BIGINT',
|
||||
},
|
||||
];
|
||||
|
||||
case 'COMPLEX<quantilesDoublesSketch>':
|
||||
return [
|
||||
{
|
||||
name: `Median ${column.name}`,
|
||||
expression: F('APPROX_QUANTILE_DS', C(column.name), 0.5),
|
||||
sqlType: 'DOUBLE',
|
||||
},
|
||||
{
|
||||
name: `P95 ${column.name}`,
|
||||
expression: F('APPROX_QUANTILE_DS', C(column.name), 0.95),
|
||||
sqlType: 'DOUBLE',
|
||||
},
|
||||
{
|
||||
name: `P98 ${column.name}`,
|
||||
expression: F('APPROX_QUANTILE_DS', C(column.name), 0.98),
|
||||
sqlType: 'DOUBLE',
|
||||
},
|
||||
];
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Classes, Menu, MenuItem, Position, Tag } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import { Popover2 } from '@blueprintjs/popover2';
|
||||
import type { OptionValue, ParameterDefinition } from '@druid-toolkit/visuals-core';
|
||||
import { getPluginOptionLabel } from '@druid-toolkit/visuals-core';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
export interface OptionsInputProps {
|
||||
parameter: ParameterDefinition;
|
||||
options: readonly OptionValue[];
|
||||
value: OptionValue[];
|
||||
onValueChange(value: OptionValue[]): void;
|
||||
}
|
||||
|
||||
export const OptionsInput = function OptionsInput(props: OptionsInputProps) {
|
||||
const { options, value, onValueChange, parameter } = props;
|
||||
|
||||
if (parameter.type !== 'options') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedOptions: OptionValue[] = value.filter(v => options.includes(v));
|
||||
|
||||
const availableOptions = parameter.allowDuplicates
|
||||
? options
|
||||
: options.filter(o => !value.find(v => v === o));
|
||||
|
||||
return (
|
||||
<div className={classNames('options-input', Classes.INPUT, Classes.TAG_INPUT, Classes.FILL)}>
|
||||
<div className={Classes.TAG_INPUT_VALUES}>
|
||||
{selectedOptions.map((o, i) => (
|
||||
<Tag
|
||||
interactive
|
||||
key={i}
|
||||
onRemove={() => {
|
||||
onValueChange(value.filter(v => v !== o));
|
||||
}}
|
||||
>
|
||||
{getPluginOptionLabel(o, parameter)}
|
||||
</Tag>
|
||||
))}
|
||||
<Popover2
|
||||
position={Position.BOTTOM}
|
||||
content={
|
||||
<Menu>
|
||||
{availableOptions.map((o, i) => (
|
||||
<MenuItem
|
||||
key={i}
|
||||
text={getPluginOptionLabel(o, parameter)}
|
||||
onClick={() => {
|
||||
onValueChange(value.concat(o));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Tag icon={IconNames.PLUS} interactive />
|
||||
</Popover2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
|
||||
|
||||
export class DragHelper {
|
||||
static dragColumn: ExpressionMeta | undefined;
|
||||
|
||||
static createDragGhost(dataTransfer: DataTransfer, text: string): void {
|
||||
const dragGhost = document.createElement('div');
|
||||
dragGhost.className = 'drag-ghost';
|
||||
|
||||
const dragGhostInner = document.createElement('div');
|
||||
dragGhostInner.className = 'drag-ghost-inner';
|
||||
dragGhostInner.textContent = text;
|
||||
dragGhost.appendChild(dragGhostInner);
|
||||
|
||||
document.body.appendChild(dragGhost);
|
||||
|
||||
dataTransfer.setDragImage(dragGhost, 0, 0);
|
||||
|
||||
// Remove the host after a ms because it is no longer needed
|
||||
setTimeout(() => document.body.removeChild(dragGhost), 1);
|
||||
}
|
||||
}
|
|
@ -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 '../../../variables';
|
||||
|
||||
.droppable-container {
|
||||
position: relative;
|
||||
|
||||
&.drop-hover {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
border: 1px solid $druid-brand;
|
||||
border-radius: 3px;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
|
||||
import classNames from 'classnames';
|
||||
import React, { forwardRef, useState } from 'react';
|
||||
|
||||
import { DragHelper } from '../drag-helper';
|
||||
|
||||
import './droppable-container.scss';
|
||||
|
||||
export interface DroppableContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onDropColumn(column: ExpressionMeta): void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DroppableContainer = forwardRef(function DroppableContainer(
|
||||
props: DroppableContainerProps,
|
||||
ref,
|
||||
) {
|
||||
const { className, onDropColumn, children, ...rest } = props;
|
||||
const [dropHover, setDropHover] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref as any}
|
||||
className={classNames('droppable-container', className, { 'drop-hover': dropHover })}
|
||||
{...rest}
|
||||
onDragOver={e => {
|
||||
if (!DragHelper.dragColumn) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDropHover(true);
|
||||
}}
|
||||
onDragLeave={e => {
|
||||
const currentTarget = e.currentTarget;
|
||||
const relatedTarget = e.relatedTarget;
|
||||
if (currentTarget.contains(relatedTarget as any)) return;
|
||||
setDropHover(false);
|
||||
}}
|
||||
onDrop={() => {
|
||||
if (!DragHelper.dragColumn) return;
|
||||
const dragColumn = DragHelper.dragColumn;
|
||||
DragHelper.dragColumn = undefined;
|
||||
setDropHover(false);
|
||||
onDropColumn(dragColumn);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -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';
|
||||
|
||||
$resources-width: 240px;
|
||||
|
||||
.explore-view {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: $resources-width 1fr $resources-width;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 8px;
|
||||
|
||||
.source-pane,
|
||||
.filter-pane,
|
||||
.tile-picker,
|
||||
.control-pane-cnt {
|
||||
@include card-like;
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.resource-pane-cnt,
|
||||
.module-holder {
|
||||
@include card-like;
|
||||
}
|
||||
|
||||
.resource-pane-cnt {
|
||||
height: 100%;
|
||||
|
||||
.resource-pane {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drag-ghost {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -10;
|
||||
|
||||
.drag-ghost-inner {
|
||||
margin: 12px;
|
||||
|
||||
padding: 4px 8px;
|
||||
background: $dark-gray1;
|
||||
color: white;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,370 @@
|
|||
/*
|
||||
* 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 './modules';
|
||||
|
||||
import { Menu, MenuItem } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import type { SqlExpression, SqlTable } from '@druid-toolkit/query';
|
||||
import { C, L, sql, SqlLiteral, SqlQuery, T } from '@druid-toolkit/query';
|
||||
import type { ExpressionMeta, TransferValue } from '@druid-toolkit/visuals-core';
|
||||
import {
|
||||
useModuleContainer,
|
||||
useParameterValues,
|
||||
useSingleHost,
|
||||
} from '@druid-toolkit/visuals-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ShowValueDialog } from '../../dialogs/show-value-dialog/show-value-dialog';
|
||||
import { useLocalStorageState, useQueryManager } from '../../hooks';
|
||||
import { deepGet, filterMap, LocalStorageKeys, oneOf, queryDruidSql } from '../../utils';
|
||||
|
||||
import { ControlPane } from './control-pane/control-pane';
|
||||
import { DroppableContainer } from './droppable-container/droppable-container';
|
||||
import { FilterPane } from './filter-pane/filter-pane';
|
||||
import BarChartEcharts from './modules/bar-chart-echarts-module';
|
||||
import MultiAxisChartEcharts from './modules/multi-axis-chart-echarts-module';
|
||||
import PieChartEcharts from './modules/pie-chart-echarts-module';
|
||||
import TableReact from './modules/table-react-module';
|
||||
import TimeChartEcharts from './modules/time-chart-echarts-module';
|
||||
import { ResourcePane } from './resource-pane/resource-pane';
|
||||
import { SourcePane } from './source-pane/source-pane';
|
||||
import { TilePicker } from './tile-picker/tile-picker';
|
||||
import type { Dataset } from './utils';
|
||||
import { adjustTransferValue, normalizeType } from './utils';
|
||||
|
||||
import './explore-view.scss';
|
||||
|
||||
const VISUAL_MODULES = [
|
||||
{
|
||||
moduleName: 'time_chart_echarts',
|
||||
icon: IconNames.TIMELINE_LINE_CHART,
|
||||
label: 'Time chart',
|
||||
module: TimeChartEcharts,
|
||||
transfer: ['splitColumn', 'metric'],
|
||||
},
|
||||
{
|
||||
moduleName: 'bar_chart_echarts',
|
||||
icon: IconNames.TIMELINE_BAR_CHART,
|
||||
label: 'Bar chart',
|
||||
module: BarChartEcharts,
|
||||
transfer: ['splitColumn', 'metric'],
|
||||
},
|
||||
{
|
||||
moduleName: 'table_react',
|
||||
icon: IconNames.TH,
|
||||
label: 'Table',
|
||||
module: TableReact,
|
||||
transfer: ['splitColumns', 'metrics'],
|
||||
},
|
||||
{
|
||||
moduleName: 'pie_chart_echarts',
|
||||
icon: IconNames.PIE_CHART,
|
||||
label: 'Pie chart',
|
||||
module: PieChartEcharts,
|
||||
transfer: ['splitColumn', 'metric'],
|
||||
},
|
||||
{
|
||||
moduleName: 'multi-axis_chart_echarts',
|
||||
icon: IconNames.SERIES_ADD,
|
||||
label: 'Multi-axis chart',
|
||||
module: MultiAxisChartEcharts,
|
||||
transfer: ['metrics'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
type ModuleType = (typeof VISUAL_MODULES)[number]['moduleName'];
|
||||
|
||||
// ---------------------------------------
|
||||
|
||||
interface QueryHistoryEntry {
|
||||
time: Date;
|
||||
sqlQuery: string;
|
||||
}
|
||||
|
||||
const MAX_PAST_QUERIES = 10;
|
||||
const QUERY_HISTORY: QueryHistoryEntry[] = [];
|
||||
|
||||
function addQueryToHistory(sqlQuery: string): void {
|
||||
QUERY_HISTORY.unshift({ time: new Date(), sqlQuery });
|
||||
while (QUERY_HISTORY.length > MAX_PAST_QUERIES) QUERY_HISTORY.pop();
|
||||
}
|
||||
|
||||
function getFormattedQueryHistory(): string {
|
||||
return QUERY_HISTORY.map(
|
||||
({ time, sqlQuery }) => `At ${time.toISOString()} ran query:\n\n${sqlQuery}`,
|
||||
).join('\n\n-----------------------------------------------------\n\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
|
||||
async function introspect(tableName: SqlTable): Promise<Dataset> {
|
||||
const columns = await queryDruidSql({
|
||||
query: `SELECT COLUMN_NAME AS "name", DATA_TYPE AS "sqlType" FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'druid' AND TABLE_NAME = ${L(tableName.getName())}`,
|
||||
});
|
||||
|
||||
return {
|
||||
table: tableName,
|
||||
columns: columns.map(({ name, sqlType }) => ({ name, expression: C(name), sqlType })),
|
||||
};
|
||||
}
|
||||
|
||||
// micro-cache
|
||||
const MAX_TIME_TTL = 60000;
|
||||
let lastMaxTimeTable: string | undefined;
|
||||
let lastMaxTimeValue: Date | undefined;
|
||||
let lastMaxTimeTimestamp = 0;
|
||||
|
||||
async function getMaxTimeForTable(tableName: string): Promise<Date | undefined> {
|
||||
// micro-cache get
|
||||
if (
|
||||
lastMaxTimeTable === tableName &&
|
||||
lastMaxTimeValue &&
|
||||
Date.now() < lastMaxTimeTimestamp + MAX_TIME_TTL
|
||||
) {
|
||||
return lastMaxTimeValue;
|
||||
}
|
||||
|
||||
const d = await queryDruidSql({
|
||||
query: sql`SELECT MAX(__time) AS "maxTime" FROM ${T(tableName)}`,
|
||||
});
|
||||
|
||||
const maxTime = new Date(deepGet(d, '0.maxTime'));
|
||||
if (isNaN(maxTime.valueOf())) return;
|
||||
|
||||
// micro-cache set
|
||||
lastMaxTimeTable = tableName;
|
||||
lastMaxTimeValue = maxTime;
|
||||
lastMaxTimeTimestamp = Date.now();
|
||||
|
||||
return maxTime;
|
||||
}
|
||||
|
||||
async function extendedQueryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]> {
|
||||
if (sqlQueryPayload.query.includes('MAX_DATA_TIME()')) {
|
||||
const parsed = SqlQuery.parse(sqlQueryPayload.query);
|
||||
const tableName = parsed.getFirstTableName();
|
||||
if (tableName) {
|
||||
const maxTime = await getMaxTimeForTable(tableName);
|
||||
if (maxTime) {
|
||||
sqlQueryPayload = {
|
||||
...sqlQueryPayload,
|
||||
query: sqlQueryPayload.query.replace(/MAX_DATA_TIME\(\)/g, L(maxTime)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addQueryToHistory(sqlQueryPayload.query);
|
||||
console.debug(`Running query:\n${sqlQueryPayload.query}`);
|
||||
|
||||
return queryDruidSql(sqlQueryPayload);
|
||||
}
|
||||
|
||||
export const ExploreView = React.memo(function ExploreView() {
|
||||
const [shownText, setShownText] = useState<string | undefined>();
|
||||
const filterPane = useRef<{ filterOn(column: ExpressionMeta): void }>();
|
||||
|
||||
const [moduleName, setModuleName] = useLocalStorageState<ModuleType>(
|
||||
LocalStorageKeys.EXPLORE_CONTENT,
|
||||
VISUAL_MODULES[0].moduleName,
|
||||
);
|
||||
|
||||
const [columns, setColumns] = useState<ExpressionMeta[]>([]);
|
||||
|
||||
const { host, where, table, visualModule, updateWhere, updateTable } = useSingleHost({
|
||||
sqlQuery: extendedQueryDruidSql,
|
||||
persist: { name: LocalStorageKeys.EXPLORE_ESSENCE, storage: 'localStorage' },
|
||||
visualModules: Object.fromEntries(VISUAL_MODULES.map(v => [v.moduleName, v.module])),
|
||||
selectedModule: moduleName,
|
||||
moduleState: {
|
||||
parameterValues: {},
|
||||
table: T('select source'),
|
||||
where: SqlLiteral.TRUE,
|
||||
},
|
||||
});
|
||||
|
||||
const { parameterValues, updateParameterValues, resetParameterValues } = useParameterValues({
|
||||
host,
|
||||
selectedModule: moduleName,
|
||||
columns,
|
||||
});
|
||||
|
||||
const [datasetState] = useQueryManager<SqlExpression, Dataset>({
|
||||
query: table,
|
||||
processQuery: tableName => introspect(tableName as SqlTable),
|
||||
});
|
||||
|
||||
const onShow = useMemo(() => {
|
||||
const currentShowTransfers =
|
||||
VISUAL_MODULES.find(vm => vm.moduleName === moduleName)?.transfer || [];
|
||||
if (currentShowTransfers.length) {
|
||||
const paramName = currentShowTransfers[0];
|
||||
const showControlType = visualModule?.parameterDefinitions?.[paramName]?.type;
|
||||
|
||||
if (paramName && oneOf(showControlType, 'column', 'columns')) {
|
||||
return (column: ExpressionMeta) => {
|
||||
updateParameterValues({ [paramName]: showControlType === 'column' ? column : [column] });
|
||||
};
|
||||
}
|
||||
}
|
||||
return;
|
||||
}, [updateParameterValues, moduleName, visualModule?.parameterDefinitions]);
|
||||
|
||||
const dataset = datasetState.getSomeData();
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(dataset?.columns ?? []);
|
||||
}, [dataset?.columns]);
|
||||
|
||||
const [containerRef] = useModuleContainer({ host, selectedModule: moduleName, columns });
|
||||
|
||||
return (
|
||||
<div className="explore-view">
|
||||
<SourcePane
|
||||
selectedTableName={table ? (table as SqlTable).getName() : '-'}
|
||||
onSelectedTableNameChange={t => updateTable(T(t))}
|
||||
disabled={Boolean(dataset && datasetState.loading)}
|
||||
/>
|
||||
<FilterPane
|
||||
ref={filterPane}
|
||||
dataset={dataset}
|
||||
filter={where}
|
||||
onFilterChange={updateWhere}
|
||||
queryDruidSql={extendedQueryDruidSql}
|
||||
/>
|
||||
<TilePicker<ModuleType>
|
||||
modules={VISUAL_MODULES}
|
||||
selectedTileName={moduleName}
|
||||
onSelectedTileNameChange={m => {
|
||||
const currentParameterDefinitions = visualModule?.parameterDefinitions || {};
|
||||
const valuesToTransfer: TransferValue[] = filterMap(
|
||||
VISUAL_MODULES.find(vm => vm.moduleName === visualModule?.moduleName)?.transfer || [],
|
||||
paramName => {
|
||||
const parameterDefinition = currentParameterDefinitions[paramName];
|
||||
if (!parameterDefinition) return;
|
||||
const parameterValue = parameterValues[paramName];
|
||||
if (typeof parameterValue === 'undefined') return;
|
||||
return [parameterDefinition.type, parameterValue];
|
||||
},
|
||||
);
|
||||
|
||||
setModuleName(m);
|
||||
resetParameterValues();
|
||||
|
||||
const newModuleDef = VISUAL_MODULES.find(vm => vm.moduleName === m);
|
||||
if (newModuleDef) {
|
||||
const newParameters: any = newModuleDef.module?.parameters || {};
|
||||
const transferParameterValues: [name: string, value: any][] = filterMap(
|
||||
newModuleDef.transfer || [],
|
||||
t => {
|
||||
const p = newParameters[t];
|
||||
if (!p) return;
|
||||
const normalizedTargetType = normalizeType(p.type);
|
||||
const transferSource = valuesToTransfer.find(
|
||||
([t]) => normalizeType(t) === normalizedTargetType,
|
||||
);
|
||||
if (!transferSource) return;
|
||||
const targetValue = adjustTransferValue(
|
||||
transferSource[1],
|
||||
transferSource[0],
|
||||
p.type,
|
||||
);
|
||||
if (typeof targetValue === 'undefined') return;
|
||||
return [t, targetValue];
|
||||
},
|
||||
);
|
||||
|
||||
if (transferParameterValues.length) {
|
||||
updateParameterValues(Object.fromEntries(transferParameterValues));
|
||||
}
|
||||
}
|
||||
}}
|
||||
moreMenu={
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon={IconNames.HISTORY}
|
||||
text="Show query history"
|
||||
onClick={() => {
|
||||
setShownText(getFormattedQueryHistory());
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={IconNames.RESET}
|
||||
text="Reset visualization state"
|
||||
onClick={() => {
|
||||
resetParameterValues();
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
<div className="resource-pane-cnt">
|
||||
{!dataset && datasetState.loading && 'Loading...'}
|
||||
{dataset && (
|
||||
<ResourcePane
|
||||
dataset={dataset}
|
||||
onFilter={c => {
|
||||
filterPane.current?.filterOn(c);
|
||||
}}
|
||||
onShow={onShow}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DroppableContainer
|
||||
ref={containerRef}
|
||||
onDropColumn={column => {
|
||||
let nextModuleName: ModuleType;
|
||||
if (column.sqlType === 'TIMESTAMP') {
|
||||
nextModuleName = 'time_chart_echarts';
|
||||
} else {
|
||||
nextModuleName = 'table_react';
|
||||
}
|
||||
|
||||
setModuleName(nextModuleName);
|
||||
|
||||
if (column.sqlType === 'TIMESTAMP') {
|
||||
resetParameterValues();
|
||||
} else {
|
||||
updateParameterValues({ splitColumns: [column] });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="control-pane-cnt">
|
||||
{dataset && visualModule?.parameterDefinitions && (
|
||||
<ControlPane
|
||||
columns={dataset.columns}
|
||||
onUpdateParameterValues={updateParameterValues}
|
||||
parameterValues={parameterValues}
|
||||
visualModule={visualModule}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{shownText && (
|
||||
<ShowValueDialog
|
||||
title="Query history"
|
||||
str={shownText}
|
||||
onClose={() => {
|
||||
setShownText(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.column-value {
|
||||
&.empty,
|
||||
&.null {
|
||||
font-style: italic;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
|
@ -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 React from 'react';
|
||||
|
||||
import './column-value.scss';
|
||||
|
||||
export interface ColumnValueProps {
|
||||
value: any;
|
||||
}
|
||||
|
||||
export const ColumnValue = function ColumnValue(props: ColumnValueProps) {
|
||||
const { value } = props;
|
||||
|
||||
if (value === '') {
|
||||
return <span className="column-value empty">empty</span>;
|
||||
} else if (value === null) {
|
||||
return <span className="column-value null">null</span>;
|
||||
}
|
||||
|
||||
return <span className="column-value">{String(value)}</span>;
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.contains-filter-control {
|
||||
.preview-list {
|
||||
padding: 5px 0;
|
||||
height: 300px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(15, 19, 32, 0.4);
|
||||
|
||||
.preview-item {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Button, FormGroup, InputGroup, Intent, Menu, MenuItem } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import type { ContainsFilterPattern } from '@druid-toolkit/query';
|
||||
import { C, filterPatternToExpression, SqlExpression, SqlLiteral } from '@druid-toolkit/query';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { useQueryManager } from '../../../../../hooks';
|
||||
import { ColumnPicker } from '../../../column-picker/column-picker';
|
||||
import type { Dataset } from '../../../utils';
|
||||
|
||||
import './contains-filter-control.scss';
|
||||
|
||||
export interface ContainsFilterControlProps {
|
||||
dataset: Dataset;
|
||||
filter: SqlExpression | undefined;
|
||||
initFilterPattern: ContainsFilterPattern;
|
||||
negated: boolean;
|
||||
setFilterPattern(filterPattern: ContainsFilterPattern): void;
|
||||
queryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]>;
|
||||
}
|
||||
|
||||
export const ContainsFilterControl = React.memo(function ContainsFilterControl(
|
||||
props: ContainsFilterControlProps,
|
||||
) {
|
||||
const { dataset, filter, initFilterPattern, negated, setFilterPattern, queryDruidSql } = props;
|
||||
const [column, setColumn] = useState<string>(initFilterPattern.column);
|
||||
const [contains, setContains] = useState(initFilterPattern.contains);
|
||||
|
||||
function makePattern(): ContainsFilterPattern {
|
||||
return {
|
||||
type: 'contains',
|
||||
negated,
|
||||
column,
|
||||
contains,
|
||||
};
|
||||
}
|
||||
|
||||
const previewQuery = useMemo(() => {
|
||||
const columnRef = C(column);
|
||||
const queryParts: string[] = [`SELECT ${columnRef.as('c')}`, `FROM ${dataset.table}`];
|
||||
|
||||
const filterEx = SqlExpression.and(
|
||||
filter,
|
||||
contains ? filterPatternToExpression(makePattern()) : undefined,
|
||||
);
|
||||
if (!(filterEx instanceof SqlLiteral)) {
|
||||
queryParts.push(`WHERE ${filterEx}`);
|
||||
}
|
||||
|
||||
queryParts.push(`GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 101`);
|
||||
return queryParts.join('\n');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps
|
||||
}, [dataset.table, filter, column, contains, negated]);
|
||||
|
||||
const [previewState] = useQueryManager<string, string[]>({
|
||||
query: previewQuery,
|
||||
debounceIdle: 100,
|
||||
debounceLoading: 500,
|
||||
processQuery: async query => {
|
||||
const vs = await queryDruidSql<{ c: any }>({
|
||||
query,
|
||||
});
|
||||
|
||||
return vs.map(d => String(d.c));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="contains-filter-control">
|
||||
<FormGroup label="Column">
|
||||
<ColumnPicker
|
||||
availableColumns={dataset.columns}
|
||||
selectedColumnName={column}
|
||||
onSelectedColumnNameChange={setColumn}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<InputGroup
|
||||
value={contains}
|
||||
onChange={e => setContains(e.target.value)}
|
||||
placeholder="Search string"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label="Preview">
|
||||
<Menu className="preview-list">
|
||||
{previewState.data?.map((v, i) => (
|
||||
<MenuItem
|
||||
key={i}
|
||||
className="preview-item"
|
||||
text={String(v)}
|
||||
shouldDismissPopover={false}
|
||||
/>
|
||||
))}
|
||||
{previewState.loading && <MenuItem disabled text="Loading..." />}
|
||||
{previewState.error && (
|
||||
<MenuItem icon={IconNames.ERROR} disabled text={previewState.getErrorMessage()} />
|
||||
)}
|
||||
</Menu>
|
||||
</FormGroup>
|
||||
<div className="button-bar">
|
||||
<Button
|
||||
intent={Intent.PRIMARY}
|
||||
text="OK"
|
||||
onClick={() => {
|
||||
const newPattern = makePattern();
|
||||
// TODO check if valid
|
||||
// if (!isFilterPatternValid(newPattern)) return;
|
||||
setFilterPattern(newPattern);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Button, FormGroup, Intent, TextArea } from '@blueprintjs/core';
|
||||
import type { CustomFilterPattern } from '@druid-toolkit/query';
|
||||
import { SqlExpression } from '@druid-toolkit/query';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export interface CustomFilterControlProps {
|
||||
initFilterPattern: CustomFilterPattern;
|
||||
negated: boolean;
|
||||
setFilterPattern(filterPattern: CustomFilterPattern): void;
|
||||
}
|
||||
|
||||
export const CustomFilterControl = React.memo(function CustomFilterControl(
|
||||
props: CustomFilterControlProps,
|
||||
) {
|
||||
const { initFilterPattern, negated, setFilterPattern } = props;
|
||||
const [formula, setFormula] = useState<string>(String(initFilterPattern.expression || ''));
|
||||
|
||||
function makePattern(): CustomFilterPattern {
|
||||
return {
|
||||
type: 'custom',
|
||||
negated,
|
||||
expression: SqlExpression.maybeParse(formula),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="custom-filter-control">
|
||||
<FormGroup>
|
||||
<TextArea
|
||||
value={formula}
|
||||
onChange={e => setFormula(e.target.value)}
|
||||
placeholder="SQL expression"
|
||||
/>
|
||||
</FormGroup>
|
||||
<div className="button-bar">
|
||||
<Button
|
||||
intent={Intent.PRIMARY}
|
||||
text="OK"
|
||||
onClick={() => {
|
||||
const newPattern = makePattern();
|
||||
// TODO check if valid
|
||||
// if (!isFilterPatternValid(newPattern)) return;
|
||||
setFilterPattern(newPattern);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.filter-menu {
|
||||
width: 400px;
|
||||
|
||||
&.main {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.controls .bp4-form-content {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
|
||||
.type-selector {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-bar {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Button, ButtonGroup, FormGroup, HTMLSelect } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import type { FilterPattern, FilterPatternType, SqlExpression } from '@druid-toolkit/query';
|
||||
import { changeFilterPatternType, FILTER_PATTERN_TYPES } from '@druid-toolkit/query';
|
||||
import type { JSX } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ColumnPickerMenu } from '../../column-picker-menu/column-picker-menu';
|
||||
import type { Dataset } from '../../utils';
|
||||
import { initPatternForColumn } from '../pattern-helpers';
|
||||
|
||||
import { ContainsFilterControl } from './contains-filter-control/contains-filter-control';
|
||||
import { CustomFilterControl } from './custom-filter-control/custom-filter-control';
|
||||
import { RegexpFilterControl } from './regexp-filter-control/regexp-filter-control';
|
||||
import { TimeIntervalFilterControl } from './time-interval-filter-control/time-interval-filter-control';
|
||||
import { TimeRelativeFilterControl } from './time-relative-filter-control/time-relative-filter-control';
|
||||
import { ValuesFilterControl } from './values-filter-control/values-filter-control';
|
||||
|
||||
import './filter-menu.scss';
|
||||
|
||||
const PATTERN_TYPE_TO_NAME: Record<FilterPatternType, string> = {
|
||||
values: 'Values',
|
||||
contains: 'Contains',
|
||||
custom: 'Custom',
|
||||
mvContains: 'Multi-value contains',
|
||||
numberRange: 'Number range',
|
||||
regexp: 'Regular expression',
|
||||
timeInterval: 'Time interval',
|
||||
timeRelative: 'Time relative',
|
||||
};
|
||||
|
||||
export interface FilterMenuProps {
|
||||
dataset: Dataset;
|
||||
filter: SqlExpression;
|
||||
initPattern?: FilterPattern;
|
||||
onPatternChange(newPattern: FilterPattern): void;
|
||||
onClose(): void;
|
||||
queryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]>;
|
||||
}
|
||||
|
||||
export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps) {
|
||||
const { dataset, filter, initPattern, onPatternChange, onClose, queryDruidSql } = props;
|
||||
|
||||
const [pattern, setPattern] = useState<FilterPattern | undefined>(initPattern);
|
||||
const [negated, setNegated] = useState(Boolean(pattern?.negated));
|
||||
|
||||
const { columns } = dataset;
|
||||
|
||||
if (!pattern) {
|
||||
return (
|
||||
<ColumnPickerMenu
|
||||
className="filter-menu"
|
||||
columns={columns}
|
||||
onSelectColumn={c => setPattern(initPatternForColumn(c))}
|
||||
iconForColumn={c => (filter.containsColumnName(c.name) ? IconNames.FILTER : undefined)}
|
||||
shouldDismissPopover={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function onAcceptPattern(pattern: FilterPattern) {
|
||||
onPatternChange({ ...pattern, negated });
|
||||
onClose();
|
||||
}
|
||||
|
||||
let cont: JSX.Element;
|
||||
switch (pattern.type) {
|
||||
case 'values':
|
||||
cont = (
|
||||
<ValuesFilterControl
|
||||
dataset={dataset}
|
||||
filter={filter.removeColumnFromAnd(pattern.column)}
|
||||
initFilterPattern={pattern}
|
||||
negated={negated}
|
||||
setFilterPattern={onAcceptPattern}
|
||||
onClose={onClose}
|
||||
queryDruidSql={queryDruidSql}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'contains':
|
||||
cont = (
|
||||
<ContainsFilterControl
|
||||
dataset={dataset}
|
||||
filter={filter.removeColumnFromAnd(pattern.column)}
|
||||
initFilterPattern={pattern}
|
||||
negated={negated}
|
||||
setFilterPattern={onAcceptPattern}
|
||||
queryDruidSql={queryDruidSql}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'regexp':
|
||||
cont = (
|
||||
<RegexpFilterControl
|
||||
dataset={dataset}
|
||||
filter={filter.removeColumnFromAnd(pattern.column)}
|
||||
initFilterPattern={pattern}
|
||||
negated={negated}
|
||||
setFilterPattern={onAcceptPattern}
|
||||
queryDruidSql={queryDruidSql}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'timeInterval':
|
||||
cont = (
|
||||
<TimeIntervalFilterControl
|
||||
dataset={dataset}
|
||||
initFilterPattern={pattern}
|
||||
negated={negated}
|
||||
setFilterPattern={onAcceptPattern}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'timeRelative':
|
||||
cont = (
|
||||
<TimeRelativeFilterControl
|
||||
dataset={dataset}
|
||||
initFilterPattern={pattern}
|
||||
negated={negated}
|
||||
setFilterPattern={onAcceptPattern}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'custom':
|
||||
cont = (
|
||||
<CustomFilterControl
|
||||
initFilterPattern={pattern}
|
||||
negated={negated}
|
||||
setFilterPattern={onAcceptPattern}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
cont = <div />; // TODO fix
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="filter-menu main">
|
||||
<FormGroup className="controls">
|
||||
<HTMLSelect
|
||||
className="type-selector"
|
||||
value={pattern.type}
|
||||
onChange={e =>
|
||||
setPattern(changeFilterPatternType(pattern, e.target.value as FilterPatternType))
|
||||
}
|
||||
>
|
||||
{FILTER_PATTERN_TYPES.map(type => (
|
||||
<option key={type} value={type}>
|
||||
{PATTERN_TYPE_TO_NAME[type]}
|
||||
</option>
|
||||
))}
|
||||
</HTMLSelect>
|
||||
<ButtonGroup>
|
||||
<Button icon={IconNames.FILTER} active={!negated} onClick={() => setNegated(false)} />
|
||||
<Button
|
||||
icon={IconNames.FILTER_REMOVE}
|
||||
active={negated}
|
||||
onClick={() => setNegated(true)}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</FormGroup>
|
||||
{cont}
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.regexp-filter-control {
|
||||
.preview-list {
|
||||
padding: 5px 0;
|
||||
height: 300px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(15, 19, 32, 0.4);
|
||||
|
||||
.preview-item {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Button, FormGroup, InputGroup, Intent, Menu, MenuItem } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import type { RegexpFilterPattern } from '@druid-toolkit/query';
|
||||
import { C, filterPatternToExpression, SqlExpression, SqlLiteral } from '@druid-toolkit/query';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { useQueryManager } from '../../../../../hooks';
|
||||
import { ColumnPicker } from '../../../column-picker/column-picker';
|
||||
import type { Dataset } from '../../../utils';
|
||||
|
||||
import './regexp-filter-control.scss';
|
||||
|
||||
function regexpIssue(possibleRegexp: string): string | undefined {
|
||||
try {
|
||||
new RegExp(possibleRegexp);
|
||||
return;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RegexpFilterControlProps {
|
||||
dataset: Dataset;
|
||||
filter: SqlExpression | undefined;
|
||||
initFilterPattern: RegexpFilterPattern;
|
||||
negated: boolean;
|
||||
setFilterPattern(filterPattern: RegexpFilterPattern): void;
|
||||
queryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]>;
|
||||
}
|
||||
|
||||
export const RegexpFilterControl = React.memo(function RegexpFilterControl(
|
||||
props: RegexpFilterControlProps,
|
||||
) {
|
||||
const { dataset, filter, initFilterPattern, negated, setFilterPattern, queryDruidSql } = props;
|
||||
const [column, setColumn] = useState<string>(initFilterPattern.column);
|
||||
const [regexp, setRegexp] = useState(initFilterPattern.regexp);
|
||||
|
||||
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');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps
|
||||
}, [dataset.table, filter, column, regexp, negated]);
|
||||
|
||||
const [previewState] = useQueryManager<string, string[]>({
|
||||
query: previewQuery,
|
||||
debounceIdle: 100,
|
||||
debounceLoading: 500,
|
||||
processQuery: async query => {
|
||||
const vs = await queryDruidSql<{ c: any }>({
|
||||
query,
|
||||
});
|
||||
|
||||
return vs.map(d => String(d.c));
|
||||
},
|
||||
});
|
||||
|
||||
const issue = regexpIssue(regexp);
|
||||
return (
|
||||
<div className="regexp-filter-control">
|
||||
<FormGroup label="Column">
|
||||
<ColumnPicker
|
||||
availableColumns={dataset.columns}
|
||||
selectedColumnName={column}
|
||||
onSelectedColumnNameChange={setColumn}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<InputGroup value={regexp} onChange={e => setRegexp(e.target.value)} placeholder="Regexp" />
|
||||
</FormGroup>
|
||||
<FormGroup label="Preview">
|
||||
<Menu className="preview-list">
|
||||
{issue ? (
|
||||
<MenuItem disabled text={`Invalid regexp: ${issue}`} />
|
||||
) : (
|
||||
<>
|
||||
{previewState.data?.map((v, i) => (
|
||||
<MenuItem
|
||||
key={i}
|
||||
className="preview-item"
|
||||
text={String(v)}
|
||||
shouldDismissPopover={false}
|
||||
/>
|
||||
))}
|
||||
{previewState.loading && <MenuItem disabled text="Loading..." />}
|
||||
{previewState.error && (
|
||||
<MenuItem icon={IconNames.ERROR} disabled text={previewState.getErrorMessage()} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</FormGroup>
|
||||
<div className="button-bar">
|
||||
<Button
|
||||
intent={Intent.PRIMARY}
|
||||
text="OK"
|
||||
onClick={() => {
|
||||
const newPattern = makePattern();
|
||||
// TODO: check if pattern is valid
|
||||
// if (!isFilterPatternValid(newPattern)) return;
|
||||
setFilterPattern(newPattern);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,129 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Button, FormGroup, InputGroup, Intent } from '@blueprintjs/core';
|
||||
import type { TimeIntervalFilterPattern } from '@druid-toolkit/query';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ColumnPicker } from '../../../column-picker/column-picker';
|
||||
import type { Dataset } from '../../../utils';
|
||||
|
||||
function utcParseDate(dateString: string): Date | undefined {
|
||||
const dateParts = dateString.split(/[-T:. ]/g);
|
||||
|
||||
// Extract the individual date and time components
|
||||
const year = parseInt(dateParts[0], 10);
|
||||
if (!(1000 < year && year < 4000)) return;
|
||||
|
||||
const month = parseInt(dateParts[1], 10);
|
||||
if (month > 12) return;
|
||||
|
||||
const day = parseInt(dateParts[2], 10);
|
||||
if (day > 31) return;
|
||||
|
||||
const hour = parseInt(dateParts[3], 10);
|
||||
if (hour > 23) return;
|
||||
|
||||
const minute = parseInt(dateParts[4], 10);
|
||||
if (minute > 59) return;
|
||||
|
||||
const second = parseInt(dateParts[5], 10);
|
||||
if (second > 59) return;
|
||||
|
||||
const millisecond = parseInt(dateParts[6], 10);
|
||||
if (millisecond >= 1000) return;
|
||||
|
||||
return new Date(Date.UTC(year, month - 1, day, hour, minute, second)); // Month is zero-based
|
||||
}
|
||||
|
||||
function normalizeDateString(dateString: string): string {
|
||||
return dateString.replace(/[^\-0-9T:./Z ]/g, '');
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toISOString().replace(/Z$/, '').replace('.000', '').replace(/T/g, ' ');
|
||||
}
|
||||
|
||||
export interface TimeIntervalFilterControlProps {
|
||||
dataset: Dataset;
|
||||
initFilterPattern: TimeIntervalFilterPattern;
|
||||
negated: boolean;
|
||||
setFilterPattern(filterPattern: TimeIntervalFilterPattern): void;
|
||||
}
|
||||
|
||||
export const TimeIntervalFilterControl = React.memo(function TimeIntervalFilterControl(
|
||||
props: TimeIntervalFilterControlProps,
|
||||
) {
|
||||
const { dataset, initFilterPattern, negated, setFilterPattern } = props;
|
||||
const [column, setColumn] = useState<string>(initFilterPattern.column);
|
||||
const [startString, setStartString] = useState<string>(formatDate(initFilterPattern.start));
|
||||
const [endString, setEndString] = useState<string>(formatDate(initFilterPattern.end));
|
||||
|
||||
function makePattern(): TimeIntervalFilterPattern | undefined {
|
||||
const start = utcParseDate(startString);
|
||||
if (!start) return;
|
||||
|
||||
const end = utcParseDate(endString);
|
||||
if (!end) return;
|
||||
|
||||
return {
|
||||
type: 'timeInterval',
|
||||
negated,
|
||||
column,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="time-interval-filter-control">
|
||||
<FormGroup label="Column">
|
||||
<ColumnPicker
|
||||
availableColumns={dataset.columns}
|
||||
selectedColumnName={column}
|
||||
onSelectedColumnNameChange={setColumn}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label="Start">
|
||||
<InputGroup
|
||||
value={startString}
|
||||
onChange={e => setStartString(normalizeDateString(e.target.value))}
|
||||
placeholder="2022-02-01 00:00:00"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label="End">
|
||||
<InputGroup
|
||||
value={endString}
|
||||
onChange={e => setEndString(normalizeDateString(e.target.value))}
|
||||
placeholder="2022-02-01 00:00:00"
|
||||
/>
|
||||
</FormGroup>
|
||||
<div className="button-bar">
|
||||
<Button
|
||||
intent={Intent.PRIMARY}
|
||||
text="OK"
|
||||
onClick={() => {
|
||||
const newPattern = makePattern();
|
||||
if (!newPattern) return;
|
||||
setFilterPattern(newPattern);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,193 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Button, FormGroup } from '@blueprintjs/core';
|
||||
import type { TimeRelativeFilterPattern } from '@druid-toolkit/query';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ColumnPicker } from '../../../column-picker/column-picker';
|
||||
import type { Dataset } from '../../../utils';
|
||||
|
||||
interface PartialPattern {
|
||||
anchor: 'currentTimestamp' | 'maxDataTime';
|
||||
rangeDuration: string;
|
||||
alignType?: 'floor' | 'ceil';
|
||||
alignDuration?: string;
|
||||
shiftDuration?: string;
|
||||
shiftStep?: number;
|
||||
}
|
||||
|
||||
function partialPatternToKey(partialPattern: PartialPattern): string {
|
||||
return [
|
||||
partialPattern.anchor,
|
||||
partialPattern.rangeDuration,
|
||||
partialPattern.alignType || '-',
|
||||
partialPattern.alignDuration || '-',
|
||||
partialPattern.shiftDuration || '-',
|
||||
partialPattern.shiftStep || '-',
|
||||
].join(',');
|
||||
}
|
||||
|
||||
interface NamedPartialPattern {
|
||||
name: string;
|
||||
partialPattern: PartialPattern;
|
||||
}
|
||||
|
||||
interface GroupedNamedPartialPatterns {
|
||||
groupName: string;
|
||||
namedPartialPatterns: NamedPartialPattern[];
|
||||
}
|
||||
|
||||
const GROUPS: GroupedNamedPartialPatterns[] = [
|
||||
{
|
||||
groupName: 'Latest',
|
||||
namedPartialPatterns: [
|
||||
{
|
||||
name: 'Hour',
|
||||
partialPattern: {
|
||||
anchor: 'maxDataTime',
|
||||
rangeDuration: 'PT1H',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Day',
|
||||
partialPattern: {
|
||||
anchor: 'maxDataTime',
|
||||
rangeDuration: 'P1D',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Week',
|
||||
partialPattern: {
|
||||
anchor: 'maxDataTime',
|
||||
rangeDuration: 'P1W',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: 'Current',
|
||||
namedPartialPatterns: [
|
||||
{
|
||||
name: 'Hour',
|
||||
partialPattern: {
|
||||
anchor: 'currentTimestamp',
|
||||
alignType: 'ceil',
|
||||
alignDuration: 'PT1H',
|
||||
rangeDuration: 'PT1H',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Day',
|
||||
partialPattern: {
|
||||
anchor: 'currentTimestamp',
|
||||
alignType: 'ceil',
|
||||
alignDuration: 'P1D',
|
||||
rangeDuration: 'P1D',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Week',
|
||||
partialPattern: {
|
||||
anchor: 'currentTimestamp',
|
||||
alignType: 'ceil',
|
||||
alignDuration: 'P1W',
|
||||
rangeDuration: 'P1W',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: 'Previous',
|
||||
namedPartialPatterns: [
|
||||
{
|
||||
name: 'Hour',
|
||||
partialPattern: {
|
||||
anchor: 'currentTimestamp',
|
||||
alignType: 'floor',
|
||||
alignDuration: 'PT1H',
|
||||
rangeDuration: 'PT1H',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Day',
|
||||
partialPattern: {
|
||||
anchor: 'currentTimestamp',
|
||||
alignType: 'floor',
|
||||
alignDuration: 'P1D',
|
||||
rangeDuration: 'P1D',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Week',
|
||||
partialPattern: {
|
||||
anchor: 'currentTimestamp',
|
||||
alignType: 'floor',
|
||||
alignDuration: 'P1W',
|
||||
rangeDuration: 'P1W',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export interface TimeRelativeFilterControlProps {
|
||||
dataset: Dataset;
|
||||
initFilterPattern: TimeRelativeFilterPattern;
|
||||
negated: boolean;
|
||||
setFilterPattern(filterPattern: TimeRelativeFilterPattern): void;
|
||||
}
|
||||
|
||||
export const TimeRelativeFilterControl = React.memo(function TimeRelativeFilterControl(
|
||||
props: TimeRelativeFilterControlProps,
|
||||
) {
|
||||
const { dataset, initFilterPattern, negated, setFilterPattern } = props;
|
||||
const [column, setColumn] = useState<string>(initFilterPattern.column);
|
||||
|
||||
const initKey = partialPatternToKey(initFilterPattern);
|
||||
return (
|
||||
<div className="time-relative-filter-control">
|
||||
<FormGroup label="Column">
|
||||
<ColumnPicker
|
||||
availableColumns={dataset.columns}
|
||||
selectedColumnName={column}
|
||||
onSelectedColumnNameChange={setColumn}
|
||||
/>
|
||||
</FormGroup>
|
||||
{GROUPS.map(({ groupName, namedPartialPatterns }, i) => (
|
||||
<FormGroup key={i} label={groupName}>
|
||||
{namedPartialPatterns.map(({ name, partialPattern }, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
text={name}
|
||||
active={initKey === partialPatternToKey(partialPattern)}
|
||||
onClick={() => {
|
||||
setFilterPattern({
|
||||
type: 'timeRelative',
|
||||
negated,
|
||||
column,
|
||||
...partialPattern,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.values-filter-control {
|
||||
.value-list {
|
||||
padding: 5px 0;
|
||||
height: 300px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(15, 19, 32, 0.4);
|
||||
border-top: none;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Button, FormGroup, InputGroup, Intent, Menu, MenuItem } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import type { 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, nonEmptyArray } from '../../../../../utils';
|
||||
import { ColumnPicker } from '../../../column-picker/column-picker';
|
||||
import type { Dataset } from '../../../utils';
|
||||
import { toggle } from '../../../utils';
|
||||
import { ColumnValue } from '../../column-value/column-value';
|
||||
|
||||
import './values-filter-control.scss';
|
||||
|
||||
export interface ValuesFilterControlProps {
|
||||
dataset: Dataset;
|
||||
filter: SqlExpression | undefined;
|
||||
initFilterPattern: ValuesFilterPattern;
|
||||
negated: boolean;
|
||||
setFilterPattern(filterPattern: ValuesFilterPattern): void;
|
||||
onClose(): void;
|
||||
queryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]>;
|
||||
}
|
||||
|
||||
export const ValuesFilterControl = React.memo(function ValuesFilterControl(
|
||||
props: ValuesFilterControlProps,
|
||||
) {
|
||||
const { dataset, filter, initFilterPattern, negated, setFilterPattern, onClose, queryDruidSql } =
|
||||
props;
|
||||
const [column, setColumn] = useState<string>(initFilterPattern.column);
|
||||
const [selectedValues, setSelectedValues] = useState<any[]>(initFilterPattern.values);
|
||||
const [searchString, setSearchString] = useState('');
|
||||
|
||||
function makePattern(): ValuesFilterPattern {
|
||||
return {
|
||||
type: 'values',
|
||||
negated,
|
||||
column,
|
||||
values: selectedValues,
|
||||
};
|
||||
}
|
||||
|
||||
const valuesQuery = useMemo(() => {
|
||||
const columnRef = C(column);
|
||||
const queryParts: string[] = [`SELECT ${columnRef.as('c')}`, `FROM ${dataset.table}`];
|
||||
|
||||
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');
|
||||
}, [dataset.table, filter, column, searchString]);
|
||||
|
||||
const [valuesState] = useQueryManager<string, any[]>({
|
||||
query: valuesQuery,
|
||||
debounceIdle: 100,
|
||||
debounceLoading: 500,
|
||||
processQuery: async query => {
|
||||
const vs = await queryDruidSql<{ c: any }>({
|
||||
query,
|
||||
});
|
||||
|
||||
return vs.map(d => d.c);
|
||||
},
|
||||
});
|
||||
|
||||
const filterPatternValues = initFilterPattern.values;
|
||||
let valuesToShow: any[] = filterPatternValues;
|
||||
const values = valuesState.data;
|
||||
if (values) {
|
||||
valuesToShow = valuesToShow.concat(values.filter(v => !filterPatternValues.includes(v)));
|
||||
}
|
||||
if (searchString) {
|
||||
valuesToShow = valuesToShow.filter(v => caseInsensitiveContains(v, searchString));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="values-filter-control">
|
||||
<FormGroup label="Column">
|
||||
<ColumnPicker
|
||||
availableColumns={dataset.columns}
|
||||
selectedColumnName={column}
|
||||
onSelectedColumnNameChange={selectedColumnName => {
|
||||
setColumn(selectedColumnName);
|
||||
setSelectedValues([]);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<InputGroup
|
||||
value={searchString}
|
||||
onChange={e => setSearchString(e.target.value)}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
<Menu className="value-list">
|
||||
{valuesToShow.map((v, i) => (
|
||||
<MenuItem
|
||||
key={i}
|
||||
icon={
|
||||
selectedValues.includes(v)
|
||||
? negated
|
||||
? IconNames.DELETE
|
||||
: IconNames.FULL_CIRCLE
|
||||
: IconNames.CIRCLE
|
||||
}
|
||||
text={<ColumnValue value={v} />}
|
||||
shouldDismissPopover={false}
|
||||
onClick={e => {
|
||||
setSelectedValues(e.altKey ? [v] : toggle(selectedValues, v));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{valuesState.loading && (
|
||||
<MenuItem icon={IconNames.BLANK} disabled>
|
||||
Loading...
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</FormGroup>
|
||||
<div className="button-bar">
|
||||
<Button
|
||||
intent={Intent.PRIMARY}
|
||||
text="OK"
|
||||
onClick={() => {
|
||||
const newPattern = makePattern();
|
||||
if (nonEmptyArray(newPattern.values)) {
|
||||
setFilterPattern(newPattern);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -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';
|
||||
|
||||
.filter-pane {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
|
||||
.filter-label {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
position: relative;
|
||||
@include card-like;
|
||||
white-space: nowrap;
|
||||
|
||||
.filter-text-button {
|
||||
& > .#{$bp-ns}-button-text {
|
||||
max-width: 300px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 150%;
|
||||
}
|
||||
|
||||
&.negated > .#{$bp-ns}-button-text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
margin: 0 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
* 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 } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import { Popover2 } from '@blueprintjs/popover2';
|
||||
import type { FilterPattern, SqlExpression } 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 { 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;
|
||||
filter: SqlExpression;
|
||||
onFilterChange(filter: SqlExpression): void;
|
||||
queryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]>;
|
||||
}
|
||||
|
||||
export const FilterPane = forwardRef(function FilterPane(props: FilterPaneProps, ref) {
|
||||
const { dataset, filter, onFilterChange, queryDruidSql } = props;
|
||||
const patterns = fitFilterPatterns(filter);
|
||||
|
||||
const [menuIndex, setMenuIndex] = useState<number>(-1);
|
||||
const [menuNew, setMenuNew] = useState<{ column?: ExpressionMeta }>();
|
||||
|
||||
function filterOn(column: ExpressionMeta) {
|
||||
const relevantPatternIndex = patterns.findIndex(
|
||||
pattern =>
|
||||
pattern.type !== 'custom' && pattern.column === column.expression.getFirstColumnName(),
|
||||
);
|
||||
if (relevantPatternIndex < 0) {
|
||||
setMenuNew({ column });
|
||||
} else {
|
||||
setMenuIndex(relevantPatternIndex);
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
filterOn,
|
||||
}),
|
||||
[patterns],
|
||||
);
|
||||
|
||||
function changePatterns(newPatterns: FilterPattern[]) {
|
||||
onFilterChange(filterPatternsToExpression(newPatterns));
|
||||
}
|
||||
|
||||
return (
|
||||
<DroppableContainer className="filter-pane" onDropColumn={filterOn}>
|
||||
<Button className="filter-label" minimal text="Filter:" />
|
||||
{patterns.map((pattern, i) => {
|
||||
return (
|
||||
<div className="filter-pill" key={i}>
|
||||
{dataset ? (
|
||||
<Popover2
|
||||
isOpen={i === menuIndex}
|
||||
onClose={() => setMenuIndex(-1)}
|
||||
content={
|
||||
<FilterMenu
|
||||
dataset={dataset}
|
||||
filter={filter}
|
||||
initPattern={pattern}
|
||||
onPatternChange={newPattern => {
|
||||
changePatterns(patterns.map((c, idx) => (idx === i ? newPattern : c)));
|
||||
}}
|
||||
onClose={() => {
|
||||
setMenuIndex(-1);
|
||||
}}
|
||||
queryDruidSql={queryDruidSql}
|
||||
/>
|
||||
}
|
||||
position="bottom"
|
||||
>
|
||||
<Button
|
||||
className={classNames('filter-text-button', { negated: pattern.negated })}
|
||||
minimal
|
||||
text={formatPatternWithoutNegation(pattern)}
|
||||
onClick={() => setMenuIndex(i)}
|
||||
/>
|
||||
</Popover2>
|
||||
) : (
|
||||
<Button
|
||||
className={classNames('filter-text-button', { negated: pattern.negated })}
|
||||
minimal
|
||||
text={formatPatternWithoutNegation(pattern)}
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="remove"
|
||||
icon={IconNames.CROSS}
|
||||
minimal
|
||||
small
|
||||
onClick={() => changePatterns(patterns.filter((_clause, idx) => idx !== i))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{dataset && (
|
||||
<Popover2
|
||||
className="add-button"
|
||||
isOpen={Boolean(menuNew)}
|
||||
position="bottom"
|
||||
onClose={() => setMenuNew(undefined)}
|
||||
content={
|
||||
<FilterMenu
|
||||
dataset={dataset}
|
||||
filter={filter}
|
||||
initPattern={menuNew?.column ? initPatternForColumn(menuNew?.column) : undefined}
|
||||
onPatternChange={newPattern => {
|
||||
changePatterns(patterns.concat(newPattern));
|
||||
}}
|
||||
onClose={() => {
|
||||
setMenuNew(undefined);
|
||||
}}
|
||||
queryDruidSql={queryDruidSql}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button icon={IconNames.PLUS} onClick={() => setMenuNew({})} minimal />
|
||||
</Popover2>
|
||||
)}
|
||||
</DroppableContainer>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* 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 { FilterPattern } from '@druid-toolkit/query';
|
||||
import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
|
||||
|
||||
export function initPatternForColumn(column: ExpressionMeta): FilterPattern {
|
||||
switch (column.sqlType) {
|
||||
case 'TIMESTAMP':
|
||||
return {
|
||||
type: 'timeRelative',
|
||||
negated: false,
|
||||
column: column.name,
|
||||
anchor: 'maxDataTime',
|
||||
rangeDuration: 'P1D',
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'values',
|
||||
negated: false,
|
||||
column: column.name,
|
||||
values: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPatternWithoutNegation(pattern: FilterPattern): string {
|
||||
switch (pattern.type) {
|
||||
case 'values':
|
||||
return `${pattern.column}: ${pattern.values
|
||||
.map(v => (v === '' ? 'empty' : String(v)))
|
||||
.join(', ')}`;
|
||||
|
||||
case 'contains':
|
||||
return `${pattern.column} ~ '${pattern.contains}'`;
|
||||
|
||||
case 'regexp':
|
||||
return `${pattern.column} ~ /${pattern.regexp}/`;
|
||||
|
||||
case 'timeInterval': {
|
||||
let startString = pattern.start.toISOString().replace(/Z$/, '');
|
||||
let endString = pattern.end.toISOString().replace(/Z$/, '');
|
||||
|
||||
if (startString.endsWith('.000') && endString.endsWith('.000')) {
|
||||
startString = startString.replace(/\.000$/, '');
|
||||
endString = endString.replace(/\.000$/, '');
|
||||
}
|
||||
|
||||
if (startString.endsWith(':00') && endString.endsWith(':00')) {
|
||||
startString = startString.replace(/:00$/, '');
|
||||
endString = endString.replace(/:00$/, '');
|
||||
}
|
||||
|
||||
return `${startString}/${endString}`;
|
||||
}
|
||||
|
||||
case 'timeRelative':
|
||||
return `${pattern.column} in ${pattern.rangeDuration}`;
|
||||
|
||||
case 'numberRange':
|
||||
return `${pattern.column} in ${pattern.startBound}${pattern.start}, ${pattern.end}${pattern.endBound}`;
|
||||
|
||||
case 'mvContains':
|
||||
return `${pattern.column} on of ${pattern.values
|
||||
.map(v => (v === '' ? 'empty' : String(v)))
|
||||
.join(', ')}`;
|
||||
|
||||
case 'custom':
|
||||
return String(pattern.expression);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { C, SqlExpression } from '@druid-toolkit/query';
|
||||
import { typedVisualModule } from '@druid-toolkit/visuals-core';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
import { getInitQuery } from '../utils';
|
||||
|
||||
export default typedVisualModule({
|
||||
parameters: {
|
||||
splitColumn: {
|
||||
type: 'column',
|
||||
control: {
|
||||
label: 'Bar column',
|
||||
// transferGroup: 'show',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
metric: {
|
||||
type: 'aggregate',
|
||||
default: { expression: SqlExpression.parse('COUNT(*)'), name: 'Count', sqlType: 'BIGINT' },
|
||||
control: {
|
||||
label: 'Metric to show',
|
||||
// transferGroup: 'show-agg',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
metricToSort: {
|
||||
type: 'aggregate',
|
||||
control: {
|
||||
label: 'Metric to sort (default to shown)',
|
||||
},
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
default: 5,
|
||||
control: {
|
||||
label: 'Max bars to show',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
module: ({ container, host, getLastUpdateEvent, updateWhere }) => {
|
||||
const { sqlQuery } = host;
|
||||
const myChart = echarts.init(container, 'dark');
|
||||
|
||||
myChart.setOption({
|
||||
tooltip: {},
|
||||
dataset: {
|
||||
sourceHeader: false,
|
||||
dimensions: ['dim', 'met'],
|
||||
source: [],
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
axisLabel: { interval: 0, rotate: -30 },
|
||||
},
|
||||
yAxis: {},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
encode: {
|
||||
x: 'dim',
|
||||
y: 'met',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const resizeHandler = () => {
|
||||
myChart.resize();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
|
||||
myChart.on('click', 'series', p => {
|
||||
const lastUpdateEvent = getLastUpdateEvent();
|
||||
if (!lastUpdateEvent?.parameterValues.splitColumn) return;
|
||||
|
||||
updateWhere(
|
||||
lastUpdateEvent.where.toggleClauseInWhere(
|
||||
C(lastUpdateEvent.parameterValues.splitColumn.name).equal(p.name),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
async update({ table, where, parameterValues }) {
|
||||
const { splitColumn, metric, metricToSort, limit } = parameterValues;
|
||||
if (!splitColumn) return;
|
||||
|
||||
const v = await sqlQuery(
|
||||
getInitQuery(table, where)
|
||||
.addSelect(splitColumn.expression.as('dim'), { addToGroupBy: 'end' })
|
||||
.addSelect(metric.expression.as('met'), {
|
||||
addToOrderBy: metricToSort ? undefined : 'end',
|
||||
direction: 'DESC',
|
||||
})
|
||||
.applyIf(metricToSort, q =>
|
||||
q.addOrderBy(metricToSort!.expression.toOrderByExpression('DESC')),
|
||||
)
|
||||
.changeLimitValue(limit),
|
||||
);
|
||||
myChart.setOption({
|
||||
dataset: {
|
||||
source: v.toObjectArray(),
|
||||
},
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
myChart.dispose();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* 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';
|
||||
@import '../../../../../blueprint-overrides/common/colors';
|
||||
|
||||
.generic-output-table {
|
||||
position: relative;
|
||||
|
||||
&.more-results .-totalPages {
|
||||
// Hide the total page counter as it can be confusing due to the auto limit
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dead-end {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 45%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 350px;
|
||||
|
||||
p {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
> * {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.ReactTable {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
font-feature-settings: tnum;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
.rt-thead.-header {
|
||||
box-shadow: 0 1px 0 0 rgba(black, 0.2); // This is a hack! this line is sometimes too weak in tables.
|
||||
|
||||
.rt-th {
|
||||
&.aggregate-header {
|
||||
background: rgba($druid-brand, 0.06);
|
||||
}
|
||||
|
||||
.asc {
|
||||
box-shadow: inset 0 3px 0 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.desc {
|
||||
box-shadow: inset 0 -3px 0 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.#{$bp-ns}-icon {
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.output-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
> * {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
margin-top: 3px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.formula {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rt-td {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.group-cell {
|
||||
padding: $table-cell-v-padding $table-cell-h-padding;
|
||||
}
|
||||
|
||||
.clickable-cell {
|
||||
padding: $table-cell-v-padding $table-cell-h-padding;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.#{$bp-ns}-popover2-target {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aggregate-column {
|
||||
background-color: rgba($druid-brand, 0.06);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,525 @@
|
|||
/*
|
||||
* 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, Icon, Intent, Menu, MenuItem } from '@blueprintjs/core';
|
||||
import type { IconName } from '@blueprintjs/icons';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import { Popover2 } from '@blueprintjs/popover2';
|
||||
import type { Column, QueryResult, SqlExpression } from '@druid-toolkit/query';
|
||||
import { SqlColumn, SqlLiteral, trimString } from '@druid-toolkit/query';
|
||||
import classNames from 'classnames';
|
||||
import type { JSX } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { Column as TableColumn } from 'react-table';
|
||||
import ReactTable from 'react-table';
|
||||
|
||||
import { BracedText, Deferred, TableCell } from '../../../../../components';
|
||||
import { possibleDruidFormatForValues, TIME_COLUMN } from '../../../../../druid-models';
|
||||
import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from '../../../../../react-table';
|
||||
import type { Pagination, QueryAction } from '../../../../../utils';
|
||||
import {
|
||||
columnToIcon,
|
||||
columnToWidth,
|
||||
copyAndAlert,
|
||||
formatNumber,
|
||||
getNumericColumnBraces,
|
||||
prettyPrintSql,
|
||||
stringifyValue,
|
||||
timeFormatToSql,
|
||||
} from '../../../../../utils';
|
||||
|
||||
import './generic-output-table.scss';
|
||||
|
||||
function sqlLiteralForColumnValue(column: Column, value: unknown): SqlLiteral | undefined {
|
||||
if (column.sqlType === 'TIMESTAMP') {
|
||||
const asDate = new Date(value as any);
|
||||
if (!isNaN(asDate.valueOf())) {
|
||||
return SqlLiteral.create(asDate);
|
||||
}
|
||||
}
|
||||
|
||||
return SqlLiteral.maybe(value);
|
||||
}
|
||||
|
||||
function isComparable(x: unknown): boolean {
|
||||
return x !== null && x !== '';
|
||||
}
|
||||
|
||||
function columnNester(columns: TableColumn[], groupHints: string[] | undefined): TableColumn[] {
|
||||
if (!groupHints) return columns;
|
||||
|
||||
const ret: TableColumn[] = [];
|
||||
let currentGroupHint: string | null = null;
|
||||
let currentColumnGroup: TableColumn | null = null;
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const column = columns[i];
|
||||
const groupHint = groupHints[i];
|
||||
if (groupHint) {
|
||||
if (currentGroupHint === groupHint) {
|
||||
currentColumnGroup!.columns!.push(column);
|
||||
} else {
|
||||
currentGroupHint = groupHint;
|
||||
ret.push(
|
||||
(currentColumnGroup = {
|
||||
Header: <div className="group-cell">{currentGroupHint}</div>,
|
||||
columns: [column],
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
ret.push(column);
|
||||
currentGroupHint = null;
|
||||
currentColumnGroup = null;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
export interface GenericOutputTableProps {
|
||||
queryResult: QueryResult;
|
||||
onQueryAction(action: QueryAction): void;
|
||||
onOrderByChange?(columnIndex: number, desc: boolean): void;
|
||||
onExport?(): void;
|
||||
runeMode: boolean;
|
||||
showTypeIcons: boolean;
|
||||
initPageSize?: number;
|
||||
groupHints?: string[];
|
||||
}
|
||||
|
||||
export const GenericOutputTable = React.memo(function GenericOutputTable(
|
||||
props: GenericOutputTableProps,
|
||||
) {
|
||||
const {
|
||||
queryResult,
|
||||
onQueryAction,
|
||||
onOrderByChange,
|
||||
onExport,
|
||||
runeMode,
|
||||
showTypeIcons,
|
||||
initPageSize,
|
||||
groupHints,
|
||||
} = props;
|
||||
const parsedQuery = queryResult.sqlQuery;
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
page: 0,
|
||||
pageSize: initPageSize || 20,
|
||||
});
|
||||
|
||||
// Reset page to 0 if number of results changes
|
||||
useEffect(() => {
|
||||
setPagination(pagination => {
|
||||
return pagination.page ? { ...pagination, page: 0 } : pagination;
|
||||
});
|
||||
}, [queryResult.rows.length]);
|
||||
|
||||
function hasFilterOnHeader(header: string, headerIndex: number): boolean {
|
||||
if (!parsedQuery || !parsedQuery.isRealOutputColumnAtSelectIndex(headerIndex)) return false;
|
||||
|
||||
return (
|
||||
parsedQuery.getEffectiveWhereExpression().containsColumnName(header) ||
|
||||
parsedQuery.getEffectiveHavingExpression().containsColumnName(header)
|
||||
);
|
||||
}
|
||||
|
||||
function getHeaderMenu(column: Column, headerIndex: number) {
|
||||
const header = column.name;
|
||||
const ref = SqlColumn.create(header);
|
||||
const prettyRef = prettyPrintSql(ref);
|
||||
|
||||
const menuItems: JSX.Element[] = [];
|
||||
if (parsedQuery) {
|
||||
const noStar = !parsedQuery.hasStarInSelect();
|
||||
const selectExpression = parsedQuery.getSelectExpressionForIndex(headerIndex);
|
||||
|
||||
if (onOrderByChange) {
|
||||
const orderBy = parsedQuery.getOrderByForSelectIndex(headerIndex);
|
||||
|
||||
if (orderBy) {
|
||||
const reverseOrderBy = orderBy.reverseDirection();
|
||||
const reverseOrderByDirection = reverseOrderBy.getEffectiveDirection();
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key="order"
|
||||
icon={reverseOrderByDirection === 'ASC' ? IconNames.SORT_ASC : IconNames.SORT_DESC}
|
||||
text={`Order ${reverseOrderByDirection === 'ASC' ? 'ascending' : 'descending'}`}
|
||||
onClick={() => {
|
||||
onOrderByChange(headerIndex, reverseOrderByDirection !== 'ASC');
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key="order_desc"
|
||||
icon={IconNames.SORT_DESC}
|
||||
text="Order descending"
|
||||
onClick={() => {
|
||||
onOrderByChange(headerIndex, true);
|
||||
}}
|
||||
/>,
|
||||
<MenuItem
|
||||
key="order_asc"
|
||||
icon={IconNames.SORT_ASC}
|
||||
text="Order ascending"
|
||||
onClick={() => {
|
||||
onOrderByChange(headerIndex, false);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedQuery.isRealOutputColumnAtSelectIndex(headerIndex)) {
|
||||
const whereExpression = parsedQuery.getWhereExpression();
|
||||
if (whereExpression && whereExpression.containsColumnName(header)) {
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key="remove_where"
|
||||
icon={IconNames.FILTER_REMOVE}
|
||||
text="Remove from WHERE clause"
|
||||
onClick={() => {
|
||||
onQueryAction(q =>
|
||||
q.changeWhereExpression(whereExpression.removeColumnFromAnd(header)),
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const havingExpression = parsedQuery.getHavingExpression();
|
||||
if (havingExpression && havingExpression.containsColumnName(header)) {
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key="remove_having"
|
||||
icon={IconNames.FILTER_REMOVE}
|
||||
text="Remove from HAVING clause"
|
||||
onClick={() => {
|
||||
onQueryAction(q =>
|
||||
q.changeHavingExpression(havingExpression.removeColumnFromAnd(header)),
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (noStar && selectExpression) {
|
||||
if (column.isTimeColumn()) {
|
||||
// ToDo: clean
|
||||
} else if (column.sqlType === 'TIMESTAMP') {
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key="declare_time"
|
||||
icon={IconNames.TIME}
|
||||
text="Use as the primary time column"
|
||||
onClick={() => {
|
||||
onQueryAction(q => q.changeSelect(headerIndex, selectExpression.as(TIME_COLUMN)));
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
// Not a time column -------------------------------------------
|
||||
const values = queryResult.rows.map(row => row[headerIndex]);
|
||||
const possibleDruidFormat = possibleDruidFormatForValues(values);
|
||||
const formatSql = possibleDruidFormat ? timeFormatToSql(possibleDruidFormat) : undefined;
|
||||
|
||||
if (formatSql) {
|
||||
const newSelectExpression = formatSql.fillPlaceholders([
|
||||
selectExpression.getUnderlyingExpression(),
|
||||
]);
|
||||
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key="parse_time"
|
||||
icon={IconNames.TIME}
|
||||
text={`Time parse as '${possibleDruidFormat}' and use as the primary time column`}
|
||||
onClick={() => {
|
||||
onQueryAction(q =>
|
||||
q.changeSelect(headerIndex, newSelectExpression.as(TIME_COLUMN)),
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key="copy_ref"
|
||||
icon={IconNames.CLIPBOARD}
|
||||
text={`Copy: ${prettyRef}`}
|
||||
onClick={() => {
|
||||
copyAndAlert(String(ref), `${prettyRef}' copied to clipboard`);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
if (!runeMode) {
|
||||
const orderByExpression = SqlColumn.create(header);
|
||||
const descOrderBy = orderByExpression.toOrderByExpression('DESC');
|
||||
const ascOrderBy = orderByExpression.toOrderByExpression('ASC');
|
||||
const descOrderByPretty = prettyPrintSql(descOrderBy);
|
||||
const ascOrderByPretty = prettyPrintSql(descOrderBy);
|
||||
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key="copy_desc"
|
||||
icon={IconNames.CLIPBOARD}
|
||||
text={`Copy: ${descOrderByPretty}`}
|
||||
onClick={() =>
|
||||
copyAndAlert(descOrderBy.toString(), `'${descOrderByPretty}' copied to clipboard`)
|
||||
}
|
||||
/>,
|
||||
<MenuItem
|
||||
key="copy_asc"
|
||||
icon={IconNames.CLIPBOARD}
|
||||
text={`Copy: ${ascOrderByPretty}`}
|
||||
onClick={() =>
|
||||
copyAndAlert(ascOrderBy.toString(), `'${ascOrderByPretty}' copied to clipboard`)
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <Menu>{menuItems}</Menu>;
|
||||
}
|
||||
|
||||
function filterOnMenuItem(icon: IconName, clause: SqlExpression, having: boolean) {
|
||||
if (!parsedQuery) return;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
icon={icon}
|
||||
text={`${having ? 'Having' : 'Filter on'}: ${prettyPrintSql(clause)}`}
|
||||
onClick={() => {
|
||||
const columnName = clause.getUsedColumnNames()[0];
|
||||
onQueryAction(
|
||||
having
|
||||
? q => q.removeFromHaving(columnName).addHaving(clause)
|
||||
: q => q.removeColumnFromWhere(columnName).addWhere(clause),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function clipboardMenuItem(clause: SqlExpression) {
|
||||
const prettyLabel = prettyPrintSql(clause);
|
||||
return (
|
||||
<MenuItem
|
||||
icon={IconNames.CLIPBOARD}
|
||||
text={`Copy: ${prettyLabel}`}
|
||||
onClick={() => copyAndAlert(clause.toString(), `${prettyLabel} copied to clipboard`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getCellMenu(column: Column, headerIndex: number, value: unknown) {
|
||||
const showFullValueMenuItem = (
|
||||
<MenuItem
|
||||
icon={IconNames.EYE_OPEN}
|
||||
text="Show full value"
|
||||
onClick={() => {
|
||||
// ToDo: clean up
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const val = sqlLiteralForColumnValue(column, value);
|
||||
|
||||
if (parsedQuery) {
|
||||
let ex: SqlExpression | undefined;
|
||||
let having = false;
|
||||
if (parsedQuery.hasStarInSelect()) {
|
||||
ex = SqlColumn.create(column.name);
|
||||
} else {
|
||||
const selectValue = parsedQuery.getSelectExpressionForIndex(headerIndex);
|
||||
if (selectValue) {
|
||||
const outputName = selectValue.getOutputName();
|
||||
having = parsedQuery.isAggregateSelectIndex(headerIndex);
|
||||
if (having && outputName) {
|
||||
ex = SqlColumn.create(outputName);
|
||||
} else {
|
||||
ex = selectValue.getUnderlyingExpression();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jsonColumn = column.nativeType === 'COMPLEX<json>';
|
||||
return (
|
||||
<Menu>
|
||||
{ex && val && !jsonColumn && (
|
||||
<>
|
||||
{filterOnMenuItem(IconNames.FILTER, ex.equal(val), having)}
|
||||
{filterOnMenuItem(IconNames.FILTER, ex.unequal(val), having)}
|
||||
{isComparable(value) && (
|
||||
<>
|
||||
{filterOnMenuItem(IconNames.FILTER, ex.greaterThanOrEqual(val), having)}
|
||||
{filterOnMenuItem(IconNames.FILTER, ex.lessThanOrEqual(val), having)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showFullValueMenuItem}
|
||||
</Menu>
|
||||
);
|
||||
} else {
|
||||
const ref = SqlColumn.create(column.name);
|
||||
const stringValue = stringifyValue(value);
|
||||
const trimmedValue = trimString(stringValue, 50);
|
||||
return (
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon={IconNames.CLIPBOARD}
|
||||
text={`Copy: ${trimmedValue}`}
|
||||
onClick={() => copyAndAlert(stringValue, `${trimmedValue} copied to clipboard`)}
|
||||
/>
|
||||
{!runeMode && val && (
|
||||
<>
|
||||
{clipboardMenuItem(ref.equal(val))}
|
||||
{clipboardMenuItem(ref.unequal(val))}
|
||||
</>
|
||||
)}
|
||||
{showFullValueMenuItem}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getHeaderClassName(header: string) {
|
||||
if (!parsedQuery) return;
|
||||
|
||||
const className = [];
|
||||
|
||||
const orderBy = parsedQuery.getOrderByForOutputColumn(header);
|
||||
if (orderBy) {
|
||||
className.push(orderBy.getEffectiveDirection() === 'DESC' ? '-sort-desc' : '-sort-asc');
|
||||
}
|
||||
|
||||
if (parsedQuery.isAggregateOutputColumn(header)) {
|
||||
className.push('aggregate-header');
|
||||
}
|
||||
|
||||
return className.join(' ');
|
||||
}
|
||||
|
||||
const outerLimit = queryResult.getSqlOuterLimit();
|
||||
const hasMoreResults = queryResult.rows.length === outerLimit;
|
||||
const finalPage =
|
||||
hasMoreResults && Math.floor(queryResult.rows.length / pagination.pageSize) === pagination.page; // on the last page
|
||||
|
||||
const numericColumnBraces = getNumericColumnBraces(queryResult, pagination);
|
||||
return (
|
||||
<div className={classNames('generic-output-table', { 'more-results': hasMoreResults })}>
|
||||
{finalPage ? (
|
||||
<div className="dead-end">
|
||||
<p>This is the end of the inline results but there are more results in this query.</p>
|
||||
{onExport && (
|
||||
<>
|
||||
<p>If you want to see the full list of results you should export them.</p>
|
||||
<Button
|
||||
icon={IconNames.DOWNLOAD}
|
||||
text="Export results"
|
||||
intent={Intent.PRIMARY}
|
||||
fill
|
||||
onClick={onExport}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
icon={IconNames.ARROW_LEFT}
|
||||
text="Go to previous page"
|
||||
fill
|
||||
onClick={() => setPagination({ ...pagination, page: pagination.page - 1 })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ReactTable
|
||||
className="-striped -highlight"
|
||||
data={queryResult.rows as any[][]}
|
||||
ofText={hasMoreResults ? '' : 'of'}
|
||||
noDataText={queryResult.rows.length ? '' : 'Query returned no data'}
|
||||
page={pagination.page}
|
||||
pageSize={pagination.pageSize}
|
||||
onPageChange={page => setPagination({ ...pagination, page })}
|
||||
onPageSizeChange={(pageSize, page) => setPagination({ page, pageSize })}
|
||||
sortable={false}
|
||||
defaultPageSize={SMALL_TABLE_PAGE_SIZE}
|
||||
pageSizeOptions={SMALL_TABLE_PAGE_SIZE_OPTIONS}
|
||||
showPagination={
|
||||
queryResult.rows.length > Math.min(SMALL_TABLE_PAGE_SIZE, pagination.pageSize)
|
||||
}
|
||||
columns={columnNester(
|
||||
queryResult.header.map((column, i) => {
|
||||
const h = column.name;
|
||||
const icon = showTypeIcons ? columnToIcon(column) : undefined;
|
||||
|
||||
return {
|
||||
Header() {
|
||||
return (
|
||||
<Popover2 content={<Deferred content={() => getHeaderMenu(column, i)} />}>
|
||||
<div className="clickable-cell">
|
||||
<div className="output-name">
|
||||
{icon && <Icon className="type-icon" icon={icon} size={12} />}
|
||||
{h}
|
||||
{hasFilterOnHeader(h, i) && <Icon icon={IconNames.FILTER} size={14} />}
|
||||
</div>
|
||||
</div>
|
||||
</Popover2>
|
||||
);
|
||||
},
|
||||
headerClassName: getHeaderClassName(h),
|
||||
accessor: String(i),
|
||||
Cell(row) {
|
||||
const value = row.value;
|
||||
return (
|
||||
<div>
|
||||
<Popover2
|
||||
content={<Deferred content={() => getCellMenu(column, i, value)} />}
|
||||
>
|
||||
{numericColumnBraces[i] ? (
|
||||
<BracedText
|
||||
className="table-padding"
|
||||
text={formatNumber(value)}
|
||||
braces={numericColumnBraces[i]}
|
||||
padFractionalPart
|
||||
/>
|
||||
) : (
|
||||
<TableCell value={value} unlimited />
|
||||
)}
|
||||
</Popover2>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
width: columnToWidth(column),
|
||||
className:
|
||||
parsedQuery && parsedQuery.isAggregateOutputColumn(h)
|
||||
? 'aggregate-column'
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
groupHints,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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 './generic-output-table/generic-output-table';
|
|
@ -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.
|
||||
*/
|
||||
|
||||
export * from './bar-chart-echarts-module';
|
||||
export * from './multi-axis-chart-echarts-module';
|
||||
export * from './pie-chart-echarts-module';
|
||||
export * from './table-react-module';
|
||||
export * from './time-chart-echarts-module';
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { C, F, L, SqlExpression } from '@druid-toolkit/query';
|
||||
import { typedVisualModule } from '@druid-toolkit/visuals-core';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
import { getInitQuery } from '../utils';
|
||||
|
||||
export default typedVisualModule({
|
||||
parameters: {
|
||||
timeGranularity: {
|
||||
type: 'option',
|
||||
options: ['PT1M', 'PT5M', 'PT30M', 'PT1H', 'P1D'],
|
||||
default: 'PT1H',
|
||||
control: {
|
||||
optionLabels: {
|
||||
PT1M: 'Minute',
|
||||
PT5M: '5 minutes',
|
||||
PT30M: '30 minutes',
|
||||
PT1H: 'Hour',
|
||||
PT6H: '6 hours',
|
||||
P1D: 'Day',
|
||||
},
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
type: 'aggregates',
|
||||
default: [{ expression: SqlExpression.parse('COUNT(*)'), name: 'Count', sqlType: 'BIGINT' }],
|
||||
control: {
|
||||
label: 'Metrics to show',
|
||||
required: true,
|
||||
// transferGroup: 'show',
|
||||
},
|
||||
},
|
||||
},
|
||||
module: ({ container, host }) => {
|
||||
const myChart = echarts.init(container, 'dark');
|
||||
|
||||
myChart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985',
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: [],
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: [
|
||||
{
|
||||
type: 'time',
|
||||
boundaryGap: false,
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const resizeHandler = () => {
|
||||
myChart.resize();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
|
||||
return {
|
||||
async update({ table, where, parameterValues }) {
|
||||
const { timeGranularity, metrics } = parameterValues;
|
||||
|
||||
const dataset = (
|
||||
await host.sqlQuery(
|
||||
getInitQuery(table, where)
|
||||
.addSelect(F.timeFloor(C('__time'), L(timeGranularity)).as('time'), {
|
||||
addToGroupBy: 'end',
|
||||
addToOrderBy: 'end',
|
||||
direction: 'ASC',
|
||||
})
|
||||
.applyForEach(metrics, (q, metric) => q.addSelect(metric.expression.as(metric.name))),
|
||||
)
|
||||
).toObjectArray();
|
||||
|
||||
myChart.setOption(
|
||||
{
|
||||
dataset: {
|
||||
dimensions: ['time'].concat(metrics.map(m => m.name)),
|
||||
source: dataset,
|
||||
},
|
||||
grid: {
|
||||
right: metrics.length * 40,
|
||||
},
|
||||
yAxis: metrics.map(({ name }, i) => ({
|
||||
type: 'value',
|
||||
name: name,
|
||||
position: i === 0 ? 'left' : 'right',
|
||||
offset: i === 0 ? 0 : (i - 1) * 80,
|
||||
axisLine: {
|
||||
show: true,
|
||||
},
|
||||
})),
|
||||
series: metrics.map(({ name }, i) => ({
|
||||
name: name,
|
||||
type: 'line',
|
||||
showSymbol: false,
|
||||
yAxisIndex: i,
|
||||
encode: {
|
||||
x: 'time',
|
||||
y: name,
|
||||
itemId: name,
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
replaceMerge: ['yAxis', 'series'],
|
||||
},
|
||||
);
|
||||
},
|
||||
destroy() {
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
myChart.dispose();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { C, SqlExpression } from '@druid-toolkit/query';
|
||||
import { typedVisualModule } from '@druid-toolkit/visuals-core';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
import { getInitQuery } from '../utils';
|
||||
|
||||
export default typedVisualModule({
|
||||
parameters: {
|
||||
splitColumn: {
|
||||
type: 'column',
|
||||
control: {
|
||||
label: 'Slice column',
|
||||
// transferGroup: 'show',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
metric: {
|
||||
type: 'aggregate',
|
||||
default: { expression: SqlExpression.parse('COUNT(*)'), name: 'Count', sqlType: 'BIGINT' },
|
||||
control: {
|
||||
// transferGroup: 'show',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
default: 5,
|
||||
control: {
|
||||
label: 'Max slices to show',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
showOthers: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
control: { label: 'Show others' },
|
||||
},
|
||||
},
|
||||
module: ({ container, host, getLastUpdateEvent, updateWhere }) => {
|
||||
const myChart = echarts.init(container, 'dark');
|
||||
|
||||
myChart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: [],
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const resizeHandler = () => {
|
||||
myChart.resize();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
|
||||
myChart.on('click', 'series', p => {
|
||||
const lastUpdateEvent = getLastUpdateEvent();
|
||||
if (!lastUpdateEvent?.parameterValues.splitColumn) return;
|
||||
|
||||
updateWhere(
|
||||
lastUpdateEvent.where.toggleClauseInWhere(
|
||||
C(lastUpdateEvent.parameterValues.splitColumn.name).equal(p.name),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
async update({ table, where, parameterValues }) {
|
||||
const { splitColumn, metric, limit } = parameterValues;
|
||||
|
||||
if (!splitColumn) return;
|
||||
|
||||
const result = await host.sqlQuery(
|
||||
getInitQuery(table, where)
|
||||
.addSelect(splitColumn.expression.as('name'), { addToGroupBy: 'end' })
|
||||
.addSelect(metric.expression.as('value'), {
|
||||
addToOrderBy: 'end',
|
||||
direction: 'DESC',
|
||||
})
|
||||
.changeLimitValue(limit),
|
||||
);
|
||||
|
||||
const data = result.toObjectArray();
|
||||
|
||||
if (parameterValues.showOthers) {
|
||||
const others = await host.sqlQuery(
|
||||
getInitQuery(table, where)
|
||||
.addSelect(metric.expression.as('value'))
|
||||
.addWhere(C(splitColumn.name).notIn(result.getColumnByIndex(0)!)),
|
||||
);
|
||||
|
||||
data.push({ name: 'Others', value: others.rows[0][0] });
|
||||
}
|
||||
|
||||
myChart.setOption({
|
||||
series: [
|
||||
{
|
||||
name: metric.name,
|
||||
data,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
myChart.dispose();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
|
@ -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.
|
||||
*/
|
||||
|
||||
.table-module {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.generic-output-table {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,499 @@
|
|||
/*
|
||||
* 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 { SqlOrderByExpression } from '@druid-toolkit/query';
|
||||
import {
|
||||
C,
|
||||
F,
|
||||
SqlCase,
|
||||
SqlColumn,
|
||||
SqlExpression,
|
||||
SqlFunction,
|
||||
SqlLiteral,
|
||||
SqlQuery,
|
||||
SqlWithPart,
|
||||
T,
|
||||
} from '@druid-toolkit/query';
|
||||
import type { ExpressionMeta, Host } from '@druid-toolkit/visuals-core';
|
||||
import { typedVisualModule } from '@druid-toolkit/visuals-core';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { Loader } from '../../../components';
|
||||
import { useQueryManager } from '../../../hooks';
|
||||
import { getInitQuery } from '../utils';
|
||||
|
||||
import { GenericOutputTable } from './components';
|
||||
|
||||
import './table-react-module.scss';
|
||||
|
||||
type MultipleValueMode = 'null' | 'empty' | 'latest' | 'latestNonNull' | 'count';
|
||||
|
||||
const KNOWN_AGGREGATIONS = [
|
||||
'COUNT',
|
||||
'SUM',
|
||||
'MIN',
|
||||
'MAX',
|
||||
'AVG',
|
||||
'APPROX_COUNT_DISTINCT',
|
||||
'APPROX_COUNT_DISTINCT_DS_HLL',
|
||||
'APPROX_COUNT_DISTINCT_DS_THETA',
|
||||
'DS_HLL',
|
||||
'DS_THETA',
|
||||
'APPROX_QUANTILE',
|
||||
'APPROX_QUANTILE_DS',
|
||||
'APPROX_QUANTILE_FIXED_BUCKETS',
|
||||
'DS_QUANTILES_SKETCH',
|
||||
'BLOOM_FILTER',
|
||||
'TDIGEST_QUANTILE',
|
||||
'TDIGEST_GENERATE_SKETCH',
|
||||
'VAR_POP',
|
||||
'VAR_SAMP',
|
||||
'VARIANCE',
|
||||
'STDDEV_POP',
|
||||
'STDDEV_SAMP',
|
||||
'STDDEV',
|
||||
'EARLIEST',
|
||||
'LATEST',
|
||||
'ANY_VALUE',
|
||||
];
|
||||
|
||||
const NULL_REPLACEMENT = SqlLiteral.create('__VIS_NULL__');
|
||||
|
||||
function nullableColumn(column: ExpressionMeta) {
|
||||
return column.sqlType !== 'TIMESTAMP';
|
||||
}
|
||||
|
||||
function nvl(ex: SqlExpression): SqlExpression {
|
||||
return SqlFunction.simple('NVL', [ex, NULL_REPLACEMENT]);
|
||||
}
|
||||
|
||||
function nullif(ex: SqlExpression): SqlExpression {
|
||||
return SqlFunction.simple('NULLIF', [ex, NULL_REPLACEMENT]);
|
||||
}
|
||||
|
||||
function toGroupByExpression(
|
||||
splitColumn: ExpressionMeta,
|
||||
nvlIfNeeded: boolean,
|
||||
timeBucket: string,
|
||||
) {
|
||||
const { expression, sqlType, name } = splitColumn;
|
||||
return expression
|
||||
.applyIf(sqlType === 'TIMESTAMP', e => SqlFunction.simple('TIME_FLOOR', [e, timeBucket]))
|
||||
.applyIf(nvlIfNeeded && nullableColumn(splitColumn), nvl)
|
||||
.as(name);
|
||||
}
|
||||
|
||||
function toShowColumnExpression(
|
||||
showColumn: ExpressionMeta,
|
||||
mode: MultipleValueMode,
|
||||
): SqlExpression {
|
||||
let ex: SqlExpression = SqlFunction.simple('LATEST', [showColumn.expression, 1024]);
|
||||
|
||||
let elseEx: SqlExpression | undefined;
|
||||
switch (mode) {
|
||||
case 'null':
|
||||
elseEx = SqlLiteral.NULL;
|
||||
break;
|
||||
|
||||
case 'empty':
|
||||
elseEx = SqlLiteral.create('');
|
||||
break;
|
||||
|
||||
case 'latestNonNull':
|
||||
elseEx = SqlFunction.simple(
|
||||
'LATEST',
|
||||
[showColumn.expression, 1024],
|
||||
showColumn.expression.isNotNull(),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'count':
|
||||
elseEx = SqlFunction.simple('CONCAT', [
|
||||
'Multiple values (',
|
||||
SqlFunction.countDistinct(showColumn.expression),
|
||||
')',
|
||||
]);
|
||||
break;
|
||||
|
||||
default:
|
||||
// latest
|
||||
break;
|
||||
}
|
||||
|
||||
if (elseEx) {
|
||||
ex = SqlCase.ifThenElse(SqlFunction.countDistinct(showColumn.expression).equal(1), ex, elseEx);
|
||||
}
|
||||
|
||||
return ex.as(showColumn.name);
|
||||
}
|
||||
|
||||
function shiftTime(ex: SqlQuery, period: string): SqlQuery {
|
||||
return ex.walk(q => {
|
||||
if (q instanceof SqlColumn && q.getName() === '__time') {
|
||||
return SqlFunction.simple('TIME_SHIFT', [q, period, 1]);
|
||||
} else {
|
||||
return q;
|
||||
}
|
||||
}) as SqlQuery;
|
||||
}
|
||||
|
||||
interface QueryAndHints {
|
||||
query: SqlQuery;
|
||||
groupHints: string[];
|
||||
}
|
||||
|
||||
export default typedVisualModule({
|
||||
parameters: {
|
||||
splitColumns: {
|
||||
type: 'columns',
|
||||
control: {
|
||||
label: 'Group by',
|
||||
// transferGroup: 'show',
|
||||
},
|
||||
},
|
||||
|
||||
timeBucket: {
|
||||
type: 'option',
|
||||
options: ['PT1M', 'PT5M', 'PT1H', 'P1D', 'P1M'],
|
||||
default: 'PT1H',
|
||||
control: {
|
||||
label: 'Time bucket',
|
||||
optionLabels: {
|
||||
PT1M: '1 minute',
|
||||
PT5M: '5 minutes',
|
||||
PT1H: '1 hour',
|
||||
P1D: '1 day',
|
||||
P1M: '1 month',
|
||||
},
|
||||
visible: ({ params }) => (params.splitColumns || []).some((c: any) => c.name === '__time'),
|
||||
},
|
||||
},
|
||||
|
||||
showColumns: {
|
||||
type: 'columns',
|
||||
control: {
|
||||
label: 'Show columns',
|
||||
},
|
||||
},
|
||||
multipleValueMode: {
|
||||
type: 'option',
|
||||
options: ['null', 'latest', 'latestNonNull', 'count'],
|
||||
control: {
|
||||
label: 'For shown column with multiple values...',
|
||||
optionLabels: {
|
||||
null: 'Show null',
|
||||
latest: 'Show latest value',
|
||||
latestNonNull: 'Show latest value (non-null)',
|
||||
count: `Show '<count> values'`,
|
||||
},
|
||||
visible: ({ params }) => Boolean((params.showColumns || []).length),
|
||||
},
|
||||
},
|
||||
pivotColumn: {
|
||||
type: 'column',
|
||||
control: {
|
||||
label: 'Pivot column',
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
type: 'aggregates',
|
||||
default: [{ expression: SqlFunction.count(), name: 'Count', sqlType: 'BIGINT' }],
|
||||
control: {
|
||||
label: 'Aggregates',
|
||||
// transferGroup: 'show-agg',
|
||||
},
|
||||
},
|
||||
|
||||
compares: {
|
||||
type: 'options',
|
||||
options: ['PT1M', 'PT5M', 'PT1H', 'P1D', 'P1M'],
|
||||
control: {
|
||||
label: 'Compares',
|
||||
optionLabels: {
|
||||
PT1M: '1 minute',
|
||||
PT5M: '5 minutes',
|
||||
PT1H: '1 hour',
|
||||
P1D: '1 day',
|
||||
P1M: '1 month',
|
||||
},
|
||||
visible: ({ params }) => !params.pivotColumn,
|
||||
},
|
||||
},
|
||||
|
||||
showDelta: {
|
||||
type: 'boolean',
|
||||
control: {
|
||||
visible: ({ params }) => Boolean((params.compares || []).length),
|
||||
},
|
||||
},
|
||||
maxRows: {
|
||||
type: 'number',
|
||||
default: 200,
|
||||
min: 1,
|
||||
max: 1000000,
|
||||
control: {
|
||||
label: 'Max rows',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
module: ({ container, host, updateWhere }) => {
|
||||
return {
|
||||
update({ table, where, parameterValues }) {
|
||||
ReactDOM.render(
|
||||
<TableModule
|
||||
host={host}
|
||||
table={table}
|
||||
where={where}
|
||||
parameterValues={parameterValues}
|
||||
updateWhere={updateWhere}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
},
|
||||
destroy() {
|
||||
ReactDOM.unmountComponentAtNode(container);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
interface TableModuleProps {
|
||||
host: Host;
|
||||
table: SqlExpression;
|
||||
where: SqlExpression;
|
||||
parameterValues: Record<string, any>;
|
||||
updateWhere: (where: SqlExpression) => void;
|
||||
}
|
||||
|
||||
function TableModule(props: TableModuleProps) {
|
||||
const { host, table, where, parameterValues, updateWhere } = props;
|
||||
const { sqlQuery } = host;
|
||||
const [orderBy, setOrderBy] = useState<SqlOrderByExpression | undefined>();
|
||||
|
||||
const pivotValueQuery = useMemo(() => {
|
||||
const pivotColumn: ExpressionMeta = parameterValues.pivotColumn;
|
||||
const metrics: ExpressionMeta[] = parameterValues.metrics;
|
||||
if (!pivotColumn) return;
|
||||
|
||||
return getInitQuery(table, where)
|
||||
.addSelect(pivotColumn.expression.as('v'), { addToGroupBy: 'end' })
|
||||
.changeOrderByExpression(
|
||||
metrics.length
|
||||
? metrics[0].expression.toOrderByExpression('DESC')
|
||||
: F.count().toOrderByExpression('DESC'),
|
||||
)
|
||||
.changeLimitValue(20);
|
||||
}, [table, where, parameterValues]);
|
||||
|
||||
const [pivotValueState] = useQueryManager({
|
||||
query: pivotValueQuery,
|
||||
processQuery: async (pivotValueQuery: SqlQuery) => {
|
||||
return (await sqlQuery(pivotValueQuery)).getColumnByName('v') as string[];
|
||||
},
|
||||
});
|
||||
|
||||
const queryAndHints = useMemo(() => {
|
||||
const splitColumns: ExpressionMeta[] = parameterValues.splitColumns;
|
||||
const timeBucket: string = parameterValues.timeBucket || 'PT1H';
|
||||
const showColumns: ExpressionMeta[] = parameterValues.showColumns;
|
||||
const multipleValueMode: MultipleValueMode = parameterValues.multipleValueMode || 'null';
|
||||
const pivotColumn: ExpressionMeta = parameterValues.pivotColumn;
|
||||
const metrics: ExpressionMeta[] = parameterValues.metrics;
|
||||
const compares: string[] = parameterValues.compares || [];
|
||||
const showDelta: boolean = parameterValues.showDelta;
|
||||
const maxRows: number = parameterValues.maxRows;
|
||||
|
||||
const pivotValues = pivotColumn ? pivotValueState.data : undefined;
|
||||
if (pivotColumn && !pivotValues) return;
|
||||
|
||||
const hasCompare = Boolean(compares.length);
|
||||
|
||||
const mainQuery = getInitQuery(table, where)
|
||||
.applyForEach(splitColumns, (q, splitColumn) =>
|
||||
q.addSelect(toGroupByExpression(splitColumn, hasCompare, timeBucket), {
|
||||
addToGroupBy: 'end',
|
||||
}),
|
||||
)
|
||||
.applyForEach(showColumns, (q, showColumn) =>
|
||||
q.addSelect(toShowColumnExpression(showColumn, multipleValueMode)),
|
||||
)
|
||||
.applyForEach(pivotValues || [''], (q, pivotValue, i) =>
|
||||
q.applyForEach(metrics, (q, metric) =>
|
||||
q.addSelect(
|
||||
metric.expression
|
||||
.as(metric.name)
|
||||
.applyIf(pivotColumn, q =>
|
||||
q
|
||||
.addFilterToAggregations(
|
||||
pivotColumn.expression.equal(pivotValue),
|
||||
KNOWN_AGGREGATIONS,
|
||||
)
|
||||
.as(`${metric.name}${i > 0 ? ` [${pivotValue}]` : ''}`),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.applyIf(metrics.length > 0 || splitColumns.length > 0, q =>
|
||||
q.changeOrderByExpression(
|
||||
orderBy || C(metrics[0]?.name || splitColumns[0]?.name).toOrderByExpression('DESC'),
|
||||
),
|
||||
)
|
||||
.changeLimitValue(maxRows);
|
||||
|
||||
if (!hasCompare) {
|
||||
return {
|
||||
query: mainQuery,
|
||||
groupHints: pivotColumn
|
||||
? splitColumns
|
||||
.map(() => '')
|
||||
.concat(
|
||||
showColumns.map(() => ''),
|
||||
(pivotValues || []).flatMap(v => metrics.map(() => v)),
|
||||
)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
const main = T('main');
|
||||
return {
|
||||
query: SqlQuery.from(main)
|
||||
.changeWithParts(
|
||||
[SqlWithPart.simple('main', mainQuery)].concat(
|
||||
compares.map((comparePeriod, i) =>
|
||||
SqlWithPart.simple(
|
||||
`compare${i}`,
|
||||
getInitQuery(table, where)
|
||||
.applyForEach(splitColumns, (q, splitColumn) =>
|
||||
q.addSelect(toGroupByExpression(splitColumn, true, timeBucket), {
|
||||
addToGroupBy: 'end',
|
||||
}),
|
||||
)
|
||||
.applyForEach(metrics, (q, metric) =>
|
||||
q.addSelect(metric.expression.as(metric.name)),
|
||||
)
|
||||
.apply(q => shiftTime(q, comparePeriod)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.changeSelectExpressions(
|
||||
splitColumns
|
||||
.map(splitColumn =>
|
||||
main
|
||||
.column(splitColumn.name)
|
||||
.applyIf(nullableColumn(splitColumn), nullif)
|
||||
.as(splitColumn.name),
|
||||
)
|
||||
.concat(
|
||||
showColumns.map(showColumn => main.column(showColumn.name).as(showColumn.name)),
|
||||
metrics.map(metric => main.column(metric.name).as(metric.name)),
|
||||
compares.flatMap((_, i) =>
|
||||
metrics.flatMap(metric => {
|
||||
const c = T(`compare${i}`).column(metric.name);
|
||||
|
||||
const ret = [SqlFunction.simple('COALESCE', [c, 0]).as(`#prev: ${metric.name}`)];
|
||||
|
||||
if (showDelta) {
|
||||
ret.push(
|
||||
F.stringFormat(
|
||||
'%.1f%%',
|
||||
SqlFunction.simple('SAFE_DIVIDE', [
|
||||
SqlExpression.parse(`(${main.column(metric.name)} - ${c}) * 100.0`),
|
||||
c,
|
||||
]),
|
||||
).as(`%chg: ${metric.name}`),
|
||||
);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.applyForEach(compares, (q, _comparePeriod, i) =>
|
||||
q.addLeftJoin(
|
||||
T(`compare${i}`),
|
||||
SqlExpression.and(
|
||||
...splitColumns.map(splitColumn =>
|
||||
main.column(splitColumn.name).equal(T(`compare${i}`).column(splitColumn.name)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
groupHints: splitColumns
|
||||
.map(() => 'Current')
|
||||
.concat(
|
||||
showColumns.map(() => 'Current'),
|
||||
metrics.map(() => 'Current'),
|
||||
compares.flatMap(comparePeriod =>
|
||||
metrics
|
||||
.flatMap(() => (showDelta ? ['', ''] : ['']))
|
||||
.map(() => `Comparison to ${comparePeriod}`),
|
||||
),
|
||||
),
|
||||
};
|
||||
}, [table, where, parameterValues, orderBy, pivotValueState.data]);
|
||||
|
||||
const [resultState] = useQueryManager({
|
||||
query: queryAndHints,
|
||||
processQuery: async (queryAndHints: QueryAndHints) => {
|
||||
const { query, groupHints } = queryAndHints;
|
||||
return {
|
||||
result: await sqlQuery(query),
|
||||
groupHints,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const resultData = resultState.getSomeData();
|
||||
return (
|
||||
<div className="table-module">
|
||||
{resultState.error ? (
|
||||
resultState.getErrorMessage()
|
||||
) : resultData ? (
|
||||
<GenericOutputTable
|
||||
runeMode={false}
|
||||
queryResult={resultData.result}
|
||||
groupHints={resultData.groupHints}
|
||||
showTypeIcons={false}
|
||||
onOrderByChange={(headerIndex, desc) => {
|
||||
const idx = SqlLiteral.index(headerIndex);
|
||||
if (orderBy && String(orderBy.expression) === String(idx)) {
|
||||
setOrderBy(orderBy.reverseDirection());
|
||||
} else {
|
||||
setOrderBy(idx.toOrderByExpression(desc ? 'DESC' : 'ASC'));
|
||||
}
|
||||
}}
|
||||
onQueryAction={action => {
|
||||
const query = getInitQuery(table, where);
|
||||
if (!query) return;
|
||||
const nextQuery = action(query);
|
||||
const prevWhere = query.getWhereExpression() || SqlLiteral.TRUE;
|
||||
const nextWhere = nextQuery.getWhereExpression() || SqlLiteral.TRUE;
|
||||
if (prevWhere && nextWhere && !prevWhere.equals(nextWhere)) {
|
||||
updateWhere(nextWhere);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : undefined}
|
||||
{resultState.loading && <Loader />}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,213 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { C, F, L, SqlExpression } from '@druid-toolkit/query';
|
||||
import { typedVisualModule } from '@druid-toolkit/visuals-core';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
import { getInitQuery } from '../utils';
|
||||
|
||||
function transformData(data: any[]): any[] {
|
||||
let lastTime = -1;
|
||||
let lastDatum: any;
|
||||
const ret = [];
|
||||
for (const d of data) {
|
||||
if (d.time.valueOf() === lastTime) {
|
||||
lastDatum[d.stack] = d.met;
|
||||
} else {
|
||||
if (lastDatum) ret.push(lastDatum);
|
||||
lastTime = d.time.valueOf();
|
||||
lastDatum = { time: d.time, [d.stack]: d.met };
|
||||
}
|
||||
}
|
||||
if (lastDatum) ret.push(lastDatum);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export default typedVisualModule({
|
||||
parameters: {
|
||||
timeGranularity: {
|
||||
type: 'option',
|
||||
options: ['PT1M', 'PT5M', 'PT30M', 'PT1H', 'P1D'],
|
||||
default: 'PT1H',
|
||||
control: {
|
||||
optionLabels: {
|
||||
PT1M: 'Minute',
|
||||
PT5M: '5 minutes',
|
||||
PT30M: '30 minutes',
|
||||
PT1H: 'Hour',
|
||||
PT6H: '6 hours',
|
||||
P1D: 'Day',
|
||||
},
|
||||
},
|
||||
},
|
||||
splitColumn: {
|
||||
type: 'column',
|
||||
control: {
|
||||
label: 'Stack by',
|
||||
// transferGroup: 'show',
|
||||
},
|
||||
},
|
||||
numberToStack: {
|
||||
type: 'number',
|
||||
default: 7,
|
||||
min: 2,
|
||||
control: {
|
||||
label: 'Max stacks',
|
||||
required: true,
|
||||
visible: ({ params }) => Boolean(params.splitColumn),
|
||||
},
|
||||
},
|
||||
metric: {
|
||||
type: 'aggregate',
|
||||
default: { expression: SqlExpression.parse('COUNT(*)'), name: 'Count', sqlType: 'BIGINT' },
|
||||
control: {
|
||||
label: 'Metric to show',
|
||||
required: true,
|
||||
// transferGroup: 'show-agg',
|
||||
},
|
||||
},
|
||||
},
|
||||
module: ({ container, host }) => {
|
||||
const myChart = echarts.init(container, 'dark');
|
||||
|
||||
myChart.setOption({
|
||||
dataset: {
|
||||
dimensions: [],
|
||||
source: [],
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985',
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: [],
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: [
|
||||
{
|
||||
type: 'time',
|
||||
boundaryGap: false,
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
},
|
||||
],
|
||||
series: [],
|
||||
});
|
||||
|
||||
const resizeHandler = () => {
|
||||
myChart.resize();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
|
||||
return {
|
||||
async update({ table, where, parameterValues }) {
|
||||
const { splitColumn, metric, numberToStack, timeGranularity } = parameterValues;
|
||||
|
||||
const vs = splitColumn
|
||||
? (
|
||||
await host.sqlQuery(
|
||||
getInitQuery(table, where)
|
||||
.addSelect(splitColumn.expression.as('v'), { addToGroupBy: 'end' })
|
||||
.changeOrderByExpression(metric.expression.toOrderByExpression('DESC'))
|
||||
.changeLimitValue(numberToStack),
|
||||
)
|
||||
).getColumnByIndex(0)!
|
||||
: undefined;
|
||||
|
||||
const dataset = (
|
||||
await host.sqlQuery(
|
||||
getInitQuery(
|
||||
table,
|
||||
splitColumn && vs ? where.and(splitColumn.expression.in(vs)) : where,
|
||||
)
|
||||
.addSelect(F.timeFloor(C('__time'), L(timeGranularity)).as('time'), {
|
||||
addToGroupBy: 'end',
|
||||
addToOrderBy: 'end',
|
||||
direction: 'ASC',
|
||||
})
|
||||
.applyIf(splitColumn, q =>
|
||||
q.addSelect(splitColumn!.expression.as('stack'), { addToGroupBy: 'end' }),
|
||||
)
|
||||
.addSelect(metric.expression.as('met')),
|
||||
)
|
||||
).toObjectArray();
|
||||
|
||||
const sourceData = vs ? transformData(dataset) : dataset;
|
||||
const showSymbol = sourceData.length < 2;
|
||||
myChart.setOption(
|
||||
{
|
||||
dataset: {
|
||||
dimensions: ['time'].concat(vs || ['met']),
|
||||
source: sourceData,
|
||||
},
|
||||
legend: vs
|
||||
? {
|
||||
data: vs,
|
||||
}
|
||||
: undefined,
|
||||
series: (vs || ['met']).map(v => {
|
||||
return {
|
||||
id: v,
|
||||
name: v,
|
||||
type: 'line',
|
||||
stack: 'Total',
|
||||
showSymbol,
|
||||
areaStyle: {},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
encode: {
|
||||
x: 'time',
|
||||
y: v,
|
||||
itemId: v,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
{
|
||||
replaceMerge: ['legend', 'series'],
|
||||
},
|
||||
);
|
||||
},
|
||||
destroy() {
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
myChart.dispose();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.resource-pane {
|
||||
position: relative;
|
||||
|
||||
.search-input {
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.resource-items {
|
||||
position: absolute;
|
||||
top: 38px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow: auto;
|
||||
|
||||
.resource-item {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* 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 { Icon, InputGroup, Menu, MenuItem } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import { Popover2 } from '@blueprintjs/popover2';
|
||||
import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { caseInsensitiveContains, dataTypeToIcon, filterMap } from '../../../utils';
|
||||
import { DragHelper } from '../drag-helper';
|
||||
import type { Dataset } from '../utils';
|
||||
|
||||
import './resource-pane.scss';
|
||||
|
||||
export interface ResourcePaneProps {
|
||||
dataset: Dataset;
|
||||
onFilter?: (column: ExpressionMeta) => void;
|
||||
onShow?: (column: ExpressionMeta) => void;
|
||||
}
|
||||
|
||||
export const ResourcePane = function ResourcePane(props: ResourcePaneProps) {
|
||||
const { dataset, onFilter, onShow } = props;
|
||||
const [columnSearch, setColumnSearch] = useState('');
|
||||
|
||||
const { columns } = dataset;
|
||||
|
||||
return (
|
||||
<div className="resource-pane">
|
||||
<InputGroup
|
||||
className="search-input"
|
||||
value={columnSearch}
|
||||
onChange={e => setColumnSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
<div className="resource-items">
|
||||
{filterMap(columns, (c, i) => {
|
||||
if (!caseInsensitiveContains(c.name, columnSearch)) return;
|
||||
return (
|
||||
<Popover2
|
||||
className="resource-item"
|
||||
key={i}
|
||||
position="right"
|
||||
content={
|
||||
<Menu>
|
||||
{onFilter && (
|
||||
<MenuItem icon={IconNames.FILTER} text="Filter" onClick={() => onFilter(c)} />
|
||||
)}
|
||||
{onShow && (
|
||||
<MenuItem icon={IconNames.EYE_OPEN} text="Show" onClick={() => onShow(c)} />
|
||||
)}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="bp4-menu-item"
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'all';
|
||||
DragHelper.dragColumn = c;
|
||||
DragHelper.createDragGhost(e.dataTransfer, c.name);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
className="bp4-menu-item-icon"
|
||||
icon={c.sqlType ? dataTypeToIcon(c.sqlType) : IconNames.BLANK}
|
||||
/>
|
||||
<div className="bp4-fill bp4-text-overflow-ellipsis">{c.name}</div>
|
||||
</div>
|
||||
</Popover2>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.source-pane {
|
||||
.bp4-button-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.source-menu {
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
|
@ -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 { Button, Menu, MenuDivider, MenuItem, Position } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import { Popover2 } from '@blueprintjs/popover2';
|
||||
import React from 'react';
|
||||
|
||||
import { useQueryManager } from '../../../hooks';
|
||||
import { queryDruidSql } from '../../../utils';
|
||||
|
||||
import './source-pane.scss';
|
||||
|
||||
export interface SourcePaneProps {
|
||||
selectedTableName: string;
|
||||
onSelectedTableNameChange(newSelectedSource: string): void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SourcePane = React.memo(function SourcePane(props: SourcePaneProps) {
|
||||
const { selectedTableName, onSelectedTableNameChange, disabled } = props;
|
||||
|
||||
const [sources] = useQueryManager<string, string[]>({
|
||||
initQuery: '',
|
||||
processQuery: async () => {
|
||||
const tables = await queryDruidSql<{ TABLE_NAME: string }>({
|
||||
query: `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'TABLE'`,
|
||||
});
|
||||
|
||||
return tables.map(d => d.TABLE_NAME);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover2
|
||||
className="source-pane"
|
||||
disabled={disabled}
|
||||
minimal
|
||||
position={Position.BOTTOM_LEFT}
|
||||
content={
|
||||
<Menu className="source-menu">
|
||||
{sources.loading && <MenuDivider title="Loading..." />}
|
||||
{sources.data?.map((s, i) => (
|
||||
<MenuItem key={i} text={s} onClick={() => onSelectedTableNameChange(s)} />
|
||||
))}
|
||||
{!sources.data?.length && <MenuItem text="No sources" disabled />}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
text={`Source: ${selectedTableName}`}
|
||||
rightIcon={IconNames.CARET_DOWN}
|
||||
fill
|
||||
minimal
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Popover2>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.tile-picker {
|
||||
.picker-button .bp4-button-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.more-button.bp4-popover2-target {
|
||||
flex: 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* 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, Menu, MenuItem, Position } from '@blueprintjs/core';
|
||||
import type { IconName } from '@blueprintjs/icons';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import { Popover2 } from '@blueprintjs/popover2';
|
||||
import type { JSX } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import './tile-picker.scss';
|
||||
|
||||
export interface TilePickerProps<Name extends string> {
|
||||
modules: readonly { moduleName: Name; icon: IconName; label: string }[];
|
||||
selectedTileName: Name | undefined;
|
||||
onSelectedTileNameChange(newSelectedTileName: Name): void;
|
||||
moreMenu?: JSX.Element;
|
||||
}
|
||||
|
||||
declare function TilePickerComponent<Name extends string>(
|
||||
props: TilePickerProps<Name>,
|
||||
): JSX.Element;
|
||||
|
||||
export const TilePicker = React.memo(function TilePicker(props: TilePickerProps<string>) {
|
||||
const { modules, selectedTileName, onSelectedTileNameChange, moreMenu } = props;
|
||||
|
||||
const selectedTileManifest = modules.find(module => module.moduleName === selectedTileName);
|
||||
return (
|
||||
<ButtonGroup className="tile-picker" fill>
|
||||
<Popover2
|
||||
className="picker-button"
|
||||
minimal
|
||||
fill
|
||||
position={Position.BOTTOM_RIGHT}
|
||||
content={
|
||||
<Menu>
|
||||
{modules.map((module, i) => (
|
||||
<MenuItem
|
||||
key={i}
|
||||
icon={module.icon}
|
||||
text={module.label}
|
||||
onClick={() => onSelectedTileNameChange(module.moduleName)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={selectedTileManifest ? selectedTileManifest.icon : IconNames.BOX}
|
||||
text={selectedTileManifest ? selectedTileManifest.label : 'Please select something'}
|
||||
fill
|
||||
minimal
|
||||
rightIcon={IconNames.CARET_DOWN}
|
||||
/>
|
||||
</Popover2>
|
||||
{moreMenu && (
|
||||
<Popover2 className="more-button" position={Position.BOTTOM_RIGHT} content={moreMenu}>
|
||||
<Button minimal icon={IconNames.MORE} />
|
||||
</Popover2>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
);
|
||||
}) as typeof TilePickerComponent;
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* 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 { SqlExpression, SqlTable } from '@druid-toolkit/query';
|
||||
import { SqlQuery } from '@druid-toolkit/query';
|
||||
import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
|
||||
import type { ParameterDefinition } from '@druid-toolkit/visuals-core/src/models/parameter';
|
||||
|
||||
import { nonEmptyArray } from '../../utils';
|
||||
|
||||
export interface Dataset {
|
||||
table: SqlTable;
|
||||
columns: ExpressionMeta[];
|
||||
}
|
||||
|
||||
export function toggle<T>(xs: readonly T[], x: T, eq?: (a: T, b: T) => boolean): T[] {
|
||||
const e = eq || ((a, b) => a === b);
|
||||
return xs.find(_ => e(_, x)) ? xs.filter(d => !e(d, x)) : xs.concat([x]);
|
||||
}
|
||||
|
||||
export function getInitQuery(table: SqlExpression, where: SqlExpression): SqlQuery {
|
||||
return SqlQuery.from(table).applyIf(String(where) !== 'TRUE', q =>
|
||||
q.changeWhereExpression(where),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeType(paramType: ParameterDefinition['type']): ParameterDefinition['type'] {
|
||||
switch (paramType) {
|
||||
case 'aggregates':
|
||||
return 'aggregate';
|
||||
case 'columns':
|
||||
return 'column';
|
||||
case 'splitCombines':
|
||||
return 'splitCombine';
|
||||
default:
|
||||
return paramType;
|
||||
}
|
||||
}
|
||||
|
||||
export function adjustTransferValue(
|
||||
value: unknown,
|
||||
sourceType: ParameterDefinition['type'],
|
||||
targetType: ParameterDefinition['type'],
|
||||
) {
|
||||
const comboType: `${ParameterDefinition['type']}->${ParameterDefinition['type']}` = `${sourceType}->${targetType}`;
|
||||
switch (comboType) {
|
||||
case 'aggregate->aggregates':
|
||||
case 'column->columns':
|
||||
case 'splitCombine->splitCombines':
|
||||
return [value];
|
||||
|
||||
case 'aggregates->aggregate':
|
||||
case 'columns->column':
|
||||
case 'splitCombines->splitCombine':
|
||||
return nonEmptyArray(value) ? value[0] : undefined;
|
||||
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@
|
|||
*/
|
||||
|
||||
export * from './datasources-view/datasources-view';
|
||||
export * from './explore-view/explore-view';
|
||||
export * from './home-view/home-view';
|
||||
export * from './load-data-view/load-data-view';
|
||||
export * from './lookups-view/lookups-view';
|
||||
|
|
Loading…
Reference in New Issue