Skip to content
17 changes: 17 additions & 0 deletions web-console/src/utils/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,23 @@ export function findMap<T, Q>(
return filterMap(xs, f)[0];
}

export function minBy<T>(xs: T[], f: (item: T, index: number) => number): T | undefined {
if (!xs.length) return undefined;

let minItem = xs[0];
let minValue = f(xs[0], 0);

for (let i = 1; i < xs.length; i++) {
const currentValue = f(xs[i], i);
if (currentValue < minValue) {
minValue = currentValue;
minItem = xs[i];
}
}

return minItem;
}

export function changeByIndex<T>(
xs: readonly T[],
i: number,
Expand Down
13 changes: 12 additions & 1 deletion web-console/src/utils/query-manager/query-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface QueryManagerOptions<Q, R, I = never, E extends Error = Error> {
cancelToken: CancelToken,
) => Promise<R | IntermediateQueryState<I> | ResultWithAuxiliaryWork<R>>;
onStateChange?: (queryResolve: QueryState<R, E, I>) => void;
debounceInit?: number;
debounceIdle?: number;
debounceLoading?: number;
backgroundStatusCheckInitDelay?: number;
Expand Down Expand Up @@ -78,6 +79,7 @@ export class QueryManager<Q, R, I = never, E extends Error = Error> {
private state: QueryState<R, E, I>;
private currentQueryId = 0;

private readonly runWhenInit: () => void | Promise<void>;
private readonly runWhenIdle: () => void | Promise<void>;
private readonly runWhenLoading: () => void | Promise<void>;

Expand All @@ -88,6 +90,11 @@ export class QueryManager<Q, R, I = never, E extends Error = Error> {
this.backgroundStatusCheckInitDelay = options.backgroundStatusCheckInitDelay || 500;
this.backgroundStatusCheckDelay = options.backgroundStatusCheckDelay || 1000;
this.swallowBackgroundError = options.swallowBackgroundError;
if (options.debounceInit !== 0) {
this.runWhenInit = debounce(this.run, options.debounceInit || 50);
} else {
this.runWhenInit = this.run;
}
if (options.debounceIdle !== 0) {
this.runWhenIdle = debounce(this.run, options.debounceIdle || 100);
} else {
Expand Down Expand Up @@ -257,7 +264,11 @@ export class QueryManager<Q, R, I = never, E extends Error = Error> {
}),
);

void this.runWhenIdle();
if (this.lastQuery) {
void this.runWhenIdle();
} else {
void this.runWhenInit();
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@
*/

import type { IconName } from '@blueprintjs/core';
import { Icon, InputGroup, Menu, MenuItem } from '@blueprintjs/core';
import { ContextMenu, Icon, InputGroup, Menu, MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames';
import type { Column } from 'druid-query-toolkit';
import { C } from 'druid-query-toolkit';
import { useState } from 'react';

import { caseInsensitiveContains, columnToIcon, filterMap } from '../../../../utils';
import { caseInsensitiveContains, columnToIcon, copyAndAlert, filterMap } from '../../../../utils';

import './column-picker-menu.scss';

Expand Down Expand Up @@ -66,17 +67,33 @@ export const ColumnPickerMenu = function ColumnPickerMenu(props: ColumnPickerMen
/>
)}
{filterMap(columns, (c, i) => {
if (!caseInsensitiveContains(c.name, columnSearch)) return;
const columnName = c.name;
if (!caseInsensitiveContains(columnName, columnSearch)) return;
const iconName = rightIconForColumn?.(c);
return (
<MenuItem
<ContextMenu
key={i}
icon={columnToIcon(c) || IconNames.BLANK}
text={c.name}
labelElement={iconName && <Icon icon={iconName} />}
onClick={() => onSelectColumn(c)}
shouldDismissPopover={shouldDismissPopover}
/>
content={
<Menu>
<MenuItem
text="Copy"
onClick={() => copyAndAlert(String(columnName), `Copied to clipboard`)}
/>
<MenuItem
text="Copy as SQL column"
onClick={() => copyAndAlert(String(C(columnName)), `Copied to clipboard`)}
/>
</Menu>
}
>
<MenuItem
icon={columnToIcon(c) || IconNames.BLANK}
text={columnName}
labelElement={iconName && <Icon icon={iconName} />}
onClick={() => onSelectColumn(c)}
shouldDismissPopover={shouldDismissPopover}
/>
</ContextMenu>
);
})}
</Menu>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import './contains-filter-control.scss';

export interface ContainsFilterControlProps {
querySource: QuerySource;
extraFilter: SqlExpression;
filter: SqlExpression;
filterPattern: ContainsFilterPattern;
setFilterPattern(filterPattern: ContainsFilterPattern): void;
Expand All @@ -39,14 +40,15 @@ export interface ContainsFilterControlProps {
export const ContainsFilterControl = React.memo(function ContainsFilterControl(
props: ContainsFilterControlProps,
) {
const { querySource, filter, filterPattern, setFilterPattern, runSqlQuery } = props;
const { querySource, extraFilter, filter, filterPattern, setFilterPattern, runSqlQuery } = props;
const { column, negated, contains } = filterPattern;

const previewQuery = useMemo(
() =>
querySource
.getInitQuery(
SqlExpression.and(
extraFilter,
filter,
contains ? filterPatternToExpression(filterPattern) : undefined,
),
Expand All @@ -56,7 +58,7 @@ export const ContainsFilterControl = React.memo(function ContainsFilterControl(
.changeLimitValue(101)
.toString(),
// eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps
[querySource.query, filter, column, contains, negated],
[querySource.query, extraFilter, filter, column, contains, negated],
);

const [previewState] = useQueryManager<string, string[]>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ type FilterMenuTab = 'compose' | 'sql';

export interface FilterMenuProps {
querySource: QuerySource;
extraFilter: SqlExpression;
filter: SqlExpression;
initPattern?: FilterPattern;
onPatternChange(newPattern: FilterPattern): void;
Expand All @@ -121,6 +122,7 @@ export interface FilterMenuProps {
export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps) {
const {
querySource,
extraFilter,
filter,
initPattern,
onPatternChange,
Expand All @@ -136,8 +138,18 @@ export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps)
initPattern?.type === 'custom' ? filterPatternToExpression(initPattern).toString() : '',
);
const [pattern, setPattern] = useState<FilterPattern | undefined>(initPattern);
const [issue, setIssue] = useState<string | undefined>();
const { columns } = querySource;

function setFilterPatternOrIssue(pattern: FilterPattern | undefined, issue: string | undefined) {
if (pattern) {
setPattern(pattern);
setIssue(undefined);
} else {
setIssue(issue || 'Issue');
}
}

function onAcceptPattern(pattern: FilterPattern) {
onPatternChange(pattern);
onClose();
Expand All @@ -151,6 +163,7 @@ export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps)
cont = (
<ValuesFilterControl
querySource={querySource}
extraFilter={extraFilter}
filter={filter}
filterPattern={pattern}
setFilterPattern={setPattern}
Expand All @@ -163,6 +176,7 @@ export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps)
cont = (
<ContainsFilterControl
querySource={querySource}
extraFilter={extraFilter}
filter={filter}
filterPattern={pattern}
setFilterPattern={setPattern}
Expand All @@ -175,6 +189,7 @@ export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps)
cont = (
<RegexpFilterControl
querySource={querySource}
extraFilter={extraFilter}
filter={filter}
filterPattern={pattern}
setFilterPattern={setPattern}
Expand All @@ -198,7 +213,8 @@ export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps)
<TimeIntervalFilterControl
querySource={querySource}
filterPattern={pattern}
setFilterPattern={setPattern}
setFilterPatternOrIssue={setFilterPatternOrIssue}
onIssue={setIssue}
/>
);
break;
Expand Down Expand Up @@ -281,6 +297,7 @@ export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps)
active={tab === 'sql'}
onClick={() => {
setFormula(pattern ? filterPatternToExpression(pattern).toString() : '');
setIssue(undefined);
setTab('sql');
}}
/>
Expand Down Expand Up @@ -416,8 +433,17 @@ export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps)
intent={Intent.PRIMARY}
text="Apply"
disabled={tab === 'sql' && formula === ''}
data-tooltip={issue ? `Issue: ${issue}` : undefined}
onClick={() => {
if (tab === 'compose') {
if (issue) {
AppToaster.show({
message: issue,
intent: Intent.DANGER,
});
return;
}

if (pattern) {
onAcceptPattern(pattern);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function regexpIssue(possibleRegexp: string): string | undefined {

export interface RegexpFilterControlProps {
querySource: QuerySource;
extraFilter: SqlExpression;
filter: SqlExpression;
filterPattern: RegexpFilterPattern;
setFilterPattern(filterPattern: RegexpFilterPattern): void;
Expand All @@ -48,21 +49,25 @@ export interface RegexpFilterControlProps {
export const RegexpFilterControl = React.memo(function RegexpFilterControl(
props: RegexpFilterControlProps,
) {
const { querySource, filter, filterPattern, setFilterPattern, runSqlQuery } = props;
const { querySource, extraFilter, filter, filterPattern, setFilterPattern, runSqlQuery } = props;
const { column, negated, regexp } = filterPattern;

const previewQuery = useMemo(
() =>
querySource
.getInitQuery(
SqlExpression.and(filter, regexp ? filterPatternToExpression(filterPattern) : undefined),
SqlExpression.and(
extraFilter,
filter,
regexp ? filterPatternToExpression(filterPattern) : undefined,
),
)
.addSelect(F.cast(C(column), 'VARCHAR').as('c'), { addToGroupBy: 'end' })
.changeOrderByExpression(F.count().toOrderByExpression('DESC'))
.changeLimitValue(101)
.toString(),
// eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps
[querySource.query, filter, column, regexp, negated],
[querySource.query, extraFilter, filter, column, regexp, negated],
);

const [previewState] = useQueryManager<string, string[]>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,35 +18,69 @@

import { FormGroup } from '@blueprintjs/core';
import type { TimeIntervalFilterPattern } from 'druid-query-toolkit';
import React from 'react';
import React, { useState } from 'react';

import type { QuerySource } from '../../../../models';
import { UtcDateInput } from '../../../utc-date-input/utc-date-input';
import { IsoDateInput } from '../../../iso-date-input/iso-date-input';

import './time-interval-filter-control.scss';

function isSwappedFilterPattern(pattern: TimeIntervalFilterPattern) {
return pattern.end <= pattern.start;
}

export interface TimeIntervalFilterControlProps {
querySource: QuerySource;
filterPattern: TimeIntervalFilterPattern;
setFilterPattern(filterPattern: TimeIntervalFilterPattern): void;
setFilterPatternOrIssue(
filterPattern: TimeIntervalFilterPattern | undefined,
issue: string | undefined,
): void;
onIssue(issue: string): void;
}

export const TimeIntervalFilterControl = React.memo(function TimeIntervalFilterControl(
props: TimeIntervalFilterControlProps,
) {
const { filterPattern, setFilterPattern } = props;
const { start, end } = filterPattern;
const { filterPattern, setFilterPatternOrIssue, onIssue } = props;
const [swappedFilterPattern, setSwappedFilterPattern] = useState<
TimeIntervalFilterPattern | undefined
>();
const { start, end } = swappedFilterPattern || filterPattern;

return (
<div className="time-interval-filter-control">
<FormGroup label="Start">
<UtcDateInput
<IsoDateInput
date={start}
onChange={start => setFilterPattern({ ...filterPattern, start })}
onChange={start => {
const newPattern = { ...filterPattern, start };
if (isSwappedFilterPattern(newPattern)) {
setSwappedFilterPattern(newPattern);
setFilterPatternOrIssue(undefined, 'Start date must be before end date');
} else {
setSwappedFilterPattern(undefined);
setFilterPatternOrIssue(newPattern, undefined);
}
}}
onIssue={() => onIssue('Bad start date')}
/>
</FormGroup>
<FormGroup label="End">
<UtcDateInput date={end} onChange={end => setFilterPattern({ ...filterPattern, end })} />
<IsoDateInput
date={end}
onChange={end => {
const newPattern = { ...filterPattern, end };
if (isSwappedFilterPattern(newPattern)) {
setSwappedFilterPattern(newPattern);
setFilterPatternOrIssue(undefined, 'End date must be after start date');
} else {
setSwappedFilterPattern(undefined);
setFilterPatternOrIssue(newPattern, undefined);
}
}}
onIssue={() => onIssue('Bad end date')}
/>
</FormGroup>
</div>
);
Expand Down
Loading
Loading