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:
mcbrewster 2019-08-06 23:58:00 -07:00 committed by Clint Wylie
parent 7702005f8f
commit e2d1d00fb8
12 changed files with 1001 additions and 761 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -40,4 +40,8 @@
padding-left: 0;
}
}
.bp3-popover-target {
cursor: pointer;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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