sql: extract frontend code into separate package (#81109)

* sql: extract frontend code into separate package

* updated package version
This commit is contained in:
Gábor Farkas
2024-01-26 11:38:29 +01:00
committed by GitHub
parent 2febbec758
commit 29e8a355cb
87 changed files with 187 additions and 104 deletions
@@ -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';