sql: extract frontend code into separate package (#81109)
* sql: extract frontend code into separate package * updated package version
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
import {
|
||||
AnyObject,
|
||||
BasicConfig,
|
||||
Config,
|
||||
JsonTree,
|
||||
Operator,
|
||||
Settings,
|
||||
SimpleField,
|
||||
SqlFormatOperator,
|
||||
Utils,
|
||||
ValueSource,
|
||||
Widgets,
|
||||
} from '@react-awesome-query-builder/ui';
|
||||
import { List } from 'immutable';
|
||||
import { isString } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import { dateTime, toOption } from '@grafana/data';
|
||||
import { Button, DateTimePicker, Input, Select } from '@grafana/ui';
|
||||
|
||||
const buttonLabels = {
|
||||
add: 'Add',
|
||||
remove: 'Remove',
|
||||
};
|
||||
|
||||
export const emptyInitTree: JsonTree = {
|
||||
id: Utils.uuid(),
|
||||
type: 'group',
|
||||
};
|
||||
|
||||
const TIME_FILTER = 'timeFilter';
|
||||
const macros = [TIME_FILTER];
|
||||
|
||||
// Widgets are the components rendered for each field type see the docs for more info
|
||||
// https://github.com/ukrbublik/react-awesome-query-builder/blob/master/CONFIG.adoc#configwidgets
|
||||
export const widgets: Widgets = {
|
||||
...BasicConfig.widgets,
|
||||
text: {
|
||||
...BasicConfig.widgets.text,
|
||||
factory: function TextInput(props) {
|
||||
return (
|
||||
<Input
|
||||
value={props?.value || ''}
|
||||
placeholder={props?.placeholder}
|
||||
onChange={(e) => props?.setValue(e.currentTarget.value)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
number: {
|
||||
...BasicConfig.widgets.number,
|
||||
factory: function NumberInput(props) {
|
||||
return (
|
||||
<Input
|
||||
value={props?.value}
|
||||
placeholder={props?.placeholder}
|
||||
type="number"
|
||||
onChange={(e) => props?.setValue(Number.parseInt(e.currentTarget.value, 10))}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
datetime: {
|
||||
...BasicConfig.widgets.datetime,
|
||||
factory: function DateTimeInput(props) {
|
||||
if (props?.operator === Op.MACROS) {
|
||||
return (
|
||||
<Select
|
||||
id={props.id}
|
||||
aria-label="Macros value selector"
|
||||
menuShouldPortal
|
||||
options={macros.map(toOption)}
|
||||
value={props?.value}
|
||||
onChange={(val) => props.setValue(val.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const dateValue = dateTime(props?.value).isValid() ? dateTime(props?.value).utc() : undefined;
|
||||
return (
|
||||
<DateTimePicker
|
||||
onChange={(e) => {
|
||||
props?.setValue(e.format(BasicConfig.widgets.datetime.valueFormat));
|
||||
}}
|
||||
date={dateValue}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Function for formatting widget’s value in SQL WHERE query.
|
||||
sqlFormatValue: (val, field, widget, operator, operatorDefinition, rightFieldDef) => {
|
||||
if (operator === Op.MACROS) {
|
||||
if (macros.includes(val)) {
|
||||
return val;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// This is just satisfying the type checker, this should never happen
|
||||
if (
|
||||
typeof BasicConfig.widgets.datetime.sqlFormatValue === 'string' ||
|
||||
typeof BasicConfig.widgets.datetime.sqlFormatValue === 'object'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const func = BasicConfig.widgets.datetime.sqlFormatValue;
|
||||
// We need to pass the ctx to this function this way so *this* is correct
|
||||
return func?.call(BasicConfig.ctx, val, field, widget, operator, operatorDefinition, rightFieldDef) || '';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Settings are the configuration options for the query builder see the docs for more info
|
||||
// https://github.com/ukrbublik/react-awesome-query-builder/blob/master/CONFIG.adoc#configsettings
|
||||
export const settings: Settings = {
|
||||
...BasicConfig.settings,
|
||||
canRegroup: false,
|
||||
maxNesting: 1,
|
||||
canReorder: false,
|
||||
showNot: false,
|
||||
addRuleLabel: buttonLabels.add,
|
||||
deleteLabel: buttonLabels.remove,
|
||||
// This is the component that renders conjunctions (logical operators)
|
||||
renderConjs: function Conjunctions(conjProps) {
|
||||
return (
|
||||
<Select
|
||||
id={conjProps?.id}
|
||||
aria-label="Conjunction"
|
||||
menuShouldPortal
|
||||
options={conjProps?.conjunctionOptions ? Object.keys(conjProps?.conjunctionOptions).map(toOption) : undefined}
|
||||
value={conjProps?.selectedConjunction}
|
||||
onChange={(val) => conjProps?.setConjunction(val.value!)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// This is the component that renders fields
|
||||
renderField: function Field(fieldProps) {
|
||||
const fields = fieldProps?.config?.fields || {};
|
||||
return (
|
||||
<Select
|
||||
id={fieldProps?.id}
|
||||
width={25}
|
||||
aria-label="Field"
|
||||
menuShouldPortal
|
||||
options={fieldProps?.items.map((f) => {
|
||||
// @ts-ignore
|
||||
const icon = fields[f.key].mainWidgetProps?.customProps?.icon;
|
||||
return {
|
||||
label: f.label,
|
||||
value: f.key,
|
||||
icon,
|
||||
};
|
||||
})}
|
||||
value={fieldProps?.selectedKey}
|
||||
onChange={(val) => {
|
||||
fieldProps?.setField(val.label!);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// This is the component used for the Add/Remove buttons
|
||||
renderButton: function RAQBButton(buttonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
title={`${buttonProps?.label} filter`}
|
||||
onClick={buttonProps?.onClick}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
icon={buttonProps?.label === buttonLabels.add ? 'plus' : 'times'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// This is the component used for the fields operator selector
|
||||
renderOperator: function Operator(operatorProps) {
|
||||
return (
|
||||
<Select
|
||||
options={operatorProps?.items.map((op) => ({ label: op.label, value: op.key }))}
|
||||
aria-label="Operator"
|
||||
menuShouldPortal
|
||||
value={operatorProps?.selectedKey}
|
||||
onChange={(val) => {
|
||||
operatorProps?.setField(val.value || '');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// add IN / NOT IN operators to text to support multi-value variables
|
||||
const enum Op {
|
||||
IN = 'select_any_in',
|
||||
NOT_IN = 'select_not_any_in',
|
||||
MACROS = 'macros',
|
||||
}
|
||||
const customOperators = getCustomOperators(BasicConfig);
|
||||
const textWidget = BasicConfig.types.text.widgets.text;
|
||||
const opers = [...(textWidget.operators || []), Op.IN, Op.NOT_IN];
|
||||
const customTextWidget = {
|
||||
...textWidget,
|
||||
operators: opers,
|
||||
};
|
||||
|
||||
const customTypes = {
|
||||
...BasicConfig.types,
|
||||
text: {
|
||||
...BasicConfig.types.text,
|
||||
widgets: {
|
||||
...BasicConfig.types.text.widgets,
|
||||
text: customTextWidget,
|
||||
},
|
||||
},
|
||||
datetime: {
|
||||
...BasicConfig.types.datetime,
|
||||
widgets: {
|
||||
...BasicConfig.types.datetime.widgets,
|
||||
datetime: {
|
||||
...BasicConfig.types.datetime.widgets.datetime,
|
||||
operators: [Op.MACROS, ...(BasicConfig.types.datetime.widgets.datetime.operators || [])],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// This is the configuration for the query builder that doesn't include the fields but all the other configuration for the UI
|
||||
// Fields should be added dynamically based on returned data
|
||||
// See the doc for more info https://github.com/ukrbublik/react-awesome-query-builder/blob/master/CONFIG.adoc
|
||||
export const raqbConfig: Config = {
|
||||
...BasicConfig,
|
||||
widgets,
|
||||
settings,
|
||||
operators: customOperators,
|
||||
types: customTypes,
|
||||
};
|
||||
|
||||
export type { Config };
|
||||
|
||||
const noop = () => '';
|
||||
|
||||
const isSqlFormatOp = (func: unknown): func is SqlFormatOperator => {
|
||||
return typeof func === 'function';
|
||||
};
|
||||
|
||||
function getCustomOperators(config: BasicConfig) {
|
||||
const { ...supportedOperators } = config.operators;
|
||||
|
||||
// IN operator expects array, override IN formatter for multi-value variables
|
||||
const sqlFormatInOpOrNoop = () => {
|
||||
const sqlFormatOp = supportedOperators[Op.IN].sqlFormatOp;
|
||||
if (isSqlFormatOp(sqlFormatOp)) {
|
||||
return sqlFormatOp;
|
||||
}
|
||||
return noop;
|
||||
};
|
||||
|
||||
const customSqlInFormatter = (
|
||||
field: string,
|
||||
op: string,
|
||||
value: string | List<string>,
|
||||
valueSrc: ValueSource,
|
||||
valueType: string,
|
||||
opDef: Operator,
|
||||
operatorOptions: AnyObject,
|
||||
fieldDef: SimpleField
|
||||
) => {
|
||||
return sqlFormatInOpOrNoop()(
|
||||
field,
|
||||
op,
|
||||
splitIfString(value),
|
||||
valueSrc,
|
||||
valueType,
|
||||
opDef,
|
||||
operatorOptions,
|
||||
fieldDef
|
||||
);
|
||||
};
|
||||
// NOT IN operator expects array, override NOT IN formatter for multi-value variables
|
||||
const sqlFormatNotInOpOrNoop = () => {
|
||||
const sqlFormatOp = supportedOperators[Op.NOT_IN].sqlFormatOp;
|
||||
if (isSqlFormatOp(sqlFormatOp)) {
|
||||
return sqlFormatOp;
|
||||
}
|
||||
return noop;
|
||||
};
|
||||
|
||||
const customSqlNotInFormatter = (
|
||||
field: string,
|
||||
op: string,
|
||||
value: string | List<string>,
|
||||
valueSrc: ValueSource,
|
||||
valueType: string,
|
||||
opDef: Operator,
|
||||
operatorOptions: AnyObject,
|
||||
fieldDef: SimpleField
|
||||
) => {
|
||||
return sqlFormatNotInOpOrNoop()(
|
||||
field,
|
||||
op,
|
||||
splitIfString(value),
|
||||
valueSrc,
|
||||
valueType,
|
||||
opDef,
|
||||
operatorOptions,
|
||||
fieldDef
|
||||
);
|
||||
};
|
||||
|
||||
const customOperators = {
|
||||
...supportedOperators,
|
||||
[Op.IN]: {
|
||||
...supportedOperators[Op.IN],
|
||||
sqlFormatOp: customSqlInFormatter,
|
||||
},
|
||||
[Op.NOT_IN]: {
|
||||
...supportedOperators[Op.NOT_IN],
|
||||
sqlFormatOp: customSqlNotInFormatter,
|
||||
},
|
||||
[Op.MACROS]: {
|
||||
label: 'Macros',
|
||||
sqlFormatOp: (field: string, _operator: string, value: string | List<string>) => {
|
||||
if (value === TIME_FILTER) {
|
||||
return `$__timeFilter(${field})`;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return customOperators;
|
||||
}
|
||||
|
||||
// value: string | List<string> but AQB uses a different version of Immutable
|
||||
function splitIfString(value: any) {
|
||||
if (isString(value)) {
|
||||
return value.split(',');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { SelectableValue, toOption } from '@grafana/data';
|
||||
import { AccessoryButton, EditorList, InputGroup } from '@grafana/experimental';
|
||||
import { Select } from '@grafana/ui';
|
||||
|
||||
import { QueryEditorGroupByExpression } from '../../expressions';
|
||||
import { SQLExpression } from '../../types';
|
||||
import { setGroupByField } from '../../utils/sql.utils';
|
||||
|
||||
interface GroupByRowProps {
|
||||
sql: SQLExpression;
|
||||
onSqlChange: (sql: SQLExpression) => void;
|
||||
columns?: Array<SelectableValue<string>>;
|
||||
}
|
||||
|
||||
export function GroupByRow({ sql, columns, onSqlChange }: GroupByRowProps) {
|
||||
const onGroupByChange = useCallback(
|
||||
(item: Array<Partial<QueryEditorGroupByExpression>>) => {
|
||||
// As new (empty object) items come in, we need to make sure they have the correct type
|
||||
const cleaned = item.map((v) => setGroupByField(v.property?.name));
|
||||
const newSql = { ...sql, groupBy: cleaned };
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
);
|
||||
|
||||
return (
|
||||
<EditorList
|
||||
items={sql.groupBy!}
|
||||
onChange={onGroupByChange}
|
||||
renderItem={makeRenderColumn({
|
||||
options: columns,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function makeRenderColumn({ options }: { options?: Array<SelectableValue<string>> }) {
|
||||
const renderColumn = function (
|
||||
item: Partial<QueryEditorGroupByExpression>,
|
||||
onChangeItem: (item: QueryEditorGroupByExpression) => void,
|
||||
onDeleteItem: () => void
|
||||
) {
|
||||
return (
|
||||
<InputGroup>
|
||||
<Select
|
||||
value={item.property?.name ? toOption(item.property.name) : null}
|
||||
aria-label="Group by"
|
||||
options={options}
|
||||
menuShouldPortal
|
||||
onChange={({ value }) => value && onChangeItem(setGroupByField(value))}
|
||||
/>
|
||||
<AccessoryButton aria-label="Remove group by column" icon="times" variant="secondary" onClick={onDeleteItem} />
|
||||
</InputGroup>
|
||||
);
|
||||
};
|
||||
return renderColumn;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { uniqueId } from 'lodash';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { SelectableValue, toOption } from '@grafana/data';
|
||||
import { EditorField, InputGroup } from '@grafana/experimental';
|
||||
import { Input, RadioButtonGroup, Select, Space } from '@grafana/ui';
|
||||
|
||||
import { SQLExpression } from '../../types';
|
||||
import { setPropertyField } from '../../utils/sql.utils';
|
||||
|
||||
type OrderByRowProps = {
|
||||
sql: SQLExpression;
|
||||
onSqlChange: (sql: SQLExpression) => void;
|
||||
columns?: Array<SelectableValue<string>>;
|
||||
showOffset?: boolean;
|
||||
};
|
||||
|
||||
const sortOrderOptions = [
|
||||
{ description: 'Sort by ascending', value: 'ASC', icon: 'sort-amount-up' } as const,
|
||||
{ description: 'Sort by descending', value: 'DESC', icon: 'sort-amount-down' } as const,
|
||||
];
|
||||
|
||||
export function OrderByRow({ sql, onSqlChange, columns, showOffset }: OrderByRowProps) {
|
||||
const onSortOrderChange = useCallback(
|
||||
(item: 'ASC' | 'DESC') => {
|
||||
const newSql: SQLExpression = { ...sql, orderByDirection: item };
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
);
|
||||
|
||||
const onLimitChange = useCallback(
|
||||
(event: React.FormEvent<HTMLInputElement>) => {
|
||||
const newSql: SQLExpression = { ...sql, limit: Number.parseInt(event.currentTarget.value, 10) };
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
);
|
||||
|
||||
const onOffsetChange = useCallback(
|
||||
(event: React.FormEvent<HTMLInputElement>) => {
|
||||
const newSql: SQLExpression = { ...sql, offset: Number.parseInt(event.currentTarget.value, 10) };
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
);
|
||||
|
||||
const onOrderByChange = useCallback(
|
||||
(item: SelectableValue<string>) => {
|
||||
const newSql: SQLExpression = { ...sql, orderBy: setPropertyField(item?.value) };
|
||||
if (item === null) {
|
||||
newSql.orderByDirection = undefined;
|
||||
}
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorField label="Order by" width={25}>
|
||||
<InputGroup>
|
||||
<Select
|
||||
aria-label="Order by"
|
||||
options={columns}
|
||||
value={sql.orderBy?.property.name ? toOption(sql.orderBy.property.name) : null}
|
||||
isClearable
|
||||
menuShouldPortal
|
||||
onChange={onOrderByChange}
|
||||
/>
|
||||
|
||||
<Space h={1.5} />
|
||||
|
||||
<RadioButtonGroup
|
||||
options={sortOrderOptions}
|
||||
disabled={!sql?.orderBy?.property.name}
|
||||
value={sql.orderByDirection}
|
||||
onChange={onSortOrderChange}
|
||||
/>
|
||||
</InputGroup>
|
||||
</EditorField>
|
||||
<EditorField label="Limit" optional width={25}>
|
||||
<Input type="number" min={0} id={uniqueId('limit-')} value={sql.limit || ''} onChange={onLimitChange} />
|
||||
</EditorField>
|
||||
{showOffset && (
|
||||
<EditorField label="Offset" optional width={25}>
|
||||
<Input type="number" id={uniqueId('offset-')} value={sql.offset || ''} onChange={onOffsetChange} />
|
||||
</EditorField>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { CodeEditor, Field, IconButton, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { formatSQL } from '../../utils/formatSQL';
|
||||
|
||||
type PreviewProps = {
|
||||
rawSql: string;
|
||||
datasourceType?: string;
|
||||
};
|
||||
|
||||
export function Preview({ rawSql, datasourceType }: PreviewProps) {
|
||||
// TODO: use zero index to give feedback about copy success
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const copyPreview = (rawSql: string) => {
|
||||
copyToClipboard(rawSql);
|
||||
reportInteraction('grafana_sql_preview_copied', {
|
||||
datasource: datasourceType,
|
||||
});
|
||||
};
|
||||
|
||||
const labelElement = (
|
||||
<div className={styles.labelWrapper}>
|
||||
<span className={styles.label}>Preview</span>
|
||||
<IconButton tooltip="Copy to clipboard" onClick={() => copyPreview(rawSql)} name="copy" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Field label={labelElement} className={styles.grow}>
|
||||
<CodeEditor
|
||||
language="sql"
|
||||
height={80}
|
||||
value={formatSQL(rawSql)}
|
||||
monacoOptions={{ scrollbar: { vertical: 'hidden' }, scrollBeyondLastLine: false }}
|
||||
readOnly={true}
|
||||
showMiniMap={false}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
grow: css({ flexGrow: 1 }),
|
||||
label: css({ fontSize: 12, fontWeight: theme.typography.fontWeightMedium }),
|
||||
labelWrapper: css({ display: 'flex', justifyContent: 'space-between', paddingBottom: theme.spacing(0.5) }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
import { QueryWithDefaults } from '../../defaults';
|
||||
import { DB, SQLQuery } from '../../types';
|
||||
import { useSqlChange } from '../../utils/useSqlChange';
|
||||
|
||||
import { GroupByRow } from './GroupByRow';
|
||||
|
||||
interface SQLGroupByRowProps {
|
||||
fields: SelectableValue[];
|
||||
query: QueryWithDefaults;
|
||||
onQueryChange: (query: SQLQuery) => void;
|
||||
db: DB;
|
||||
}
|
||||
|
||||
export function SQLGroupByRow({ fields, query, onQueryChange, db }: SQLGroupByRowProps) {
|
||||
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
|
||||
|
||||
return <GroupByRow columns={fields} sql={query.sql!} onSqlChange={onSqlChange} />;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
import { QueryWithDefaults } from '../../defaults';
|
||||
import { DB, SQLQuery } from '../../types';
|
||||
import { useSqlChange } from '../../utils/useSqlChange';
|
||||
|
||||
import { OrderByRow } from './OrderByRow';
|
||||
|
||||
type SQLOrderByRowProps = {
|
||||
fields: SelectableValue[];
|
||||
query: QueryWithDefaults;
|
||||
onQueryChange: (query: SQLQuery) => void;
|
||||
db: DB;
|
||||
};
|
||||
|
||||
export function SQLOrderByRow({ fields, query, onQueryChange, db }: SQLOrderByRowProps) {
|
||||
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
|
||||
let columnsWithIndices: SelectableValue[] = [];
|
||||
|
||||
if (fields) {
|
||||
const options = query.sql?.columns?.map((c, i) => {
|
||||
const value = c.name ? `${c.name}(${c.parameters?.map((p) => p.name)})` : c.parameters?.map((p) => p.name);
|
||||
return {
|
||||
value,
|
||||
label: `${i + 1} - ${value}`,
|
||||
};
|
||||
});
|
||||
columnsWithIndices = [
|
||||
{
|
||||
value: '',
|
||||
label: 'Selected columns',
|
||||
options,
|
||||
expanded: true,
|
||||
},
|
||||
...fields,
|
||||
];
|
||||
}
|
||||
|
||||
return <OrderByRow sql={query.sql!} onSqlChange={onSqlChange} columns={columnsWithIndices} />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
import { SelectableValue, toOption } from '@grafana/data';
|
||||
|
||||
import { COMMON_AGGREGATE_FNS } from '../../constants';
|
||||
import { QueryWithDefaults } from '../../defaults';
|
||||
import { DB, SQLQuery } from '../../types';
|
||||
import { useSqlChange } from '../../utils/useSqlChange';
|
||||
|
||||
import { SelectRow } from './SelectRow';
|
||||
|
||||
interface SQLSelectRowProps {
|
||||
fields: SelectableValue[];
|
||||
query: QueryWithDefaults;
|
||||
onQueryChange: (query: SQLQuery) => void;
|
||||
db: DB;
|
||||
}
|
||||
|
||||
export function SQLSelectRow({ fields, query, onQueryChange, db }: SQLSelectRowProps) {
|
||||
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
|
||||
const functions = [...COMMON_AGGREGATE_FNS, ...(db.functions?.() || [])].map(toOption);
|
||||
|
||||
return (
|
||||
<SelectRow
|
||||
columns={fields}
|
||||
sql={query.sql!}
|
||||
format={query.format}
|
||||
functions={functions}
|
||||
onSqlChange={onSqlChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { SelectableValue, VariableWithMultiSupport } from '@grafana/data';
|
||||
import { getTemplateSrv } from '@grafana/runtime';
|
||||
|
||||
import { QueryWithDefaults } from '../../defaults';
|
||||
import { DB, SQLExpression, SQLQuery, SQLSelectableValue } from '../../types';
|
||||
import { useSqlChange } from '../../utils/useSqlChange';
|
||||
|
||||
import { Config } from './AwesomeQueryBuilder';
|
||||
import { WhereRow } from './WhereRow';
|
||||
|
||||
interface WhereRowProps {
|
||||
query: QueryWithDefaults;
|
||||
fields: SelectableValue[];
|
||||
onQueryChange: (query: SQLQuery) => void;
|
||||
db: DB;
|
||||
}
|
||||
|
||||
export function SQLWhereRow({ query, fields, onQueryChange, db }: WhereRowProps) {
|
||||
const state = useAsync(async () => {
|
||||
return mapFieldsToTypes(fields);
|
||||
}, [fields]);
|
||||
|
||||
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
|
||||
|
||||
return (
|
||||
<WhereRow
|
||||
// TODO: fix key that's used to force clean render or SQLWhereRow - otherwise it doesn't render operators correctly
|
||||
key={JSON.stringify(state.value)}
|
||||
config={{ fields: state.value || {} }}
|
||||
sql={query.sql!}
|
||||
onSqlChange={(val: SQLExpression) => {
|
||||
const templateVars = getTemplateSrv().getVariables() as VariableWithMultiSupport[];
|
||||
removeQuotesForMultiVariables(val, templateVars);
|
||||
|
||||
onSqlChange(val);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// needed for awesome query builder
|
||||
function mapFieldsToTypes(columns: SQLSelectableValue[]) {
|
||||
const fields: Config['fields'] = {};
|
||||
for (const col of columns) {
|
||||
fields[col.value] = {
|
||||
type: col.raqbFieldType || 'text',
|
||||
valueSources: ['value'],
|
||||
mainWidgetProps: { customProps: { icon: col.icon } },
|
||||
};
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function removeQuotesForMultiVariables(val: SQLExpression, templateVars: VariableWithMultiSupport[]) {
|
||||
const multiVariableInWhereString = (tv: VariableWithMultiSupport) =>
|
||||
tv.multi && (val.whereString?.includes(`\${${tv.name}}`) || val.whereString?.includes(`$${tv.name}`));
|
||||
|
||||
if (templateVars.some((tv) => multiVariableInWhereString(tv))) {
|
||||
val.whereString = val.whereString?.replaceAll("')", ')');
|
||||
val.whereString = val.whereString?.replaceAll("('", '(');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { uniqueId } from 'lodash';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { SelectableValue, toOption } from '@grafana/data';
|
||||
import { EditorField, Stack } from '@grafana/experimental';
|
||||
import { Button, Select, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { QueryEditorExpressionType, QueryEditorFunctionExpression } from '../../expressions';
|
||||
import { SQLExpression, QueryFormat } from '../../types';
|
||||
import { createFunctionField } from '../../utils/sql.utils';
|
||||
|
||||
interface SelectRowProps {
|
||||
sql: SQLExpression;
|
||||
format: QueryFormat | undefined;
|
||||
onSqlChange: (sql: SQLExpression) => void;
|
||||
columns?: Array<SelectableValue<string>>;
|
||||
functions?: Array<SelectableValue<string>>;
|
||||
}
|
||||
|
||||
const asteriskValue = { label: '*', value: '*' };
|
||||
|
||||
export function SelectRow({ sql, format, columns, onSqlChange, functions }: SelectRowProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const columnsWithAsterisk = [asteriskValue, ...(columns || [])];
|
||||
const timeSeriesAliasOpts: Array<SelectableValue<string>> = [];
|
||||
|
||||
// Add necessary alias options for time series format
|
||||
// when that format has been selected
|
||||
if (format === QueryFormat.Timeseries) {
|
||||
timeSeriesAliasOpts.push({ label: 'time', value: 'time' });
|
||||
timeSeriesAliasOpts.push({ label: 'value', value: 'value' });
|
||||
}
|
||||
|
||||
const onColumnChange = useCallback(
|
||||
(item: QueryEditorFunctionExpression, index: number) => (column: SelectableValue<string>) => {
|
||||
let modifiedItem = { ...item };
|
||||
if (!item.parameters?.length) {
|
||||
modifiedItem.parameters = [{ type: QueryEditorExpressionType.FunctionParameter, name: column.value } as const];
|
||||
} else {
|
||||
modifiedItem.parameters = item.parameters.map((p) =>
|
||||
p.type === QueryEditorExpressionType.FunctionParameter ? { ...p, name: column.value } : p
|
||||
);
|
||||
}
|
||||
|
||||
const newSql: SQLExpression = {
|
||||
...sql,
|
||||
columns: sql.columns?.map((c, i) => (i === index ? modifiedItem : c)),
|
||||
};
|
||||
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
);
|
||||
|
||||
const onAggregationChange = useCallback(
|
||||
(item: QueryEditorFunctionExpression, index: number) => (aggregation: SelectableValue<string>) => {
|
||||
const newItem = {
|
||||
...item,
|
||||
name: aggregation?.value,
|
||||
};
|
||||
const newSql: SQLExpression = {
|
||||
...sql,
|
||||
columns: sql.columns?.map((c, i) => (i === index ? newItem : c)),
|
||||
};
|
||||
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
);
|
||||
|
||||
const onAliasChange = useCallback(
|
||||
(item: QueryEditorFunctionExpression, index: number) => (alias: SelectableValue<string>) => {
|
||||
let newItem = { ...item };
|
||||
|
||||
if (alias !== null) {
|
||||
newItem = { ...item, alias: `"${alias?.value?.trim()}"` };
|
||||
} else {
|
||||
delete newItem.alias;
|
||||
}
|
||||
|
||||
const newSql: SQLExpression = {
|
||||
...sql,
|
||||
columns: sql.columns?.map((c, i) => (i === index ? newItem : c)),
|
||||
};
|
||||
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
);
|
||||
|
||||
const removeColumn = useCallback(
|
||||
(index: number) => () => {
|
||||
const clone = [...sql.columns!];
|
||||
clone.splice(index, 1);
|
||||
const newSql: SQLExpression = {
|
||||
...sql,
|
||||
columns: clone,
|
||||
};
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
);
|
||||
|
||||
const addColumn = useCallback(() => {
|
||||
const newSql: SQLExpression = { ...sql, columns: [...sql.columns!, createFunctionField()] };
|
||||
onSqlChange(newSql);
|
||||
}, [onSqlChange, sql]);
|
||||
|
||||
return (
|
||||
<Stack gap={2} wrap direction="column">
|
||||
{sql.columns?.map((item, index) => (
|
||||
<div key={index}>
|
||||
<Stack gap={2} alignItems="end">
|
||||
<EditorField label="Column" width={25}>
|
||||
<Select
|
||||
value={getColumnValue(item)}
|
||||
options={columnsWithAsterisk}
|
||||
inputId={`select-column-${index}-${uniqueId()}`}
|
||||
menuShouldPortal
|
||||
allowCustomValue
|
||||
onChange={onColumnChange(item, index)}
|
||||
/>
|
||||
</EditorField>
|
||||
|
||||
<EditorField label="Aggregation" optional width={25}>
|
||||
<Select
|
||||
value={item.name ? toOption(item.name) : null}
|
||||
inputId={`select-aggregation-${index}-${uniqueId()}`}
|
||||
isClearable
|
||||
menuShouldPortal
|
||||
allowCustomValue
|
||||
options={functions}
|
||||
onChange={onAggregationChange(item, index)}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label="Alias" optional width={15}>
|
||||
<Select
|
||||
value={item.alias ? toOption(item.alias) : null}
|
||||
inputId={`select-alias-${index}-${uniqueId()}`}
|
||||
options={timeSeriesAliasOpts}
|
||||
onChange={onAliasChange(item, index)}
|
||||
isClearable
|
||||
menuShouldPortal
|
||||
allowCustomValue
|
||||
/>
|
||||
</EditorField>
|
||||
<Button
|
||||
aria-label="Remove"
|
||||
type="button"
|
||||
icon="trash-alt"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
onClick={removeColumn(index)}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addColumn}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
icon="plus"
|
||||
aria-label="Add"
|
||||
className={styles.addButton}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = () => {
|
||||
return { addButton: css({ alignSelf: 'flex-start' }) };
|
||||
};
|
||||
|
||||
function getColumnValue({ parameters }: QueryEditorFunctionExpression): SelectableValue<string> | null {
|
||||
const column = parameters?.find((p) => p.type === QueryEditorExpressionType.FunctionParameter);
|
||||
if (column?.name) {
|
||||
return toOption(column.name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { EditorRows, EditorRow, EditorField } from '@grafana/experimental';
|
||||
|
||||
import { DB, QueryEditorProps, QueryRowFilter } from '../../types';
|
||||
import { QueryToolbox } from '../query-editor-raw/QueryToolbox';
|
||||
|
||||
import { Preview } from './Preview';
|
||||
import { SQLGroupByRow } from './SQLGroupByRow';
|
||||
import { SQLOrderByRow } from './SQLOrderByRow';
|
||||
import { SQLSelectRow } from './SQLSelectRow';
|
||||
import { SQLWhereRow } from './SQLWhereRow';
|
||||
|
||||
interface VisualEditorProps extends QueryEditorProps {
|
||||
db: DB;
|
||||
queryRowFilter: QueryRowFilter;
|
||||
onValidate: (isValid: boolean) => void;
|
||||
}
|
||||
|
||||
export const VisualEditor = ({ query, db, queryRowFilter, onChange, onValidate, range }: VisualEditorProps) => {
|
||||
const state = useAsync(async () => {
|
||||
const fields = await db.fields(query);
|
||||
return fields;
|
||||
}, [db, query.dataset, query.table]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorRows>
|
||||
<EditorRow>
|
||||
<SQLSelectRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
|
||||
</EditorRow>
|
||||
{queryRowFilter.filter && (
|
||||
<EditorRow>
|
||||
<EditorField label="Filter by column value" optional>
|
||||
<SQLWhereRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
|
||||
</EditorField>
|
||||
</EditorRow>
|
||||
)}
|
||||
{queryRowFilter.group && (
|
||||
<EditorRow>
|
||||
<EditorField label="Group by column">
|
||||
<SQLGroupByRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
|
||||
</EditorField>
|
||||
</EditorRow>
|
||||
)}
|
||||
{queryRowFilter.order && (
|
||||
<EditorRow>
|
||||
<SQLOrderByRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
|
||||
</EditorRow>
|
||||
)}
|
||||
{queryRowFilter.preview && query.rawSql && (
|
||||
<EditorRow>
|
||||
<Preview rawSql={query.rawSql} datasourceType={query.datasource?.type} />
|
||||
</EditorRow>
|
||||
)}
|
||||
</EditorRows>
|
||||
<QueryToolbox db={db} query={query} onValidate={onValidate} range={range} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { injectGlobal } from '@emotion/css';
|
||||
import { Builder, Config, ImmutableTree, Query, Utils } from '@react-awesome-query-builder/ui';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { SQLExpression } from '../../types';
|
||||
|
||||
import { emptyInitTree, raqbConfig } from './AwesomeQueryBuilder';
|
||||
|
||||
interface SQLBuilderWhereRowProps {
|
||||
sql: SQLExpression;
|
||||
onSqlChange: (sql: SQLExpression) => void;
|
||||
config?: Partial<Config>;
|
||||
}
|
||||
|
||||
export function WhereRow({ sql, config, onSqlChange }: SQLBuilderWhereRowProps) {
|
||||
const [tree, setTree] = useState<ImmutableTree>();
|
||||
const configWithDefaults = useMemo(() => ({ ...raqbConfig, ...config }), [config]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set the initial tree
|
||||
if (!tree) {
|
||||
const initTree = Utils.checkTree(Utils.loadTree(sql.whereJsonTree ?? emptyInitTree), configWithDefaults);
|
||||
setTree(initTree);
|
||||
}
|
||||
}, [configWithDefaults, sql.whereJsonTree, tree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sql.whereJsonTree) {
|
||||
setTree(Utils.checkTree(Utils.loadTree(emptyInitTree), configWithDefaults));
|
||||
}
|
||||
}, [configWithDefaults, sql.whereJsonTree]);
|
||||
|
||||
const onTreeChange = useCallback(
|
||||
(changedTree: ImmutableTree, config: Config) => {
|
||||
setTree(changedTree);
|
||||
const newSql = {
|
||||
...sql,
|
||||
whereJsonTree: Utils.getTree(changedTree),
|
||||
whereString: Utils.sqlFormat(changedTree, config),
|
||||
};
|
||||
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
);
|
||||
|
||||
if (!tree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Query
|
||||
{...configWithDefaults}
|
||||
value={tree}
|
||||
onChange={onTreeChange}
|
||||
renderBuilder={(props) => <Builder {...props} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function flex(direction: string) {
|
||||
return `
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: ${direction};`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
injectGlobal`
|
||||
.group--header {
|
||||
${flex('row')}
|
||||
}
|
||||
|
||||
.group-or-rule {
|
||||
${flex('column')}
|
||||
.rule {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.rule--body {
|
||||
${flex('row')}
|
||||
}
|
||||
|
||||
.group--children {
|
||||
${flex('column')}
|
||||
}
|
||||
|
||||
.group--conjunctions:empty {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export { GroupByRow } from './GroupByRow';
|
||||
Reference in New Issue
Block a user