blob: 1b1cd5a785f46aec8a12c2c00628c7afa9d3be85 [file] [log] [blame]
/*
* 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,
InputGroup,
Intent,
Menu,
MenuItem,
Popover,
SegmentedControl,
Tag,
} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames';
import type { Column, SqlExpression } from 'druid-query-toolkit';
import { SqlColumn } from 'druid-query-toolkit';
import type { JSX } from 'react';
import {
AutoForm,
FancyNumericInput,
FormGroupWithInfo,
PopoverText,
} from '../../../../components';
import { AppToaster } from '../../../../singletons';
import { filterMap } from '../../../../utils';
import type { OptionValue, ParameterDefinition, QuerySource } from '../../models';
import { evaluateFunctor, ExpressionMeta, getModuleOptionLabel, Measure } from '../../models';
import { changeOrAdd } from '../../utils';
import { DroppableContainer } from '../droppable-container/droppable-container';
import { ExpressionMenu } from './expression-menu';
import { MeasureMenu } from './measure-menu';
import { NamedExpressionsInput } from './named-expressions-input';
import { OptionsInput } from './options-input';
import './control-pane.scss';
export interface ControlPaneProps {
querySource: QuerySource | undefined;
where: SqlExpression;
onUpdateParameterValues(params: Record<string, unknown>): void;
parameters: Record<string, ParameterDefinition>;
parameterValues: Record<string, unknown>;
compact?: boolean;
onAddToSourceQueryAsColumn?(expression: SqlExpression): void;
onAddToSourceQueryAsMeasure?(measure: Measure): void;
}
export const ControlPane = function ControlPane(props: ControlPaneProps) {
const {
querySource,
where,
onUpdateParameterValues,
parameters,
parameterValues,
compact,
onAddToSourceQueryAsColumn,
onAddToSourceQueryAsMeasure,
} = props;
const columns = querySource?.columns || [];
const measures = querySource?.measures || [];
function renderOptionsPropInput(
parameter: ParameterDefinition,
value: any,
onValueChange: (value: any) => void,
): {
element: JSX.Element;
onDropColumn?: (column: Column) => void;
onDropMeasure?: (measure: Measure) => void;
} {
const required = evaluateFunctor(parameter.required, parameterValues, querySource, where);
switch (parameter.type) {
case 'boolean': {
return {
element: (
<SegmentedControl
value={String(value)}
onValueChange={v => {
onValueChange(v === 'true');
}}
options={[
{ value: 'false', label: 'False' },
{ value: 'true', label: 'True' },
]}
small
/>
),
};
}
case 'number':
return {
element: (
<FancyNumericInput
value={value}
onValueChange={onValueChange}
placeholder={parameter.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.placeholder}
fill
/>
),
};
case 'option': {
const controlOptions =
evaluateFunctor(parameter.options, parameterValues, querySource, where) || [];
const selectedOption: OptionValue | undefined = controlOptions.find(o => o === value);
return {
element: (
<Popover
fill
position="bottom-left"
minimal
content={
<Menu>
{controlOptions.map((o, i) => (
<MenuItem
key={i}
text={getModuleOptionLabel(o, parameter)}
labelElement={
o === selectedOption ? <Icon icon={IconNames.TICK} /> : undefined
}
onClick={() => onValueChange(o)}
/>
))}
</Menu>
}
>
<InputGroup
value={
typeof selectedOption === 'undefined'
? `Unknown option: ${value}`
: getModuleOptionLabel(selectedOption, parameter)
}
readOnly
fill
rightElement={<Button icon={IconNames.CARET_DOWN} minimal />}
/>
</Popover>
),
};
}
case 'options': {
const controlOptions =
evaluateFunctor(parameter.options, parameterValues, querySource, where) || [];
return {
element: (
<OptionsInput
options={controlOptions}
value={(value as OptionValue[]) || []}
onValueChange={onValueChange}
optionLabel={o => getModuleOptionLabel(o, parameter)}
allowDuplicates={parameter.allowDuplicates}
nonEmpty={parameter.nonEmpty}
/>
),
};
}
case 'expression': {
const handleSelectColumn = (c: Column) => {
onValueChange(ExpressionMeta.fromColumn(c));
};
return {
element: (
<NamedExpressionsInput<ExpressionMeta>
allowReordering
values={value ? [value] : []}
onValuesChange={vs => onValueChange(vs[0])}
singleton
nonEmpty={required}
itemMenu={(initExpression, onClose) => (
<ExpressionMenu
columns={columns}
initExpression={initExpression}
onSelectExpression={onValueChange}
onClose={onClose}
onAddToSourceQueryAsColumn={onAddToSourceQueryAsColumn}
/>
)}
/>
),
onDropColumn: handleSelectColumn,
};
}
case 'expressions': {
const disabledColumnNames = parameter.allowDuplicates
? []
: filterMap(value as ExpressionMeta[], ({ expression }) =>
expression instanceof SqlColumn ? expression.getName() : undefined,
);
return {
element: (
<NamedExpressionsInput<ExpressionMeta>
allowReordering
values={value as ExpressionMeta[]}
onValuesChange={onValueChange}
nonEmpty={parameter.nonEmpty}
itemMenu={(initExpression, onClose) => (
<ExpressionMenu
columns={columns}
initExpression={initExpression}
onSelectExpression={c => onValueChange(changeOrAdd(value, initExpression, c))}
disabledColumnNames={disabledColumnNames}
onClose={onClose}
onAddToSourceQueryAsColumn={onAddToSourceQueryAsColumn}
/>
)}
/>
),
onDropColumn: (column: Column) => {
const columnName = column.name;
if (
!parameter.allowDuplicates &&
value.find((v: ExpressionMeta) => v.name === columnName)
) {
AppToaster.show({
intent: Intent.WARNING,
message: (
<>
<Tag minimal>{columnName}</Tag> already selected
</>
),
});
return;
}
onValueChange(value.concat(ExpressionMeta.fromColumn(column)));
},
};
}
case 'measure': {
return {
element: (
<NamedExpressionsInput<Measure>
values={value ? [value] : []}
onValuesChange={vs => onValueChange(vs[0])}
singleton
nonEmpty={required}
itemMenu={(initMeasure, onClose) => (
<MeasureMenu
columns={columns}
measures={measures}
initMeasure={initMeasure}
onSelectMeasure={onValueChange}
onClose={onClose}
onAddToSourceQueryAsMeasure={onAddToSourceQueryAsMeasure}
/>
)}
/>
),
onDropColumn: column => {
const candidateMeasures = Measure.getPossibleMeasuresForColumn(column).filter(
p => !value || value.name !== p.name,
);
if (!candidateMeasures.length) return;
onValueChange(candidateMeasures[0]);
},
onDropMeasure: onValueChange,
};
}
case 'measures': {
const disabledMeasureNames = parameter.allowDuplicates
? []
: filterMap(value as Measure[], measure => measure.getAggregateMeasureName());
return {
element: (
<NamedExpressionsInput<Measure>
values={value}
onValuesChange={onValueChange}
allowReordering
nonEmpty={parameter.nonEmpty}
itemMenu={(initMeasure, onClose) => (
<MeasureMenu
columns={columns}
measures={measures}
initMeasure={initMeasure}
disabledMeasureNames={disabledMeasureNames}
onSelectMeasure={m => onValueChange(changeOrAdd(value, initMeasure, m))}
onClose={onClose}
onAddToSourceQueryAsMeasure={onAddToSourceQueryAsMeasure}
/>
)}
/>
),
onDropColumn: column => {
const candidateMeasures = Measure.getPossibleMeasuresForColumn(column).filter(
p => !value.some((v: Measure) => v.name === p.name),
);
if (!candidateMeasures.length) return;
onValueChange(value.concat(candidateMeasures[0]));
},
onDropMeasure: measure => {
onValueChange(value.concat(measure));
},
};
}
default:
return {
element: (
<Button
icon={IconNames.ERROR}
text={`Type not supported: ${(parameter as { type: string }).type}`}
disabled
fill
/>
),
};
}
}
const namedParameters = Object.entries(parameters ?? {});
return (
<div className={classNames('control-pane', { compact })}>
{namedParameters.map(([name, parameter], i) => {
const visible = evaluateFunctor(parameter.visible, parameterValues, querySource, where);
const defined = evaluateFunctor(parameter.defined, parameterValues, querySource, where);
if (visible === false || defined === false) return;
if (compact && !parameter.important) return;
const value = parameterValues[name];
function onValueChange(newValue: unknown) {
onUpdateParameterValues({ [name]: newValue });
}
const { element, onDropColumn, onDropMeasure } = renderOptionsPropInput(
parameter,
value,
onValueChange,
);
const label =
evaluateFunctor(parameter.label, parameterValues, querySource, where) ||
AutoForm.makeLabelName(name);
const description = compact
? undefined
: evaluateFunctor(parameter.description, parameterValues, querySource, where);
const formGroup = (
<FormGroupWithInfo
key={i}
label={label}
inline={compact}
info={description && <PopoverText>{description}</PopoverText>}
>
{element}
</FormGroupWithInfo>
);
if (!onDropColumn) {
return formGroup;
}
return (
<DroppableContainer key={i} onDropColumn={onDropColumn} onDropMeasure={onDropMeasure}>
{formGroup}
</DroppableContainer>
);
})}
</div>
);
};