mirror of https://github.com/apache/druid.git
Web-Console: Add show and copy actions to column tree (#8251)
* save * add copy and show popup to column tree menu * fixes * nest css, uset functions * fix copy state * update package * update package-lock
This commit is contained in:
parent
7702005f8f
commit
e2d1d00fb8
File diff suppressed because it is too large
Load Diff
|
@ -61,7 +61,7 @@
|
|||
"d3": "^5.9.7",
|
||||
"d3-array": "^2.2.0",
|
||||
"druid-console": "^0.0.2",
|
||||
"druid-query-toolkit": "^0.3.8",
|
||||
"druid-query-toolkit": "^0.3.12",
|
||||
"file-saver": "^2.0.2",
|
||||
"has-own-prop": "^2.0.0",
|
||||
"hjson": "^3.1.2",
|
||||
|
|
|
@ -16,14 +16,16 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Button, HTMLSelect, InputGroup } from '@blueprintjs/core';
|
||||
import { Button, HTMLSelect, InputGroup, Intent } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import FileSaver from 'file-saver';
|
||||
import hasOwnProp from 'has-own-prop';
|
||||
import numeral from 'numeral';
|
||||
import React from 'react';
|
||||
import { Filter, FilterRender } from 'react-table';
|
||||
|
||||
import { AppToaster } from '../singletons/toaster';
|
||||
export function wait(ms: number): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(resolve, ms);
|
||||
|
@ -328,3 +330,11 @@ export function downloadFile(text: string, type: string, filename: string): void
|
|||
export function escapeSqlIdentifier(identifier: string): string {
|
||||
return `"${identifier.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export function copyAndAlert(copyString: string, alertMessage: string): void {
|
||||
copy(copyString, { format: 'text/plain' });
|
||||
AppToaster.show({
|
||||
message: alertMessage,
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -42,7 +42,11 @@ exports[`sql view matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
<QueryOutput
|
||||
disabled={true}
|
||||
loading={false}
|
||||
sqlExcludeColumn={[Function]}
|
||||
sqlFilterRow={[Function]}
|
||||
sqlOrderBy={[Function]}
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
|
|
|
@ -95,7 +95,19 @@ exports[`column tree matches snapshot 1`] = `
|
|||
<span
|
||||
class="bp3-tree-node-label"
|
||||
>
|
||||
deletion-tutorial
|
||||
<span
|
||||
class="bp3-popover-wrapper"
|
||||
>
|
||||
<span
|
||||
class="bp3-popover-target"
|
||||
>
|
||||
<div
|
||||
class=""
|
||||
>
|
||||
deletion-tutorial
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
|
|
@ -40,4 +40,8 @@
|
|||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bp3-popover-target {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,57 +16,278 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { HTMLSelect, IconName, ITreeNode, Tree } from '@blueprintjs/core';
|
||||
import { HTMLSelect, IconName, ITreeNode, Menu, MenuItem, Position, Tree } from '@blueprintjs/core';
|
||||
import { Popover } from '@blueprintjs/core/lib/cjs';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import React, { ChangeEvent } from 'react';
|
||||
|
||||
import { Loader } from '../../../components';
|
||||
import { escapeSqlIdentifier, groupBy } from '../../../utils';
|
||||
import { copyAndAlert, escapeSqlIdentifier, groupBy } from '../../../utils';
|
||||
import { ColumnMetadata } from '../../../utils/column-metadata';
|
||||
|
||||
import './column-tree.scss';
|
||||
|
||||
function handleTableClick(
|
||||
tableSchema: string,
|
||||
nodeData: ITreeNode,
|
||||
onQueryStringChange: (queryString: string) => void,
|
||||
): void {
|
||||
let columns: string[];
|
||||
if (nodeData.childNodes) {
|
||||
columns = nodeData.childNodes.map(child => escapeSqlIdentifier(String(child.label)));
|
||||
} else {
|
||||
columns = ['*'];
|
||||
}
|
||||
if (tableSchema === 'druid') {
|
||||
onQueryStringChange(`SELECT ${columns.join(', ')}
|
||||
FROM ${escapeSqlIdentifier(String(nodeData.label))}
|
||||
WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY`);
|
||||
} else {
|
||||
onQueryStringChange(`SELECT ${columns.join(', ')}
|
||||
FROM ${tableSchema}.${nodeData.label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getTableQuery(tableSchema: string, nodeData: ITreeNode): string {
|
||||
let columns: string[];
|
||||
if (nodeData.childNodes) {
|
||||
columns = nodeData.childNodes.map(child => escapeSqlIdentifier(String(child.label)));
|
||||
} else {
|
||||
columns = ['*'];
|
||||
}
|
||||
if (tableSchema === 'druid') {
|
||||
return `SELECT ${columns.join(', ')}
|
||||
FROM ${escapeSqlIdentifier(String(nodeData.label))}
|
||||
WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY`;
|
||||
} else {
|
||||
return `SELECT ${columns.join(', ')}
|
||||
FROM ${tableSchema}.${nodeData.label}`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleColumnClick(
|
||||
columnSchema: string,
|
||||
columnTable: string,
|
||||
nodeData: ITreeNode,
|
||||
onQueryStringChange: (queryString: string) => void,
|
||||
): void {
|
||||
if (columnSchema === 'druid') {
|
||||
if (nodeData.icon === IconNames.TIME) {
|
||||
onQueryStringChange(`SELECT
|
||||
TIME_FLOOR(${escapeSqlIdentifier(String(nodeData.label))}, 'PT1H') AS "Time",
|
||||
COUNT(*) AS "Count"
|
||||
FROM ${escapeSqlIdentifier(columnTable)}
|
||||
WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY
|
||||
GROUP BY 1
|
||||
ORDER BY "Time" ASC`);
|
||||
} else {
|
||||
onQueryStringChange(`SELECT
|
||||
"${nodeData.label}",
|
||||
COUNT(*) AS "Count"
|
||||
FROM ${escapeSqlIdentifier(columnTable)}
|
||||
WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY
|
||||
GROUP BY 1
|
||||
ORDER BY "Count" DESC`);
|
||||
}
|
||||
} else {
|
||||
onQueryStringChange(`SELECT
|
||||
${escapeSqlIdentifier(String(nodeData.label))},
|
||||
COUNT(*) AS "Count"
|
||||
FROM ${columnSchema}.${columnTable}
|
||||
GROUP BY 1
|
||||
ORDER BY "Count" DESC`);
|
||||
}
|
||||
}
|
||||
|
||||
function getColumnQuery(columnSchema: string, columnTable: string, nodeData: ITreeNode): string {
|
||||
if (columnSchema === 'druid') {
|
||||
if (nodeData.icon === IconNames.TIME) {
|
||||
return `SELECT
|
||||
TIME_FLOOR(${escapeSqlIdentifier(String(nodeData.label))}, 'PT1H') AS "Time",
|
||||
COUNT(*) AS "Count"
|
||||
FROM ${escapeSqlIdentifier(columnTable)}
|
||||
WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY
|
||||
GROUP BY 1
|
||||
ORDER BY "Time" ASC`;
|
||||
} else {
|
||||
return `SELECT
|
||||
"${nodeData.label}",
|
||||
COUNT(*) AS "Count"
|
||||
FROM ${escapeSqlIdentifier(columnTable)}
|
||||
WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY
|
||||
GROUP BY 1
|
||||
ORDER BY "Count" DESC`;
|
||||
}
|
||||
} else {
|
||||
return `SELECT
|
||||
${escapeSqlIdentifier(String(nodeData.label))},
|
||||
COUNT(*) AS "Count"
|
||||
FROM ${columnSchema}.${columnTable}
|
||||
GROUP BY 1
|
||||
ORDER BY "Count" DESC`;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ColumnTreeProps {
|
||||
columnMetadataLoading: boolean;
|
||||
columnMetadata?: ColumnMetadata[];
|
||||
onQueryStringChange: (queryString: string) => void;
|
||||
defaultSchema?: string;
|
||||
defaultTable?: string;
|
||||
}
|
||||
|
||||
export interface ColumnTreeState {
|
||||
prevColumnMetadata?: ColumnMetadata[];
|
||||
columnTree?: ITreeNode[];
|
||||
selectedTreeIndex: number;
|
||||
expandedNode: number;
|
||||
}
|
||||
|
||||
export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeState> {
|
||||
static getDerivedStateFromProps(props: ColumnTreeProps, state: ColumnTreeState) {
|
||||
const { columnMetadata } = props;
|
||||
const { columnMetadata, defaultSchema, defaultTable } = props;
|
||||
|
||||
if (columnMetadata && columnMetadata !== state.prevColumnMetadata) {
|
||||
const columnTree = groupBy(
|
||||
columnMetadata,
|
||||
r => r.TABLE_SCHEMA,
|
||||
(metadata, schema): ITreeNode => ({
|
||||
id: schema,
|
||||
label: schema,
|
||||
childNodes: groupBy(
|
||||
metadata,
|
||||
r => r.TABLE_NAME,
|
||||
(metadata, table) => ({
|
||||
id: table,
|
||||
icon: IconNames.TH,
|
||||
label: (
|
||||
<Popover
|
||||
boundary={'window'}
|
||||
position={Position.RIGHT}
|
||||
content={
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon={IconNames.FULLSCREEN}
|
||||
text={`Show: ${table}`}
|
||||
onClick={() => {
|
||||
handleTableClick(
|
||||
schema,
|
||||
{
|
||||
id: table,
|
||||
icon: IconNames.TH,
|
||||
label: table,
|
||||
childNodes: metadata.map(columnData => ({
|
||||
id: columnData.COLUMN_NAME,
|
||||
icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
|
||||
label: columnData.COLUMN_NAME,
|
||||
})),
|
||||
},
|
||||
props.onQueryStringChange,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={IconNames.CLIPBOARD}
|
||||
text={`Copy: ${table}`}
|
||||
onClick={() => {
|
||||
copyAndAlert(
|
||||
getTableQuery(schema, {
|
||||
id: table,
|
||||
icon: IconNames.TH,
|
||||
label: table,
|
||||
childNodes: metadata.map(columnData => ({
|
||||
id: columnData.COLUMN_NAME,
|
||||
icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
|
||||
label: columnData.COLUMN_NAME,
|
||||
})),
|
||||
}),
|
||||
`${table} query copied to clipboard`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<div>{table}</div>
|
||||
</Popover>
|
||||
),
|
||||
childNodes: metadata.map(columnData => ({
|
||||
id: columnData.COLUMN_NAME,
|
||||
icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
|
||||
label: (
|
||||
<Popover
|
||||
boundary={'window'}
|
||||
position={Position.RIGHT}
|
||||
content={
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon={IconNames.FULLSCREEN}
|
||||
text={`Show: ${columnData.COLUMN_NAME}`}
|
||||
onClick={() => {
|
||||
handleColumnClick(
|
||||
schema,
|
||||
table,
|
||||
{
|
||||
id: columnData.COLUMN_NAME,
|
||||
icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
|
||||
label: columnData.COLUMN_NAME,
|
||||
},
|
||||
props.onQueryStringChange,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={IconNames.CLIPBOARD}
|
||||
text={`Copy: ${columnData.COLUMN_NAME}`}
|
||||
onClick={() => {
|
||||
copyAndAlert(
|
||||
getColumnQuery(schema, table, {
|
||||
id: columnData.COLUMN_NAME,
|
||||
icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
|
||||
label: columnData.COLUMN_NAME,
|
||||
}),
|
||||
`${columnData.COLUMN_NAME} query copied to clipboard`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<div>{columnData.COLUMN_NAME}</div>
|
||||
</Popover>
|
||||
),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
let selectedTreeIndex = -1;
|
||||
let expandedNode = -1;
|
||||
if (defaultSchema && columnTree) {
|
||||
selectedTreeIndex = columnTree
|
||||
.map(function(x) {
|
||||
return x.id;
|
||||
})
|
||||
.indexOf(defaultSchema);
|
||||
}
|
||||
if (selectedTreeIndex > -1) {
|
||||
const treeNodes = columnTree[selectedTreeIndex].childNodes;
|
||||
if (treeNodes) {
|
||||
if (defaultTable) {
|
||||
expandedNode = treeNodes
|
||||
.map(node => {
|
||||
return node.id;
|
||||
})
|
||||
.indexOf(defaultTable);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
prevColumnMetadata: columnMetadata,
|
||||
columnTree: groupBy(
|
||||
columnMetadata,
|
||||
r => r.TABLE_SCHEMA,
|
||||
(metadata, schema): ITreeNode => ({
|
||||
id: schema,
|
||||
label: schema,
|
||||
childNodes: groupBy(
|
||||
metadata,
|
||||
r => r.TABLE_NAME,
|
||||
(metadata, table) => ({
|
||||
id: table,
|
||||
icon: IconNames.TH,
|
||||
label: table,
|
||||
childNodes: metadata.map(columnData => ({
|
||||
id: columnData.COLUMN_NAME,
|
||||
icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
|
||||
label: columnData.COLUMN_NAME,
|
||||
})),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
columnTree,
|
||||
selectedTreeIndex,
|
||||
expandedNode,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
@ -88,7 +309,8 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
|
|||
constructor(props: ColumnTreeProps, context: any) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
selectedTreeIndex: 0,
|
||||
selectedTreeIndex: -1,
|
||||
expandedNode: -1,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -99,7 +321,7 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
|
|||
return (
|
||||
<HTMLSelect
|
||||
className="schema-selector"
|
||||
value={selectedTreeIndex}
|
||||
value={selectedTreeIndex > -1 ? selectedTreeIndex : undefined}
|
||||
onChange={this.handleSchemaSelectorChange}
|
||||
fill
|
||||
minimal
|
||||
|
@ -115,7 +337,7 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
|
|||
}
|
||||
|
||||
private handleSchemaSelectorChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
this.setState({ selectedTreeIndex: Number(e.target.value) });
|
||||
this.setState({ selectedTreeIndex: Number(e.target.value), expandedNode: -1 });
|
||||
};
|
||||
|
||||
render(): JSX.Element | null {
|
||||
|
@ -128,18 +350,22 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
|
|||
);
|
||||
}
|
||||
|
||||
const { columnTree, selectedTreeIndex } = this.state;
|
||||
const { columnTree, selectedTreeIndex, expandedNode } = this.state;
|
||||
if (!columnTree) return null;
|
||||
const currentSchemaSubtree = columnTree[selectedTreeIndex].childNodes;
|
||||
const currentSchemaSubtree =
|
||||
columnTree[selectedTreeIndex > -1 ? selectedTreeIndex : 0].childNodes;
|
||||
if (!currentSchemaSubtree) return null;
|
||||
|
||||
if (expandedNode > -1) {
|
||||
currentSchemaSubtree[expandedNode].isExpanded = true;
|
||||
}
|
||||
return (
|
||||
<div className="column-tree">
|
||||
{this.renderSchemaSelector()}
|
||||
<div className="tree-container">
|
||||
<Tree
|
||||
contents={currentSchemaSubtree}
|
||||
onNodeClick={this.handleNodeClick}
|
||||
onNodeClick={() => this.setState({ expandedNode: -1 })}
|
||||
onNodeCollapse={this.handleNodeCollapse}
|
||||
onNodeExpand={this.handleNodeExpand}
|
||||
/>
|
||||
|
@ -148,68 +374,8 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
|
|||
);
|
||||
}
|
||||
|
||||
private handleNodeClick = (nodeData: ITreeNode, nodePath: number[]) => {
|
||||
const { onQueryStringChange } = this.props;
|
||||
const { columnTree, selectedTreeIndex } = this.state;
|
||||
if (!columnTree) return;
|
||||
|
||||
const selectedNode = columnTree[selectedTreeIndex];
|
||||
switch (nodePath.length) {
|
||||
case 1: // Datasource
|
||||
const tableSchema = selectedNode.label;
|
||||
let columns: string[];
|
||||
if (nodeData.childNodes) {
|
||||
columns = nodeData.childNodes.map(child => escapeSqlIdentifier(String(child.label)));
|
||||
} else {
|
||||
columns = ['*'];
|
||||
}
|
||||
if (tableSchema === 'druid') {
|
||||
onQueryStringChange(`SELECT ${columns.join(', ')}
|
||||
FROM ${escapeSqlIdentifier(String(nodeData.label))}
|
||||
WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY`);
|
||||
} else {
|
||||
onQueryStringChange(`SELECT ${columns.join(', ')}
|
||||
FROM ${tableSchema}.${nodeData.label}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 2: // Column
|
||||
const schemaNode = selectedNode;
|
||||
const columnSchema = schemaNode.label;
|
||||
const columnTable = schemaNode.childNodes
|
||||
? String(schemaNode.childNodes[nodePath[0]].label)
|
||||
: '?';
|
||||
if (columnSchema === 'druid') {
|
||||
if (nodeData.icon === IconNames.TIME) {
|
||||
onQueryStringChange(`SELECT
|
||||
TIME_FLOOR(${escapeSqlIdentifier(String(nodeData.label))}, 'PT1H') AS "Time",
|
||||
COUNT(*) AS "Count"
|
||||
FROM ${escapeSqlIdentifier(columnTable)}
|
||||
WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY
|
||||
GROUP BY 1
|
||||
ORDER BY "Time" ASC`);
|
||||
} else {
|
||||
onQueryStringChange(`SELECT
|
||||
"${nodeData.label}",
|
||||
COUNT(*) AS "Count"
|
||||
FROM ${escapeSqlIdentifier(columnTable)}
|
||||
WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY
|
||||
GROUP BY 1
|
||||
ORDER BY "Count" DESC`);
|
||||
}
|
||||
} else {
|
||||
onQueryStringChange(`SELECT
|
||||
${escapeSqlIdentifier(String(nodeData.label))},
|
||||
COUNT(*) AS "Count"
|
||||
FROM ${columnSchema}.${columnTable}
|
||||
GROUP BY 1
|
||||
ORDER BY "Count" DESC`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private handleNodeCollapse = (nodeData: ITreeNode) => {
|
||||
this.setState({ expandedNode: -1 });
|
||||
nodeData.isExpanded = false;
|
||||
this.bounceState();
|
||||
};
|
||||
|
|
|
@ -23,4 +23,28 @@
|
|||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.clickable-cell {
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
.bp3-popover-target {
|
||||
width: 100%;
|
||||
}
|
||||
.aggregate-column {
|
||||
background-color: rgba(98, 205, 255, 0.1);
|
||||
}
|
||||
.rt-th {
|
||||
&.aggregate-header {
|
||||
background: rgb(75, 122, 148);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
}
|
||||
.rt-td {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,16 @@ import { QueryOutput } from './query-output';
|
|||
|
||||
describe('query output', () => {
|
||||
it('matches snapshot', () => {
|
||||
const queryOutput = <QueryOutput loading={false} error="lol" />;
|
||||
const queryOutput = (
|
||||
<QueryOutput
|
||||
sqlOrderBy={() => null}
|
||||
sqlFilterRow={() => null}
|
||||
sqlExcludeColumn={() => null}
|
||||
disabled={false}
|
||||
loading={false}
|
||||
error="lol"
|
||||
/>
|
||||
);
|
||||
|
||||
const { container } = render(queryOutput);
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
|
|
|
@ -16,16 +16,29 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Popover } from '@blueprintjs/core';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import { HeaderRows } from 'druid-query-toolkit';
|
||||
import {
|
||||
basicIdentifierEscape,
|
||||
basicLiteralEscape,
|
||||
} from 'druid-query-toolkit/build/ast/sql-query/helpers';
|
||||
import React from 'react';
|
||||
import ReactTable from 'react-table';
|
||||
|
||||
import { TableCell } from '../../../components';
|
||||
import { copyAndAlert } from '../../../utils';
|
||||
import { BasicAction, basicActionsToMenu } from '../../../utils/basic-action';
|
||||
|
||||
import './query-output.scss';
|
||||
|
||||
export interface QueryOutputProps {
|
||||
aggregateColumns?: string[];
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
sqlFilterRow: (row: string, header: string, operator: '=' | '!=') => void;
|
||||
sqlExcludeColumn: (header: string) => void;
|
||||
sqlOrderBy: (header: string, direction: 'ASC' | 'DESC') => void;
|
||||
sorted?: { id: string; desc: boolean }[];
|
||||
result?: HeaderRows;
|
||||
error?: string;
|
||||
}
|
||||
|
@ -43,13 +56,180 @@ export class QueryOutput extends React.PureComponent<QueryOutputProps> {
|
|||
sortable={false}
|
||||
columns={(result ? result.header : []).map((h: any, i) => {
|
||||
return {
|
||||
Header: h,
|
||||
Header: () => {
|
||||
return (
|
||||
<Popover className={'clickable-cell'} content={this.getHeaderActions(h)}>
|
||||
<div>{h}</div>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
headerClassName: this.getHeaderClassName(h),
|
||||
accessor: String(i),
|
||||
Cell: row => <TableCell value={row.value} />,
|
||||
Cell: row => {
|
||||
const value = row.value;
|
||||
const popover = (
|
||||
<div>
|
||||
<Popover content={this.getRowActions(value, h)}>
|
||||
<div>{value}</div>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
if (value) {
|
||||
return popover;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
className: this.props.aggregateColumns
|
||||
? this.props.aggregateColumns.indexOf(h) > -1
|
||||
? 'aggregate-column'
|
||||
: undefined
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
getHeaderActions(h: string) {
|
||||
const { disabled, sqlExcludeColumn, sqlOrderBy } = this.props;
|
||||
let actionsMenu;
|
||||
if (disabled) {
|
||||
actionsMenu = basicActionsToMenu([
|
||||
{
|
||||
icon: IconNames.CLIPBOARD,
|
||||
title: `Copy: ${h}`,
|
||||
onAction: () => {
|
||||
copyAndAlert(h, `${h}' copied to clipboard`);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: IconNames.CLIPBOARD,
|
||||
title: `Copy: ORDER BY ${basicIdentifierEscape(h)} ASC`,
|
||||
onAction: () => {
|
||||
copyAndAlert(
|
||||
`ORDER BY ${basicIdentifierEscape(h)} ASC`,
|
||||
`ORDER BY ${basicIdentifierEscape(h)} ASC' copied to clipboard`,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: IconNames.CLIPBOARD,
|
||||
title: `Copy: 'ORDER BY ${basicIdentifierEscape(h)} DESC'`,
|
||||
onAction: () => {
|
||||
copyAndAlert(
|
||||
`ORDER BY ${basicIdentifierEscape(h)} DESC`,
|
||||
`ORDER BY ${basicIdentifierEscape(h)} DESC' copied to clipboard`,
|
||||
);
|
||||
},
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const { sorted } = this.props;
|
||||
const basicActions: BasicAction[] = [];
|
||||
if (sorted) {
|
||||
sorted.map(sorted => {
|
||||
if (sorted.id === h) {
|
||||
basicActions.push({
|
||||
icon: sorted.desc ? IconNames.SORT_ASC : IconNames.SORT_DESC,
|
||||
title: `Order by: ${h} ${sorted.desc ? 'ASC' : 'DESC'}`,
|
||||
onAction: () => sqlOrderBy(h, sorted.desc ? 'ASC' : 'DESC'),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!basicActions.length) {
|
||||
basicActions.push(
|
||||
{
|
||||
icon: IconNames.SORT_ASC,
|
||||
title: `Order by: ${h} ASC`,
|
||||
onAction: () => sqlOrderBy(h, 'ASC'),
|
||||
},
|
||||
{
|
||||
icon: IconNames.SORT_DESC,
|
||||
title: `Order by: ${h} DESC`,
|
||||
onAction: () => sqlOrderBy(h, 'DESC'),
|
||||
},
|
||||
);
|
||||
}
|
||||
basicActions.push({
|
||||
icon: IconNames.CROSS,
|
||||
title: `Remove: ${h}`,
|
||||
onAction: () => sqlExcludeColumn(h),
|
||||
});
|
||||
actionsMenu = basicActionsToMenu(basicActions);
|
||||
}
|
||||
return actionsMenu ? actionsMenu : undefined;
|
||||
}
|
||||
|
||||
getRowActions(row: string, header: string) {
|
||||
const { disabled, sqlFilterRow } = this.props;
|
||||
let actionsMenu;
|
||||
if (disabled) {
|
||||
actionsMenu = basicActionsToMenu([
|
||||
{
|
||||
icon: IconNames.CLIPBOARD,
|
||||
title: `Copy: '${row}'`,
|
||||
onAction: () => {
|
||||
copyAndAlert(row, `${row} copied to clipboard`);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: IconNames.CLIPBOARD,
|
||||
title: `Copy: ${basicIdentifierEscape(header)} = ${basicLiteralEscape(row)}`,
|
||||
onAction: () => {
|
||||
copyAndAlert(
|
||||
`${basicIdentifierEscape(header)} = ${basicLiteralEscape(row)}`,
|
||||
`${basicIdentifierEscape(header)} = ${basicLiteralEscape(row)} copied to clipboard`,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: IconNames.CLIPBOARD,
|
||||
title: `Copy: ${basicIdentifierEscape(header)} != ${basicLiteralEscape(row)}`,
|
||||
onAction: () => {
|
||||
copyAndAlert(
|
||||
`${basicIdentifierEscape(header)} != ${basicLiteralEscape(row)}`,
|
||||
`${basicIdentifierEscape(header)} != ${basicLiteralEscape(row)} copied to clipboard`,
|
||||
);
|
||||
},
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
actionsMenu = basicActionsToMenu([
|
||||
{
|
||||
icon: IconNames.FILTER_KEEP,
|
||||
title: `Filter by: ${header} = ${row}`,
|
||||
onAction: () => sqlFilterRow(row, header, '='),
|
||||
},
|
||||
{
|
||||
icon: IconNames.FILTER_REMOVE,
|
||||
title: `Filter by: ${header} != ${row}`,
|
||||
onAction: () => sqlFilterRow(row, header, '!='),
|
||||
},
|
||||
]);
|
||||
}
|
||||
return actionsMenu ? actionsMenu : undefined;
|
||||
}
|
||||
|
||||
getHeaderClassName(h: string) {
|
||||
const { sorted, aggregateColumns } = this.props;
|
||||
const className = [];
|
||||
className.push(
|
||||
sorted
|
||||
? sorted.map(sorted => {
|
||||
if (sorted.id === h) {
|
||||
return sorted.desc ? '-sort-desc' : '-sort-asc';
|
||||
}
|
||||
return '';
|
||||
})[0]
|
||||
: undefined,
|
||||
);
|
||||
if (aggregateColumns) {
|
||||
if (aggregateColumns.includes(h)) {
|
||||
className.push('aggregate-header');
|
||||
}
|
||||
}
|
||||
|
||||
return className.join(' ');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,11 +24,14 @@ import {
|
|||
isFirstRowHeader,
|
||||
normalizeQueryResult,
|
||||
shouldIncludeTimestamp,
|
||||
sqlParserFactory,
|
||||
SqlQuery,
|
||||
} from 'druid-query-toolkit';
|
||||
import Hjson from 'hjson';
|
||||
import React from 'react';
|
||||
import SplitterLayout from 'react-splitter-layout';
|
||||
|
||||
import { SQL_FUNCTIONS, SyntaxDescription } from '../../../lib/sql-function-doc';
|
||||
import { QueryPlanDialog } from '../../dialogs';
|
||||
import { EditContextDialog } from '../../dialogs/edit-context-dialog/edit-context-dialog';
|
||||
import { AppToaster } from '../../singletons/toaster';
|
||||
|
@ -55,6 +58,12 @@ import { RunButton } from './run-button/run-button';
|
|||
|
||||
import './query-view.scss';
|
||||
|
||||
const parser = sqlParserFactory(
|
||||
SQL_FUNCTIONS.map((sql_function: SyntaxDescription) => {
|
||||
return sql_function.syntax.substr(0, sql_function.syntax.indexOf('('));
|
||||
}),
|
||||
);
|
||||
|
||||
interface QueryWithContext {
|
||||
queryString: string;
|
||||
queryContext: QueryContext;
|
||||
|
@ -83,12 +92,17 @@ export interface QueryViewState {
|
|||
loadingExplain: boolean;
|
||||
explainError?: string;
|
||||
|
||||
defaultSchema?: string;
|
||||
defaultTable?: string;
|
||||
ast?: SqlQuery;
|
||||
|
||||
editContextDialogOpen: boolean;
|
||||
}
|
||||
|
||||
interface QueryResult {
|
||||
queryResult: HeaderRows;
|
||||
queryExtraInfo: QueryExtraInfoData;
|
||||
parsedQuery?: SqlQuery;
|
||||
}
|
||||
|
||||
export class QueryView extends React.PureComponent<QueryViewProps, QueryViewState> {
|
||||
|
@ -174,8 +188,18 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
|
|||
processQuery: async (queryWithContext: QueryWithContext): Promise<QueryResult> => {
|
||||
const { queryString, queryContext, wrapQuery } = queryWithContext;
|
||||
|
||||
let ast: SqlQuery | undefined;
|
||||
let wrappedLimit: number | undefined;
|
||||
let jsonQuery: any;
|
||||
|
||||
try {
|
||||
ast = parser(queryString);
|
||||
} catch {}
|
||||
|
||||
if (!(ast instanceof SqlQuery)) {
|
||||
ast = undefined;
|
||||
}
|
||||
|
||||
if (QueryView.isJsonLike(queryString)) {
|
||||
jsonQuery = Hjson.parse(queryString);
|
||||
} else {
|
||||
|
@ -237,6 +261,7 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
|
|||
numResults: queryResult.rows.length,
|
||||
wrappedLimit,
|
||||
},
|
||||
parsedQuery: ast,
|
||||
};
|
||||
},
|
||||
onStateChange: ({ result, loading, error }) => {
|
||||
|
@ -245,6 +270,7 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
|
|||
queryExtraInfo: result ? result.queryExtraInfo : undefined,
|
||||
loading,
|
||||
error,
|
||||
ast: result ? result.parsedQuery : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
@ -349,6 +375,7 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
|
|||
queryExtraInfo,
|
||||
error,
|
||||
columnMetadata,
|
||||
ast,
|
||||
} = this.state;
|
||||
const runeMode = QueryView.isJsonLike(queryString);
|
||||
|
||||
|
@ -384,11 +411,51 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<QueryOutput loading={loading} result={result} error={error} />
|
||||
<QueryOutput
|
||||
aggregateColumns={ast ? ast.getAggregateColumns() : undefined}
|
||||
disabled={!ast}
|
||||
sorted={ast ? ast.getSorted() : undefined}
|
||||
sqlExcludeColumn={this.sqlExcludeColumn}
|
||||
sqlFilterRow={this.sqlFilterRow}
|
||||
sqlOrderBy={this.sqlOrderBy}
|
||||
loading={loading}
|
||||
result={result}
|
||||
error={error}
|
||||
/>
|
||||
</SplitterLayout>
|
||||
);
|
||||
}
|
||||
|
||||
private sqlOrderBy = (header: string, direction: 'ASC' | 'DESC'): void => {
|
||||
let { ast } = this.state;
|
||||
if (!ast) return;
|
||||
ast = ast.orderBy(header, direction);
|
||||
this.setState({
|
||||
queryString: ast.toString(),
|
||||
});
|
||||
this.handleRun(true, ast.toString());
|
||||
};
|
||||
|
||||
private sqlExcludeColumn = (header: string): void => {
|
||||
let { ast } = this.state;
|
||||
if (!ast) return;
|
||||
ast = ast.excludeColumn(header);
|
||||
this.setState({
|
||||
queryString: ast.toString(),
|
||||
});
|
||||
this.handleRun(true, ast.toString());
|
||||
};
|
||||
|
||||
private sqlFilterRow = (row: string, header: string, operator: '!=' | '='): void => {
|
||||
let { ast } = this.state;
|
||||
if (!ast) return;
|
||||
ast = ast.filterRow(header, row, operator);
|
||||
this.setState({
|
||||
queryString: ast.toString(),
|
||||
});
|
||||
this.handleRun(true, ast.toString());
|
||||
};
|
||||
|
||||
private handleQueryStringChange = (queryString: string): void => {
|
||||
this.setState({ queryString });
|
||||
};
|
||||
|
@ -397,13 +464,15 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
|
|||
this.setState({ queryContext });
|
||||
};
|
||||
|
||||
private handleRun = (wrapQuery: boolean) => {
|
||||
private handleRun = (wrapQuery: boolean, customQueryString?: string) => {
|
||||
const { queryString, queryContext } = this.state;
|
||||
if (!customQueryString) {
|
||||
customQueryString = queryString;
|
||||
}
|
||||
if (QueryView.isJsonLike(customQueryString) && !QueryView.validRune(customQueryString)) return;
|
||||
|
||||
if (QueryView.isJsonLike(queryString) && !QueryView.validRune(queryString)) return;
|
||||
|
||||
localStorageSet(LocalStorageKeys.QUERY_KEY, queryString);
|
||||
this.sqlQueryManager.runQuery({ queryString, queryContext, wrapQuery });
|
||||
localStorageSet(LocalStorageKeys.QUERY_KEY, customQueryString);
|
||||
this.sqlQueryManager.runQuery({ queryString: customQueryString, queryContext, wrapQuery });
|
||||
};
|
||||
|
||||
private handleExplain = () => {
|
||||
|
@ -417,7 +486,32 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
|
|||
};
|
||||
|
||||
render(): JSX.Element {
|
||||
const { columnMetadata, columnMetadataLoading, columnMetadataError } = this.state;
|
||||
const {
|
||||
columnMetadata,
|
||||
columnMetadataLoading,
|
||||
columnMetadataError,
|
||||
ast,
|
||||
queryString,
|
||||
} = this.state;
|
||||
|
||||
let tempAst: SqlQuery | undefined;
|
||||
if (!ast) {
|
||||
try {
|
||||
tempAst = parser(queryString);
|
||||
} catch {}
|
||||
}
|
||||
let defaultSchema;
|
||||
if (ast && ast instanceof SqlQuery) {
|
||||
defaultSchema = ast.getSchema();
|
||||
} else if (tempAst && tempAst instanceof SqlQuery) {
|
||||
defaultSchema = tempAst.getSchema();
|
||||
}
|
||||
let defaultTable;
|
||||
if (ast && ast instanceof SqlQuery) {
|
||||
defaultTable = ast.getTableName();
|
||||
} else if (tempAst && tempAst instanceof SqlQuery) {
|
||||
defaultTable = tempAst.getTableName();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -428,6 +522,8 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
|
|||
columnMetadataLoading={columnMetadataLoading}
|
||||
columnMetadata={columnMetadata}
|
||||
onQueryStringChange={this.handleQueryStringChange}
|
||||
defaultSchema={defaultSchema}
|
||||
defaultTable={defaultTable}
|
||||
/>
|
||||
)}
|
||||
{this.renderMainArea()}
|
||||
|
|
|
@ -134,7 +134,7 @@ export class RunButton extends React.PureComponent<RunButtonProps, RunButtonStat
|
|||
onQueryContextChange(setUseCache(queryContext, !useCache));
|
||||
}}
|
||||
/>
|
||||
{runeMode && (
|
||||
{!runeMode && (
|
||||
<MenuItem icon={IconNames.PROPERTIES} text="Edit context" onClick={onEditContext} />
|
||||
)}
|
||||
</Menu>
|
||||
|
|
Loading…
Reference in New Issue