Web console: fix grouped filtering and add complex menu (#14668)

* fix filtering when grouped

* add complex menu

* complex aggs

* use ResizeObserverEntry

* add quantile and test

* fix style

* update snapshots
This commit is contained in:
Vadim Ogievetsky 2023-08-01 10:41:44 -07:00 committed by GitHub
parent 2e456d25ae
commit 153948198c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 264 additions and 40 deletions

View File

@ -40,7 +40,12 @@ export function bootstrapReactTable() {
className: DEFAULT_TABLE_CLASS_NAME,
defaultFilterMethod: (filter: Filter, row: any) => {
const id = filter.pivotId || filter.id;
return booleanCustomTableFilter(filter, row[id]);
const subRows = row._subRows;
if (Array.isArray(subRows)) {
return subRows.some(r => booleanCustomTableFilter(filter, r[id]));
} else {
return booleanCustomTableFilter(filter, row[id]);
}
},
LoadingComponent: Loader,
loadingText: '',

View File

@ -16,7 +16,6 @@
* limitations under the License.
*/
import type { IResizeEntry } from '@blueprintjs/core';
import { FormGroup, HTMLSelect, Radio, RadioGroup, ResizeSensor } from '@blueprintjs/core';
import type { AxisScale } from 'd3-axis';
import { scaleLinear, scaleUtc } from 'd3-scale';
@ -428,7 +427,7 @@ ORDER BY "start" DESC`;
}
};
private readonly handleResize = (entries: IResizeEntry[]) => {
private readonly handleResize = (entries: ResizeObserverEntry[]) => {
const chartRect = entries[0].contentRect;
this.setState({
chartWidth: chartRect.width,

View File

@ -107,7 +107,7 @@ export function addOrUpdateFilter(filters: readonly Filter[], filter: Filter): F
return addOrUpdate(filters, filter, f => f.id);
}
export function booleanCustomTableFilter(filter: Filter, value: any): boolean {
export function booleanCustomTableFilter(filter: Filter, value: unknown): boolean {
if (value == null) return false;
const modeAndNeedle = parseFilterModeAndNeedle(filter);
if (!modeAndNeedle) return true;

View File

@ -32,7 +32,13 @@ exports[`ColumnTree matches snapshot 1`] = `
Object {
"childNodes": Array [
Object {
"icon": "time",
"icon": <Blueprint4.Icon
aria-hidden={true}
className="bp4-tree-node-icon"
icon="time"
tabIndex={-1}
title="TIMESTAMP"
/>,
"id": "__time",
"label": <Blueprint4.Popover2
autoFocus={false}
@ -65,7 +71,13 @@ exports[`ColumnTree matches snapshot 1`] = `
</Blueprint4.Popover2>,
},
Object {
"icon": "numerical",
"icon": <Blueprint4.Icon
aria-hidden={true}
className="bp4-tree-node-icon"
icon="numerical"
tabIndex={-1}
title="BIGINT"
/>,
"id": "added",
"label": <Blueprint4.Popover2
autoFocus={false}
@ -98,7 +110,13 @@ exports[`ColumnTree matches snapshot 1`] = `
</Blueprint4.Popover2>,
},
Object {
"icon": "floating-point",
"icon": <Blueprint4.Icon
aria-hidden={true}
className="bp4-tree-node-icon"
icon="floating-point"
tabIndex={-1}
title="FLOAT"
/>,
"id": "addedBy10",
"label": <Blueprint4.Popover2
autoFocus={false}

View File

@ -0,0 +1,74 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ComplexMenuItems matches snapshot when menu is opened for column not inside group by 1`] = `
<div>
<li
class="bp4-submenu"
role="none"
>
<span
class="bp4-popover-wrapper"
>
<span
aria-haspopup="true"
class="bp4-popover-target"
role="menuitem"
tabindex="0"
>
<a
class="bp4-menu-item"
role="none"
tabindex="-1"
>
<span
class="bp4-menu-item-icon"
>
<span
aria-hidden="true"
class="bp4-icon bp4-icon-function"
icon="function"
tabindex="-1"
>
<svg
data-icon="function"
height="16"
role="img"
viewBox="0 0 16 16"
width="16"
>
<path
d="M8.12 4.74H6.98c.33-1.29.75-2.24 1.28-2.84.33-.37.64-.56.95-.56.06 0 .11.02.15.05.04.04.06.09.06.15 0 .05-.04.15-.13.29-.09.14-.13.28-.13.4 0 .18.07.33.2.46.14.13.31.19.52.19.22 0 .41-.08.56-.23.15-.16.23-.37.23-.63 0-.3-.11-.55-.34-.74C10.1 1.09 9.74 1 9.24 1c-.78 0-1.49.22-2.12.67-.64.45-1.24 1.2-1.81 2.23-.2.36-.38.59-.56.69-.18.1-.46.15-.85.15l-.26.9h1.08l-1.59 6.12c-.27 1.01-.44 1.63-.54 1.86-.14.34-.34.63-.62.87-.11.1-.24.15-.4.15a.15.15 0 01-.11-.04l-.04-.05c0-.03.04-.08.12-.16.08-.08.12-.2.12-.36 0-.18-.06-.33-.19-.44-.12-.12-.3-.18-.54-.18-.28 0-.51.08-.68.23-.16.14-.25.32-.25.53 0 .22.1.42.31.59.21.17.53.25.97.25.7 0 1.32-.18 1.87-.54.54-.36 1.02-.92 1.42-1.67.41-.75.82-1.96 1.25-3.63l.91-3.54h1.1l.29-.89zm5.43 1.52c.2-.15.41-.23.62-.23.08 0 .23.03.45.09s.41.09.57.09c.23 0 .42-.08.57-.23.16-.16.24-.36.24-.61 0-.26-.08-.47-.23-.62-.15-.15-.37-.23-.66-.23-.25 0-.5.06-.72.18-.23.12-.51.38-.86.78-.26.3-.64.81-1.15 1.55-.2-.91-.55-1.75-1.05-2.51l-2.72.46-.06.29c.2-.04.37-.06.51-.06.27 0 .49.11.67.34.28.36.67 1.45 1.17 3.26-.39.52-.66.85-.8 1.01-.24.26-.44.42-.59.5-.12.06-.25.09-.41.09-.11 0-.3-.06-.56-.18-.18-.08-.34-.12-.48-.12-.27 0-.48.08-.66.25-.17.17-.26.38-.26.64 0 .25.08.44.24.6.16.15.37.23.64.23.26 0 .5-.05.73-.16.23-.11.52-.34.86-.69.35-.35.82-.9 1.43-1.67.23.73.44 1.25.61 1.58s.37.57.59.71c.22.15.5.22.83.22.32 0 .65-.11.98-.34.44-.3.88-.81 1.34-1.53l-.26-.15c-.31.43-.54.7-.69.8-.1.07-.22.1-.35.1-.16 0-.32-.1-.48-.3-.27-.34-.62-1.27-1.06-2.8.4-.68.73-1.13 1-1.34z"
fill-rule="evenodd"
/>
</svg>
</span>
</span>
<div
class="bp4-fill bp4-text-overflow-ellipsis"
>
Aggregate
</div>
<span
aria-hidden="true"
class="bp4-icon bp4-icon-caret-right bp4-submenu-icon"
icon="caret-right"
>
<svg
data-icon="caret-right"
height="16"
role="img"
viewBox="0 0 16 16"
width="16"
>
<path
d="M11 8c0-.15-.07-.28-.17-.37l-4-3.5A.495.495 0 006 4.5v7a.495.495 0 00.83.37l4-3.5c.1-.09.17-.22.17-.37z"
fill-rule="evenodd"
/>
</svg>
</span>
</a>
</span>
</span>
</li>
</div>
`;

View File

@ -0,0 +1,41 @@
/*
* 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 { SqlQuery } from '@druid-toolkit/query';
import { render } from '@testing-library/react';
import React from 'react';
import { ComplexMenuItems } from './complex-menu-items';
describe('ComplexMenuItems', () => {
it('matches snapshot when menu is opened for column not inside group by', () => {
const numberMenu = (
<ComplexMenuItems
schema="schema"
table="table"
columnName="user_theta"
columnType="COMPLEX<thetaSketch>"
parsedQuery={SqlQuery.parse(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
onQueryChange={() => {}}
/>
);
const { container } = render(numberMenu);
expect(container).toMatchSnapshot();
});
});

View File

@ -0,0 +1,83 @@
/*
* 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 { MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import type { SqlExpression, SqlQuery } from '@druid-toolkit/query';
import { C, F } from '@druid-toolkit/query';
import type { JSX } from 'react';
import React from 'react';
import { prettyPrintSql } from '../../../../../utils';
const UNIQUE_FUNCTIONS: Record<string, string> = {
'COMPLEX<hyperUnique>': 'APPROX_COUNT_DISTINCT_BUILTIN',
'COMPLEX<thetaSketch>': 'APPROX_COUNT_DISTINCT_DS_THETA',
'COMPLEX<HLLSketch>': 'APPROX_COUNT_DISTINCT_DS_HLL',
};
const QUANTILE_FUNCTIONS: Record<string, string> = {
'COMPLEX<quantilesDoublesSketch>': 'APPROX_QUANTILE_DS',
};
export interface ComplexMenuItemsProps {
table: string;
schema: string;
columnName: string;
columnType: string;
parsedQuery: SqlQuery;
onQueryChange: (query: SqlQuery, run?: boolean) => void;
}
export const ComplexMenuItems = React.memo(function ComplexMenuItems(props: ComplexMenuItemsProps) {
const { columnName, columnType, parsedQuery, onQueryChange } = props;
const column = C(columnName);
function renderAggregateMenu(): JSX.Element | undefined {
if (!parsedQuery.hasGroupBy()) return;
function aggregateMenuItem(ex: SqlExpression, alias: string) {
return (
<MenuItem
text={prettyPrintSql(ex)}
onClick={() => {
onQueryChange(parsedQuery.addSelect(ex.as(alias)), true);
}}
/>
);
}
const uniqueFn = UNIQUE_FUNCTIONS[columnType];
const quantileFn = QUANTILE_FUNCTIONS[columnType];
if (!uniqueFn && !quantileFn) return;
return (
<MenuItem icon={IconNames.FUNCTION} text="Aggregate">
{uniqueFn && aggregateMenuItem(F(uniqueFn, column), `unique_${columnName}`)}
{quantileFn && (
<>
{aggregateMenuItem(F(quantileFn, column, 0.5), `median_${columnName}`)}
{aggregateMenuItem(F(quantileFn, column, 0.98), `p98_${columnName}`)}
</>
)}
</MenuItem>
);
}
return <>{renderAggregateMenu()}</>;
});

View File

@ -16,6 +16,7 @@
* limitations under the License.
*/
export * from './complex-menu-items/complex-menu-items';
export * from './number-menu-items/number-menu-items';
export * from './string-menu-items/string-menu-items';
export * from './time-menu-items/time-menu-items';

View File

@ -36,9 +36,10 @@ export interface NumberMenuItemsProps {
}
export const NumberMenuItems = React.memo(function NumberMenuItems(props: NumberMenuItemsProps) {
function renderFilterMenu(): JSX.Element {
const { columnName, parsedQuery, onQueryChange } = props;
const { columnName, parsedQuery, onQueryChange } = props;
const column = C(columnName);
function renderFilterMenu(): JSX.Element {
function filterMenuItem(clause: SqlExpression) {
return (
<MenuItem
@ -50,7 +51,6 @@ export const NumberMenuItems = React.memo(function NumberMenuItems(props: Number
);
}
const column = C(columnName);
return (
<MenuItem icon={IconNames.FILTER} text="Filter">
{filterMenuItem(column.greaterThan(NINE_THOUSAND))}
@ -60,7 +60,6 @@ export const NumberMenuItems = React.memo(function NumberMenuItems(props: Number
}
function renderRemoveFilter(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.getEffectiveWhereExpression().containsColumnName(columnName)) return;
return (
@ -75,7 +74,6 @@ export const NumberMenuItems = React.memo(function NumberMenuItems(props: Number
}
function renderGroupByMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.hasGroupBy()) return;
function groupByMenuItem(ex: SqlExpression, alias?: string) {
@ -95,7 +93,6 @@ export const NumberMenuItems = React.memo(function NumberMenuItems(props: Number
);
}
const column = C(columnName);
return (
<MenuItem icon={IconNames.GROUP_OBJECTS} text="Group by">
{groupByMenuItem(column)}
@ -105,7 +102,6 @@ export const NumberMenuItems = React.memo(function NumberMenuItems(props: Number
}
function renderRemoveGroupBy(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
const groupedSelectIndexes = parsedQuery.getGroupedSelectIndexesForColumn(columnName);
if (!groupedSelectIndexes.length) return;
@ -121,7 +117,6 @@ export const NumberMenuItems = React.memo(function NumberMenuItems(props: Number
}
function renderAggregateMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.hasGroupBy()) return;
function aggregateMenuItem(ex: SqlExpression, alias: string) {
@ -135,7 +130,6 @@ export const NumberMenuItems = React.memo(function NumberMenuItems(props: Number
);
}
const column = C(columnName);
return (
<MenuItem icon={IconNames.FUNCTION} text="Aggregate">
{aggregateMenuItem(F('SUM', column), `sum_${columnName}`)}

View File

@ -35,9 +35,10 @@ export interface StringMenuItemsProps {
}
export const StringMenuItems = React.memo(function StringMenuItems(props: StringMenuItemsProps) {
function renderFilterMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
const { schema, table, columnName, parsedQuery, onQueryChange } = props;
const column = C(columnName);
function renderFilterMenu(): JSX.Element | undefined {
function filterMenuItem(clause: SqlExpression, run = true) {
return (
<MenuItem
@ -49,7 +50,6 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
);
}
const column = C(columnName);
return (
<MenuItem icon={IconNames.FILTER} text="Filter">
{filterMenuItem(column.isNotNull())}
@ -61,7 +61,6 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
}
function renderRemoveFilter(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.getEffectiveWhereExpression().containsColumnName(columnName)) return;
return (
@ -76,7 +75,6 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
}
function renderRemoveGroupBy(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
const groupedSelectIndexes = parsedQuery.getGroupedSelectIndexesForColumn(columnName);
if (!groupedSelectIndexes.length) return;
@ -92,7 +90,6 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
}
function renderGroupByMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.hasGroupBy()) return;
function groupByMenuItem(ex: SqlExpression, alias?: string) {
@ -112,7 +109,6 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
);
}
const column = C(columnName);
return (
<MenuItem icon={IconNames.GROUP_OBJECTS} text="Group by">
{groupByMenuItem(column)}
@ -123,7 +119,6 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
}
function renderAggregateMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.hasGroupBy()) return;
function aggregateMenuItem(ex: SqlExpression, alias: string, run = true) {
@ -137,7 +132,6 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
);
}
const column = C(columnName);
return (
<MenuItem icon={IconNames.FUNCTION} text="Aggregate">
{aggregateMenuItem(F.countDistinct(column), `dist_${columnName}`)}
@ -152,7 +146,6 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
}
function renderJoinMenu(): JSX.Element | undefined {
const { schema, table, columnName, parsedQuery, onQueryChange } = props;
if (schema !== 'lookup' || !parsedQuery) return;
const firstTableName = parsedQuery.getFirstTableName();
if (!firstTableName) return;
@ -212,7 +205,6 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
}
function renderRemoveJoin(): JSX.Element | undefined {
const { schema, parsedQuery, onQueryChange } = props;
if (schema !== 'lookup' || !parsedQuery) return;
if (!parsedQuery.hasJoin()) return;

View File

@ -113,9 +113,10 @@ export interface TimeMenuItemsProps {
}
export const TimeMenuItems = React.memo(function TimeMenuItems(props: TimeMenuItemsProps) {
function renderFilterMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
const { columnName, parsedQuery, onQueryChange } = props;
const column = C(columnName);
function renderFilterMenu(): JSX.Element | undefined {
function filterMenuItem(label: string, clause: SqlExpression) {
return (
<MenuItem
@ -161,7 +162,6 @@ export const TimeMenuItems = React.memo(function TimeMenuItems(props: TimeMenuIt
}
function renderRemoveFilter(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.getEffectiveWhereExpression().containsColumnName(columnName)) return;
return (
@ -176,7 +176,6 @@ export const TimeMenuItems = React.memo(function TimeMenuItems(props: TimeMenuIt
}
function renderRemoveGroupBy(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
const groupedSelectIndexes = parsedQuery.getGroupedSelectIndexesForColumn(columnName);
if (!groupedSelectIndexes.length) return;
@ -192,7 +191,6 @@ export const TimeMenuItems = React.memo(function TimeMenuItems(props: TimeMenuIt
}
function renderGroupByMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.hasGroupBy()) return;
function groupByMenuItem(ex: SqlExpression, alias: string) {
@ -212,7 +210,6 @@ export const TimeMenuItems = React.memo(function TimeMenuItems(props: TimeMenuIt
);
}
const column = C(columnName);
return (
<MenuItem icon={IconNames.GROUP_OBJECTS} text="Group by">
{groupByMenuItem(F.timeFloor(column, 'PT1H'), `${columnName}_by_hour`)}
@ -229,7 +226,6 @@ export const TimeMenuItems = React.memo(function TimeMenuItems(props: TimeMenuIt
}
function renderAggregateMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.hasGroupBy()) return;
function aggregateMenuItem(ex: SqlExpression, alias: string) {
@ -243,7 +239,6 @@ export const TimeMenuItems = React.memo(function TimeMenuItems(props: TimeMenuIt
);
}
const column = C(columnName);
return (
<MenuItem icon={IconNames.FUNCTION} text="Aggregate">
{aggregateMenuItem(F.max(column), `max_${columnName}`)}

View File

@ -17,7 +17,7 @@
*/
import type { TreeNodeInfo } from '@blueprintjs/core';
import { HTMLSelect, Menu, MenuItem, Position, Tree } from '@blueprintjs/core';
import { Classes, HTMLSelect, Icon, Menu, MenuItem, Position, Tree } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import { Popover2 } from '@blueprintjs/popover2';
import type { SqlExpression } from '@druid-toolkit/query';
@ -39,7 +39,12 @@ import { Deferred, Loader } from '../../../components';
import type { ColumnMetadata } from '../../../utils';
import { copyAndAlert, dataTypeToIcon, groupBy, oneOf, prettyPrintSql } from '../../../utils';
import { NumberMenuItems, StringMenuItems, TimeMenuItems } from './column-tree-menu';
import {
ComplexMenuItems,
NumberMenuItems,
StringMenuItems,
TimeMenuItems,
} from './column-tree-menu';
import './column-tree.scss';
@ -400,7 +405,15 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
childNodes: metadata.map(
(columnData): TreeNodeInfo => ({
id: columnData.COLUMN_NAME,
icon: dataTypeToIcon(columnData.DATA_TYPE),
icon: (
<Icon
className={Classes.TREE_NODE_ICON}
icon={dataTypeToIcon(columnData.DATA_TYPE)}
aria-hidden
tabIndex={-1}
title={columnData.DATA_TYPE}
/>
),
label: (
<Popover2
position={Position.RIGHT}
@ -454,6 +467,16 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
onQueryChange={onQueryChange}
/>
)}
{parsedQuery && columnData.DATA_TYPE.startsWith('COMPLEX<') && (
<ComplexMenuItems
table={tableName}
schema={schemaName}
columnName={columnData.COLUMN_NAME}
columnType={columnData.DATA_TYPE}
parsedQuery={parsedQuery}
onQueryChange={onQueryChange}
/>
)}
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${columnData.COLUMN_NAME}`}

View File

@ -16,7 +16,6 @@
* limitations under the License.
*/
import type { ResizeEntry } from '@blueprintjs/core';
import { ResizeSensor2 } from '@blueprintjs/popover2';
import { C, T } from '@druid-toolkit/query';
import type { Ace } from 'ace-builds';
@ -270,7 +269,7 @@ export class FlexibleQueryInput extends React.PureComponent<
}
}
private readonly handleAceContainerResize = (entries: ResizeEntry[]) => {
private readonly handleAceContainerResize = (entries: ResizeObserverEntry[]) => {
if (entries.length !== 1) return;
this.setState({ editorHeight: entries[0].contentRect.height });
};