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:
Vadim Ogievetsky 2023-07-20 22:49:23 -07:00 committed by GitHub
parent 295653648b
commit f5784e66d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 5711 additions and 2 deletions

View File

@ -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

View File

@ -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.

29
licenses/bin/zrender.BSD3 Normal file
View File

@ -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.

View File

@ -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",

View File

@ -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",

View File

@ -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) {

View File

@ -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}

View File

@ -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>
);

View File

@ -36,6 +36,10 @@
padding: 10px;
}
&.thinner {
padding: 5px;
}
.app-view {
position: relative;
}

View File

@ -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>

View File

@ -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];

View File

@ -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;
}
}

View File

@ -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>
);
};

View File

@ -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>
);
});

View File

@ -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;
}
}

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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;
}
}
}

View File

@ -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>
);
};

View File

@ -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;
}
}

View File

@ -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>
);
};

View File

@ -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 [];
}
}

View File

@ -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>
);
};

View File

@ -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);
}
}

View File

@ -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: '';
}
}
}

View File

@ -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>
);
});

View File

@ -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;
}
}

View File

@ -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>
);
});

View File

@ -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;
}
}

View File

@ -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>;
};

View File

@ -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;
}
}
}

View File

@ -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>
);
});

View File

@ -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>
);
});

View File

@ -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;
}
}

View File

@ -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>
);
});

View File

@ -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;
}
}
}

View File

@ -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>
);
});

View File

@ -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>
);
});

View File

@ -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>
);
});

View File

@ -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;
}
}

View File

@ -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>
);
});

View File

@ -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;
}
}

View File

@ -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>
);
});

View File

@ -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);
}
}

View File

@ -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();
},
};
},
});

View File

@ -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);
}
}

View File

@ -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>
);
});

View File

@ -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';

View File

@ -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';

View File

@ -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();
},
};
},
});

View File

@ -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();
},
};
},
});

View File

@ -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%;
}
}

View File

@ -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>
);
}

View File

@ -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();
},
};
},
});

View File

@ -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;
}
}
}

View File

@ -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>
);
};

View File

@ -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;
}

View File

@ -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>
);
});

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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';