mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[ES|QL] Refactor and improve the variables configuration (#216469)
## Summary Part of https://github.com/elastic/kibana/issues/213877 This PR doent introduce a new feature. It is mostly a refactoring to allow us to give a better UX when a user during creation changes the variable name from ??value to ?value and vice versa. The biggest change is that we move 2 components from the individual form (value, identifier) to index as now they can be shared and they own the same functionality regardless the control type. It is required to move to the next step, the creation of controls by just typing a questionmark ### Checklist - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
This commit is contained in:
parent
5bab6cf836
commit
94f0e694e4
12 changed files with 568 additions and 520 deletions
|
@ -581,6 +581,24 @@ describe('esql query helpers', () => {
|
|||
} as monaco.Position);
|
||||
expect(values).toEqual('my_field');
|
||||
});
|
||||
|
||||
it('should return undefined if no column is found', () => {
|
||||
const queryString = 'FROM my_index | STATS COUNT() ';
|
||||
const values = getValuesFromQueryField(queryString, {
|
||||
lineNumber: 1,
|
||||
column: 31,
|
||||
} as monaco.Position);
|
||||
expect(values).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('should return undefined if the column is *', () => {
|
||||
const queryString = 'FROM my_index | STATS COUNT(*) ';
|
||||
const values = getValuesFromQueryField(queryString, {
|
||||
lineNumber: 1,
|
||||
column: 31,
|
||||
} as monaco.Position);
|
||||
expect(values).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixESQLQueryWithVariables', () => {
|
||||
|
|
|
@ -224,7 +224,7 @@ export const getValuesFromQueryField = (queryString: string, cursorPosition?: mo
|
|||
|
||||
const column = Walker.match(lastCommand, { type: 'column' });
|
||||
|
||||
if (column) {
|
||||
if (column && column.name && column.name !== '*') {
|
||||
return `${column.name}`;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -7,12 +7,14 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
import { monaco } from '@kbn/monaco';
|
||||
import { ESQLControlVariable, ESQLVariableType } from '@kbn/esql-types';
|
||||
import {
|
||||
updateQueryStringWithVariable,
|
||||
getQueryForFields,
|
||||
areValuesIntervalsValid,
|
||||
getRecurrentVariableName,
|
||||
validateVariableName,
|
||||
checkVariableExistence,
|
||||
} from './helpers';
|
||||
|
||||
describe('helpers', () => {
|
||||
|
@ -114,4 +116,27 @@ describe('helpers', () => {
|
|||
expect(variable).toBe('??my_variable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkVariableExistence', () => {
|
||||
it('should return true if the variable exists', () => {
|
||||
const variables = [
|
||||
{ key: 'my_variable', type: ESQLVariableType.VALUES, value: 'value1' },
|
||||
{ key: 'my_variable2', type: ESQLVariableType.FIELDS, value: 'value2' },
|
||||
] as ESQLControlVariable[];
|
||||
const variableName = '?my_variable';
|
||||
const exists = checkVariableExistence(variables, variableName);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the variable does not exist', () => {
|
||||
const variables = [
|
||||
{ key: 'my_variable', type: ESQLVariableType.VALUES, value: 'value1' },
|
||||
{ key: 'my_variable2', type: ESQLVariableType.FIELDS, value: 'value2' },
|
||||
] as ESQLControlVariable[];
|
||||
// here ?variable2 is different from ??variable2
|
||||
const variableName = '?my_variable2';
|
||||
const exists = checkVariableExistence(variables, variableName);
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,8 +7,9 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
import { monaco } from '@kbn/monaco';
|
||||
import { ESQLVariableType } from '@kbn/esql-types';
|
||||
import { ESQLVariableType, ESQLControlVariable } from '@kbn/esql-types';
|
||||
import { timeUnits } from '@kbn/esql-validation-autocomplete';
|
||||
import { VariableNamePrefix } from '../types';
|
||||
|
||||
function inKnownTimeInterval(timeIntervalUnit: string): boolean {
|
||||
return timeUnits.some((unit) => unit === timeIntervalUnit.toLowerCase());
|
||||
|
@ -65,7 +66,7 @@ export const areValuesIntervalsValid = (values: string[]) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const getVariablePrefix = (variableType: ESQLVariableType) => {
|
||||
export const getVariableSuggestion = (variableType: ESQLVariableType) => {
|
||||
switch (variableType) {
|
||||
case ESQLVariableType.FIELDS:
|
||||
return 'field';
|
||||
|
@ -147,3 +148,41 @@ export const getVariableTypeFromQuery = (str: string, variableType: ESQLVariable
|
|||
|
||||
return variableType;
|
||||
};
|
||||
|
||||
export const getVariableNamePrefix = (type: ESQLVariableType) => {
|
||||
switch (type) {
|
||||
case ESQLVariableType.FIELDS:
|
||||
case ESQLVariableType.FUNCTIONS:
|
||||
return VariableNamePrefix.IDENTIFIER;
|
||||
case ESQLVariableType.VALUES:
|
||||
case ESQLVariableType.TIME_LITERAL:
|
||||
default:
|
||||
return VariableNamePrefix.VALUE;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkVariableExistence = (
|
||||
esqlVariables: ESQLControlVariable[],
|
||||
variableName: string
|
||||
): boolean => {
|
||||
const variableNameWithoutQuestionmark = variableName.replace(/^\?+/, '');
|
||||
const match = variableName.match(/^(\?*)/);
|
||||
const leadingQuestionMarksCount = match ? match[0].length : 0;
|
||||
|
||||
return esqlVariables.some((variable) => {
|
||||
const prefix = getVariableNamePrefix(variable.type);
|
||||
if (leadingQuestionMarksCount === 2) {
|
||||
if (prefix === VariableNamePrefix.IDENTIFIER) {
|
||||
return variable.key === variableNameWithoutQuestionmark;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (leadingQuestionMarksCount === 1) {
|
||||
if (prefix === VariableNamePrefix.VALUE) {
|
||||
return variable.key === variableNameWithoutQuestionmark;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
|
|
@ -13,11 +13,12 @@ import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
|||
import { monaco } from '@kbn/monaco';
|
||||
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
|
||||
import { ESQLVariableType } from '@kbn/esql-types';
|
||||
import { IdentifierControlForm } from './identifier_control_form';
|
||||
import { ESQLControlsFlyout } from '.';
|
||||
import { ESQLControlState, EsqlControlType } from '../types';
|
||||
|
||||
jest.mock('@kbn/esql-utils', () => ({
|
||||
getESQLQueryColumnsRaw: jest.fn().mockResolvedValue([{ name: 'column1' }, { name: 'column2' }]),
|
||||
getValuesFromQueryField: jest.fn().mockReturnValue('field'),
|
||||
}));
|
||||
|
||||
describe('IdentifierControlForm', () => {
|
||||
|
@ -32,12 +33,12 @@ describe('IdentifierControlForm', () => {
|
|||
it('should default correctly if no initial state is given', async () => {
|
||||
const { findByTestId, findByTitle } = render(
|
||||
<IntlProvider locale="en">
|
||||
<IdentifierControlForm
|
||||
variableType={ESQLVariableType.FIELDS}
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.FIELDS}
|
||||
queryString="FROM foo | STATS BY"
|
||||
onCreateControl={jest.fn()}
|
||||
onSaveControl={jest.fn()}
|
||||
closeFlyout={jest.fn()}
|
||||
onEditControl={jest.fn()}
|
||||
onCancelControl={jest.fn()}
|
||||
search={searchMock}
|
||||
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
|
||||
esqlVariables={[]}
|
||||
|
@ -78,16 +79,45 @@ describe('IdentifierControlForm', () => {
|
|||
expect(growSwitch).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should be able to change in value type', async () => {
|
||||
const { findByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.FIELDS}
|
||||
queryString="FROM foo | STATS BY"
|
||||
onSaveControl={jest.fn()}
|
||||
closeFlyout={jest.fn()}
|
||||
onCancelControl={jest.fn()}
|
||||
search={searchMock}
|
||||
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
|
||||
esqlVariables={[]}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
// variable name input should be rendered and with the default value
|
||||
expect(await findByTestId('esqlVariableName')).toHaveValue('??field');
|
||||
// change the variable name to ?value
|
||||
const variableNameInput = await findByTestId('esqlVariableName');
|
||||
fireEvent.change(variableNameInput, { target: { value: '?value' } });
|
||||
|
||||
expect(await findByTestId('esqlControlTypeDropdown')).toBeInTheDocument();
|
||||
const controlTypeInputPopover = await findByTestId('esqlControlTypeInputPopover');
|
||||
expect(within(controlTypeInputPopover).getByRole('combobox')).toHaveValue(`Static values`);
|
||||
// values dropdown should be rendered
|
||||
const valuesOptionsDropdown = await findByTestId('esqlValuesOptions');
|
||||
expect(valuesOptionsDropdown).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call the onCreateControl callback, if no initialState is given', async () => {
|
||||
const onCreateControlSpy = jest.fn();
|
||||
const { findByTestId, findByTitle } = render(
|
||||
<IntlProvider locale="en">
|
||||
<IdentifierControlForm
|
||||
variableType={ESQLVariableType.FIELDS}
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.FIELDS}
|
||||
queryString="FROM foo | STATS BY"
|
||||
onCreateControl={onCreateControlSpy}
|
||||
onSaveControl={onCreateControlSpy}
|
||||
closeFlyout={jest.fn()}
|
||||
onEditControl={jest.fn()}
|
||||
onCancelControl={jest.fn()}
|
||||
search={searchMock}
|
||||
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
|
||||
esqlVariables={[]}
|
||||
|
@ -110,13 +140,12 @@ describe('IdentifierControlForm', () => {
|
|||
const onCancelControlSpy = jest.fn();
|
||||
const { findByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
<IdentifierControlForm
|
||||
variableType={ESQLVariableType.FIELDS}
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.FIELDS}
|
||||
queryString="FROM foo | STATS BY"
|
||||
onCreateControl={jest.fn()}
|
||||
onSaveControl={jest.fn()}
|
||||
onCancelControl={onCancelControlSpy}
|
||||
closeFlyout={jest.fn()}
|
||||
onEditControl={jest.fn()}
|
||||
search={searchMock}
|
||||
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
|
||||
esqlVariables={[]}
|
||||
|
@ -142,12 +171,12 @@ describe('IdentifierControlForm', () => {
|
|||
} as ESQLControlState;
|
||||
const { findByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
<IdentifierControlForm
|
||||
variableType={ESQLVariableType.FIELDS}
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.FIELDS}
|
||||
queryString="FROM foo | STATS BY"
|
||||
onCreateControl={jest.fn()}
|
||||
onSaveControl={jest.fn()}
|
||||
closeFlyout={jest.fn()}
|
||||
onEditControl={jest.fn()}
|
||||
onCancelControl={jest.fn()}
|
||||
search={searchMock}
|
||||
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
|
||||
initialState={initialState}
|
||||
|
@ -194,12 +223,12 @@ describe('IdentifierControlForm', () => {
|
|||
const onEditControlSpy = jest.fn();
|
||||
const { findByTestId, findByTitle } = render(
|
||||
<IntlProvider locale="en">
|
||||
<IdentifierControlForm
|
||||
variableType={ESQLVariableType.FIELDS}
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.FIELDS}
|
||||
queryString="FROM foo | STATS BY"
|
||||
onCreateControl={jest.fn()}
|
||||
onSaveControl={onEditControlSpy}
|
||||
closeFlyout={jest.fn()}
|
||||
onEditControl={onEditControlSpy}
|
||||
onCancelControl={jest.fn()}
|
||||
search={searchMock}
|
||||
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
|
||||
initialState={initialState}
|
||||
|
@ -224,12 +253,12 @@ describe('IdentifierControlForm', () => {
|
|||
it('should default correctly if no initial state is given', async () => {
|
||||
const { findByTestId, findByTitle } = render(
|
||||
<IntlProvider locale="en">
|
||||
<IdentifierControlForm
|
||||
variableType={ESQLVariableType.FUNCTIONS}
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.FUNCTIONS}
|
||||
queryString="FROM foo | STATS "
|
||||
onCreateControl={jest.fn()}
|
||||
onSaveControl={jest.fn()}
|
||||
closeFlyout={jest.fn()}
|
||||
onEditControl={jest.fn()}
|
||||
onCancelControl={jest.fn()}
|
||||
search={searchMock}
|
||||
cursorPosition={{ column: 17, lineNumber: 1 } as monaco.Position}
|
||||
esqlVariables={[]}
|
||||
|
|
|
@ -7,86 +7,46 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import React, { useCallback, useState, useEffect } from 'react';
|
||||
import useMountedState from 'react-use/lib/useMountedState';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { isEqual } from 'lodash';
|
||||
import {
|
||||
EuiComboBox,
|
||||
EuiFormRow,
|
||||
EuiFlyoutBody,
|
||||
type EuiSwitchEvent,
|
||||
type EuiComboBoxOptionOption,
|
||||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { monaco } from '@kbn/monaco';
|
||||
import type { ISearchGeneric } from '@kbn/search-types';
|
||||
import { ESQLVariableType, type ESQLControlVariable } from '@kbn/esql-types';
|
||||
import { ESQLVariableType } from '@kbn/esql-types';
|
||||
import { aggFunctionDefinitions } from '@kbn/esql-validation-autocomplete';
|
||||
import { getESQLQueryColumnsRaw } from '@kbn/esql-utils';
|
||||
import type { ESQLControlState, ControlWidthOptions } from '../types';
|
||||
import {
|
||||
Header,
|
||||
Footer,
|
||||
ControlWidth,
|
||||
ControlType,
|
||||
VariableName,
|
||||
ControlLabel,
|
||||
} from './shared_form_components';
|
||||
import {
|
||||
getRecurrentVariableName,
|
||||
getFlyoutStyling,
|
||||
getQueryForFields,
|
||||
validateVariableName,
|
||||
getVariablePrefix,
|
||||
getVariableTypeFromQuery,
|
||||
} from './helpers';
|
||||
import { ControlWidth, ControlLabel } from './shared_form_components';
|
||||
import { getQueryForFields } from './helpers';
|
||||
import { EsqlControlType } from '../types';
|
||||
|
||||
interface IdentifierControlFormProps {
|
||||
search: ISearchGeneric;
|
||||
variableType: ESQLVariableType;
|
||||
variableName: string;
|
||||
queryString: string;
|
||||
esqlVariables: ESQLControlVariable[];
|
||||
closeFlyout: () => void;
|
||||
onCreateControl: (state: ESQLControlState, variableName: string) => void;
|
||||
onEditControl: (state: ESQLControlState) => void;
|
||||
setControlState: (state: ESQLControlState) => void;
|
||||
cursorPosition?: monaco.Position;
|
||||
initialState?: ESQLControlState;
|
||||
onCancelControl?: () => void;
|
||||
}
|
||||
|
||||
const IDENTIFIER_VARIABLE_PREFIX = '??';
|
||||
|
||||
export function IdentifierControlForm({
|
||||
variableType,
|
||||
variableName,
|
||||
initialState,
|
||||
queryString,
|
||||
esqlVariables,
|
||||
cursorPosition,
|
||||
onCreateControl,
|
||||
onEditControl,
|
||||
onCancelControl,
|
||||
setControlState,
|
||||
search,
|
||||
closeFlyout,
|
||||
}: IdentifierControlFormProps) {
|
||||
const isMounted = useMountedState();
|
||||
const suggestedVariableName = useMemo(() => {
|
||||
const existingVariables = new Set(
|
||||
esqlVariables
|
||||
.filter((variable) => variable.type === variableType)
|
||||
.map((variable) => variable.key)
|
||||
);
|
||||
|
||||
if (initialState) {
|
||||
return `${IDENTIFIER_VARIABLE_PREFIX}${initialState.variableName}`;
|
||||
}
|
||||
|
||||
const variablePrefix = getVariablePrefix(variableType);
|
||||
return `${IDENTIFIER_VARIABLE_PREFIX}${getRecurrentVariableName(
|
||||
variablePrefix,
|
||||
existingVariables
|
||||
)}`;
|
||||
}, [esqlVariables, initialState, variableType]);
|
||||
|
||||
const [availableIdentifiersOptions, setAvailableIdentifiersOptions] = useState<
|
||||
EuiComboBoxOptionOption[]
|
||||
|
@ -103,14 +63,10 @@ export function IdentifierControlForm({
|
|||
})
|
||||
: []
|
||||
);
|
||||
const [formIsInvalid, setFormIsInvalid] = useState(false);
|
||||
const [variableName, setVariableName] = useState(suggestedVariableName);
|
||||
const [label, setLabel] = useState(initialState?.title ?? '');
|
||||
const [minimumWidth, setMinimumWidth] = useState(initialState?.width ?? 'medium');
|
||||
const [grow, setGrow] = useState(initialState?.grow ?? false);
|
||||
|
||||
const isControlInEditMode = useMemo(() => !!initialState, [initialState]);
|
||||
|
||||
useEffect(
|
||||
function initAvailableIdentifiersOptions() {
|
||||
if (availableIdentifiersOptions.length > 0) return;
|
||||
|
@ -154,27 +110,10 @@ export function IdentifierControlForm({
|
|||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const variableExists =
|
||||
esqlVariables.some(
|
||||
(variable) => variable.key === variableName.replace(IDENTIFIER_VARIABLE_PREFIX, '')
|
||||
) && !isControlInEditMode;
|
||||
|
||||
setFormIsInvalid(!selectedIdentifiers.length || !variableName || variableExists);
|
||||
}, [esqlVariables, isControlInEditMode, selectedIdentifiers.length, variableName]);
|
||||
|
||||
const onIdentifiersChange = useCallback((selectedOptions: EuiComboBoxOptionOption[]) => {
|
||||
setSelectedIdentifiers(selectedOptions);
|
||||
}, []);
|
||||
|
||||
const onVariableNameChange = useCallback(
|
||||
(e: { target: { value: React.SetStateAction<string> } }) => {
|
||||
const text = validateVariableName(String(e.target.value), IDENTIFIER_VARIABLE_PREFIX);
|
||||
setVariableName(text);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onLabelChange = useCallback((e: { target: { value: React.SetStateAction<string> } }) => {
|
||||
setLabel(e.target.value);
|
||||
}, []);
|
||||
|
@ -216,8 +155,8 @@ export function IdentifierControlForm({
|
|||
[]
|
||||
);
|
||||
|
||||
const onCreateFieldControl = useCallback(async () => {
|
||||
const availableOptions = selectedIdentifiers.map((field) => field.label);
|
||||
useEffect(() => {
|
||||
const availableOptions = selectedIdentifiers.map((value) => value.label);
|
||||
// removes the double question mark from the variable name
|
||||
const variableNameWithoutQuestionmark = variableName.replace(/^\?+/, '');
|
||||
const state = {
|
||||
|
@ -226,92 +165,58 @@ export function IdentifierControlForm({
|
|||
width: minimumWidth,
|
||||
title: label || variableNameWithoutQuestionmark,
|
||||
variableName: variableNameWithoutQuestionmark,
|
||||
variableType: getVariableTypeFromQuery(variableName, variableType),
|
||||
controlType: EsqlControlType.STATIC_VALUES,
|
||||
variableType,
|
||||
esqlQuery: queryString,
|
||||
controlType: EsqlControlType.STATIC_VALUES,
|
||||
grow,
|
||||
};
|
||||
|
||||
if (availableOptions.length) {
|
||||
if (!isControlInEditMode) {
|
||||
await onCreateControl(state, variableName);
|
||||
} else {
|
||||
onEditControl(state);
|
||||
}
|
||||
if (!isEqual(state, initialState)) {
|
||||
setControlState(state);
|
||||
}
|
||||
closeFlyout();
|
||||
}, [
|
||||
selectedIdentifiers,
|
||||
minimumWidth,
|
||||
grow,
|
||||
initialState,
|
||||
label,
|
||||
minimumWidth,
|
||||
queryString,
|
||||
selectedIdentifiers,
|
||||
setControlState,
|
||||
variableName,
|
||||
variableType,
|
||||
queryString,
|
||||
grow,
|
||||
isControlInEditMode,
|
||||
closeFlyout,
|
||||
onCreateControl,
|
||||
onEditControl,
|
||||
]);
|
||||
|
||||
const styling = useMemo(() => getFlyoutStyling(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header isInEditMode={isControlInEditMode} />
|
||||
<EuiFlyoutBody
|
||||
css={css`
|
||||
${styling}
|
||||
`}
|
||||
<EuiFormRow
|
||||
label={i18n.translate('esql.flyout.values.label', {
|
||||
defaultMessage: 'Values',
|
||||
})}
|
||||
fullWidth
|
||||
>
|
||||
<ControlType isDisabled initialControlFlyoutType={EsqlControlType.STATIC_VALUES} />
|
||||
|
||||
<VariableName
|
||||
variableName={variableName}
|
||||
isControlInEditMode={isControlInEditMode}
|
||||
onVariableNameChange={onVariableNameChange}
|
||||
esqlVariables={esqlVariables}
|
||||
/>
|
||||
|
||||
<EuiFormRow
|
||||
label={i18n.translate('esql.flyout.values.label', {
|
||||
defaultMessage: 'Values',
|
||||
<EuiComboBox
|
||||
aria-label={i18n.translate('esql.flyout.fieldsOptions.placeholder', {
|
||||
defaultMessage: 'Select or add values',
|
||||
})}
|
||||
placeholder={i18n.translate('esql.flyout.fieldsOptions.placeholder', {
|
||||
defaultMessage: 'Select or add values',
|
||||
})}
|
||||
options={availableIdentifiersOptions}
|
||||
selectedOptions={selectedIdentifiers}
|
||||
onChange={onIdentifiersChange}
|
||||
onCreateOption={onCreateOption}
|
||||
data-test-subj="esqlIdentifiersOptions"
|
||||
fullWidth
|
||||
>
|
||||
<EuiComboBox
|
||||
aria-label={i18n.translate('esql.flyout.fieldsOptions.placeholder', {
|
||||
defaultMessage: 'Select or add values',
|
||||
})}
|
||||
placeholder={i18n.translate('esql.flyout.fieldsOptions.placeholder', {
|
||||
defaultMessage: 'Select or add values',
|
||||
})}
|
||||
options={availableIdentifiersOptions}
|
||||
selectedOptions={selectedIdentifiers}
|
||||
onChange={onIdentifiersChange}
|
||||
onCreateOption={onCreateOption}
|
||||
data-test-subj="esqlIdentifiersOptions"
|
||||
fullWidth
|
||||
compressed
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
<ControlLabel label={label} onLabelChange={onLabelChange} />
|
||||
|
||||
<ControlWidth
|
||||
minimumWidth={minimumWidth}
|
||||
grow={grow}
|
||||
onMinimumSizeChange={onMinimumSizeChange}
|
||||
onGrowChange={onGrowChange}
|
||||
compressed
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
<Footer
|
||||
isControlInEditMode={isControlInEditMode}
|
||||
variableName={variableName}
|
||||
onCancelControl={onCancelControl}
|
||||
isSaveDisabled={formIsInvalid}
|
||||
closeFlyout={closeFlyout}
|
||||
onCreateControl={onCreateFieldControl}
|
||||
</EuiFormRow>
|
||||
|
||||
<ControlLabel label={label} onLabelChange={onLabelChange} />
|
||||
|
||||
<ControlWidth
|
||||
minimumWidth={minimumWidth}
|
||||
grow={grow}
|
||||
onMinimumSizeChange={onMinimumSizeChange}
|
||||
onGrowChange={onGrowChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -7,18 +7,32 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import { EuiFlyoutBody } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { ESQLVariableType, type ESQLControlVariable } from '@kbn/esql-types';
|
||||
import { getValuesFromQueryField } from '@kbn/esql-utils';
|
||||
import type { ISearchGeneric } from '@kbn/search-types';
|
||||
import { monaco } from '@kbn/monaco';
|
||||
import type { ESQLControlState } from '../types';
|
||||
import { type ESQLControlState, EsqlControlType, VariableNamePrefix } from '../types';
|
||||
import { ValueControlForm } from './value_control_form';
|
||||
import { Header, ControlType, VariableName, Footer } from './shared_form_components';
|
||||
import { IdentifierControlForm } from './identifier_control_form';
|
||||
import { updateQueryStringWithVariable } from './helpers';
|
||||
import {
|
||||
updateQueryStringWithVariable,
|
||||
getFlyoutStyling,
|
||||
getVariableSuggestion,
|
||||
getRecurrentVariableName,
|
||||
validateVariableName,
|
||||
areValuesIntervalsValid,
|
||||
getVariableTypeFromQuery,
|
||||
getVariableNamePrefix,
|
||||
checkVariableExistence,
|
||||
} from './helpers';
|
||||
|
||||
interface ESQLControlsFlyoutProps {
|
||||
search: ISearchGeneric;
|
||||
variableType: ESQLVariableType;
|
||||
initialVariableType: ESQLVariableType;
|
||||
queryString: string;
|
||||
esqlVariables: ESQLControlVariable[];
|
||||
onSaveControl?: (controlState: ESQLControlState, updatedQuery: string) => Promise<void>;
|
||||
|
@ -30,7 +44,7 @@ interface ESQLControlsFlyoutProps {
|
|||
|
||||
export function ESQLControlsFlyout({
|
||||
search,
|
||||
variableType,
|
||||
initialVariableType,
|
||||
queryString,
|
||||
esqlVariables,
|
||||
onSaveControl,
|
||||
|
@ -39,58 +53,177 @@ export function ESQLControlsFlyout({
|
|||
initialState,
|
||||
closeFlyout,
|
||||
}: ESQLControlsFlyoutProps) {
|
||||
const onCreateControl = useCallback(
|
||||
async (state: ESQLControlState, variableName: string) => {
|
||||
if (cursorPosition) {
|
||||
const query = updateQueryStringWithVariable(queryString, variableName, cursorPosition);
|
||||
// ?? or ?
|
||||
const [variableNamePrefix, setVariableNamePrefix] = useState(
|
||||
getVariableNamePrefix(initialVariableType)
|
||||
);
|
||||
const valuesField = useMemo(() => {
|
||||
if (initialVariableType === ESQLVariableType.VALUES) {
|
||||
return getValuesFromQueryField(queryString);
|
||||
}
|
||||
return undefined;
|
||||
}, [initialVariableType, queryString]);
|
||||
|
||||
await onSaveControl?.(state, query);
|
||||
const isControlInEditMode = useMemo(() => !!initialState, [initialState]);
|
||||
const styling = useMemo(() => getFlyoutStyling(), []);
|
||||
const suggestedVariableName = useMemo(() => {
|
||||
const existingVariables = new Set(
|
||||
esqlVariables
|
||||
.filter((variable) => variable.type === initialVariableType)
|
||||
.map((variable) => variable.key)
|
||||
);
|
||||
|
||||
if (initialState) {
|
||||
return `${variableNamePrefix}${initialState.variableName}`;
|
||||
}
|
||||
|
||||
let variableNameSuggestion = getVariableSuggestion(initialVariableType);
|
||||
|
||||
if (valuesField && initialVariableType === ESQLVariableType.VALUES) {
|
||||
// variables names can't have special characters, only underscore
|
||||
const fieldVariableName = valuesField.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
variableNameSuggestion = fieldVariableName;
|
||||
}
|
||||
|
||||
return `${variableNamePrefix}${getRecurrentVariableName(
|
||||
variableNameSuggestion,
|
||||
existingVariables
|
||||
)}`;
|
||||
}, [esqlVariables, initialState, valuesField, variableNamePrefix, initialVariableType]);
|
||||
|
||||
const [controlFlyoutType, setControlFlyoutType] = useState<EsqlControlType>(
|
||||
initialState?.controlType ??
|
||||
(initialVariableType === ESQLVariableType.VALUES
|
||||
? EsqlControlType.VALUES_FROM_QUERY
|
||||
: EsqlControlType.STATIC_VALUES)
|
||||
);
|
||||
const [variableName, setVariableName] = useState(suggestedVariableName);
|
||||
const [variableType, setVariableType] = useState<ESQLVariableType>(initialVariableType);
|
||||
|
||||
const [formIsInvalid, setFormIsInvalid] = useState(false);
|
||||
const [controlState, setControlState] = useState<ESQLControlState | undefined>(initialState);
|
||||
|
||||
const areValuesValid = useMemo(() => {
|
||||
const available = controlState?.availableOptions ?? [];
|
||||
return variableType === ESQLVariableType.TIME_LITERAL
|
||||
? areValuesIntervalsValid(available.map((option) => option))
|
||||
: true;
|
||||
}, [variableType, controlState?.availableOptions]);
|
||||
|
||||
const onVariableNameChange = useCallback(
|
||||
(e: { target: { value: React.SetStateAction<string> } }) => {
|
||||
const text = validateVariableName(String(e.target.value), variableNamePrefix);
|
||||
setVariableName(text);
|
||||
const newType = getVariableTypeFromQuery(text, variableType);
|
||||
setVariableType(newType);
|
||||
setVariableNamePrefix(getVariableNamePrefix(newType));
|
||||
if (
|
||||
controlFlyoutType === EsqlControlType.VALUES_FROM_QUERY &&
|
||||
newType !== ESQLVariableType.VALUES
|
||||
) {
|
||||
setControlFlyoutType(EsqlControlType.STATIC_VALUES);
|
||||
}
|
||||
},
|
||||
[cursorPosition, onSaveControl, queryString]
|
||||
[controlFlyoutType, variableNamePrefix, variableType]
|
||||
);
|
||||
|
||||
const onEditControl = useCallback(
|
||||
async (state: ESQLControlState) => {
|
||||
await onSaveControl?.(state, '');
|
||||
},
|
||||
[onSaveControl]
|
||||
);
|
||||
useEffect(() => {
|
||||
const variableNameWithoutQuestionmark = variableName.replace(/^\?+/, '');
|
||||
const variableExists = checkVariableExistence(esqlVariables, variableName);
|
||||
setFormIsInvalid(
|
||||
!variableNameWithoutQuestionmark ||
|
||||
variableExists ||
|
||||
!areValuesValid ||
|
||||
!controlState?.availableOptions.length
|
||||
);
|
||||
}, [
|
||||
areValuesValid,
|
||||
controlState?.availableOptions.length,
|
||||
esqlVariables,
|
||||
variableName,
|
||||
variableType,
|
||||
]);
|
||||
|
||||
if (variableType === ESQLVariableType.VALUES || variableType === ESQLVariableType.TIME_LITERAL) {
|
||||
return (
|
||||
const onFlyoutTypeChange = useCallback((controlType: EsqlControlType) => {
|
||||
setControlFlyoutType(controlType);
|
||||
}, []);
|
||||
|
||||
const onCreateControl = useCallback(async () => {
|
||||
if (controlState && controlState.availableOptions.length) {
|
||||
if (!isControlInEditMode) {
|
||||
if (cursorPosition) {
|
||||
const query = updateQueryStringWithVariable(queryString, variableName, cursorPosition);
|
||||
await onSaveControl?.(controlState, query);
|
||||
}
|
||||
} else {
|
||||
await onSaveControl?.(controlState, '');
|
||||
}
|
||||
}
|
||||
closeFlyout();
|
||||
}, [
|
||||
controlState,
|
||||
closeFlyout,
|
||||
isControlInEditMode,
|
||||
cursorPosition,
|
||||
queryString,
|
||||
variableName,
|
||||
onSaveControl,
|
||||
]);
|
||||
|
||||
const formBody =
|
||||
variableNamePrefix === VariableNamePrefix.VALUE ? (
|
||||
<ValueControlForm
|
||||
queryString={queryString}
|
||||
esqlVariables={esqlVariables}
|
||||
variableName={variableName}
|
||||
controlFlyoutType={controlFlyoutType}
|
||||
variableType={variableType}
|
||||
closeFlyout={closeFlyout}
|
||||
onCancelControl={onCancelControl}
|
||||
initialState={initialState}
|
||||
onCreateControl={onCreateControl}
|
||||
onEditControl={onEditControl}
|
||||
setControlState={setControlState}
|
||||
search={search}
|
||||
cursorPosition={cursorPosition}
|
||||
valuesRetrieval={valuesField}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
variableType === ESQLVariableType.FIELDS ||
|
||||
variableType === ESQLVariableType.FUNCTIONS
|
||||
) {
|
||||
return (
|
||||
) : (
|
||||
<IdentifierControlForm
|
||||
variableType={variableType}
|
||||
esqlVariables={esqlVariables}
|
||||
variableName={variableName}
|
||||
queryString={queryString}
|
||||
onCancelControl={onCancelControl}
|
||||
onCreateControl={onCreateControl}
|
||||
onEditControl={onEditControl}
|
||||
setControlState={setControlState}
|
||||
initialState={initialState}
|
||||
closeFlyout={closeFlyout}
|
||||
search={search}
|
||||
cursorPosition={cursorPosition}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<>
|
||||
<Header isInEditMode={isControlInEditMode} />
|
||||
<EuiFlyoutBody
|
||||
css={css`
|
||||
${styling}
|
||||
`}
|
||||
>
|
||||
<ControlType
|
||||
isDisabled={variableType !== ESQLVariableType.VALUES}
|
||||
initialControlFlyoutType={controlFlyoutType}
|
||||
onFlyoutTypeChange={onFlyoutTypeChange}
|
||||
/>
|
||||
|
||||
<VariableName
|
||||
variableName={variableName}
|
||||
isControlInEditMode={isControlInEditMode}
|
||||
onVariableNameChange={onVariableNameChange}
|
||||
esqlVariables={esqlVariables}
|
||||
/>
|
||||
{formBody}
|
||||
</EuiFlyoutBody>
|
||||
<Footer
|
||||
isControlInEditMode={isControlInEditMode}
|
||||
variableName={variableName}
|
||||
onCancelControl={onCancelControl}
|
||||
isSaveDisabled={formIsInvalid}
|
||||
closeFlyout={closeFlyout}
|
||||
onCreateControl={onCreateControl}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ import {
|
|||
EuiCode,
|
||||
} from '@elastic/eui';
|
||||
import { EsqlControlType } from '../types';
|
||||
import { checkVariableExistence } from './helpers';
|
||||
|
||||
const controlTypeOptions = [
|
||||
{
|
||||
|
@ -171,13 +172,21 @@ export function VariableName({
|
|||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const isDisabledTooltipText = i18n.translate('esql.flyout.variableName.disabledTooltip', {
|
||||
defaultMessage: 'You can’t edit a control name after it’s been created.',
|
||||
});
|
||||
const variableNameWithoutQuestionmark = variableName.replace(/^\?+/, '');
|
||||
const variableExists =
|
||||
esqlVariables.some((variable) => variable.key === variableName.replace('?', '')) &&
|
||||
!isControlInEditMode;
|
||||
checkVariableExistence(esqlVariables, variableName) && !isControlInEditMode;
|
||||
const errorMessage = !variableNameWithoutQuestionmark
|
||||
? i18n.translate('esql.flyout.variableName.error', {
|
||||
defaultMessage: 'Variable name is required',
|
||||
})
|
||||
: variableExists
|
||||
? i18n.translate('esql.flyout.variableNameExists.error', {
|
||||
defaultMessage: 'Variable name already exists',
|
||||
})
|
||||
: undefined;
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={i18n.translate('esql.flyout.variableName.label', {
|
||||
|
@ -186,18 +195,8 @@ export function VariableName({
|
|||
helpText={helpText}
|
||||
fullWidth
|
||||
autoFocus
|
||||
isInvalid={!variableName || variableExists}
|
||||
error={
|
||||
!variableName
|
||||
? i18n.translate('esql.flyout.variableName.error', {
|
||||
defaultMessage: 'Variable name is required',
|
||||
})
|
||||
: variableExists
|
||||
? i18n.translate('esql.flyout.variableNameExists.error', {
|
||||
defaultMessage: 'Variable name already exists',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
isInvalid={!variableNameWithoutQuestionmark || variableExists}
|
||||
error={errorMessage}
|
||||
>
|
||||
<EuiToolTip
|
||||
content={isControlInEditMode ? isDisabledTooltipText : tooltipContent}
|
||||
|
@ -210,6 +209,7 @@ export function VariableName({
|
|||
placeholder={i18n.translate('esql.flyout.variableName.placeholder', {
|
||||
defaultMessage: 'Set a variable name',
|
||||
})}
|
||||
disabled={isControlInEditMode}
|
||||
value={variableName}
|
||||
onChange={onVariableNameChange}
|
||||
aria-label={i18n.translate('esql.flyout.variableName.placeholder', {
|
||||
|
@ -217,7 +217,6 @@ export function VariableName({
|
|||
})}
|
||||
data-test-subj="esqlVariableName"
|
||||
fullWidth
|
||||
disabled={isControlInEditMode}
|
||||
compressed
|
||||
/>
|
||||
</EuiToolTip>
|
||||
|
|
|
@ -11,11 +11,12 @@ import React from 'react';
|
|||
import { render, within, fireEvent } from '@testing-library/react';
|
||||
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
|
||||
import { IUiSettingsClient } from '@kbn/core/public';
|
||||
import { monaco } from '@kbn/monaco';
|
||||
import { coreMock } from '@kbn/core/server/mocks';
|
||||
import { ESQLVariableType } from '@kbn/esql-types';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
||||
import { ValueControlForm } from './value_control_form';
|
||||
import { ESQLControlsFlyout } from '.';
|
||||
import { EsqlControlType, ESQLControlState } from '../types';
|
||||
|
||||
jest.mock('@kbn/esql-utils', () => {
|
||||
|
@ -38,6 +39,7 @@ jest.mock('@kbn/esql-utils', () => {
|
|||
getLimitFromESQLQuery: jest.fn().mockReturnValue(1000),
|
||||
isQueryWrappedByPipes: jest.fn().mockReturnValue(false),
|
||||
getValuesFromQueryField: jest.fn().mockReturnValue('field'),
|
||||
getESQLQueryColumnsRaw: jest.fn().mockResolvedValue([{ name: 'column1' }, { name: 'column2' }]),
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -63,12 +65,12 @@ describe('ValueControlForm', () => {
|
|||
it('should default correctly if no initial state is given for an interval variable type', async () => {
|
||||
const { findByTestId, findByTitle } = render(
|
||||
<IntlProvider locale="en">
|
||||
<ValueControlForm
|
||||
variableType={ESQLVariableType.TIME_LITERAL}
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.TIME_LITERAL}
|
||||
queryString="FROM foo | STATS BY BUCKET(@timestamp,)"
|
||||
onCreateControl={jest.fn()}
|
||||
onSaveControl={jest.fn()}
|
||||
closeFlyout={jest.fn()}
|
||||
onEditControl={jest.fn()}
|
||||
onCancelControl={jest.fn()}
|
||||
search={searchMock}
|
||||
esqlVariables={[]}
|
||||
/>
|
||||
|
@ -111,14 +113,15 @@ describe('ValueControlForm', () => {
|
|||
const onCreateControlSpy = jest.fn();
|
||||
const { findByTestId, findByTitle } = render(
|
||||
<IntlProvider locale="en">
|
||||
<ValueControlForm
|
||||
variableType={ESQLVariableType.TIME_LITERAL}
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.TIME_LITERAL}
|
||||
queryString="FROM foo | STATS BY BUCKET(@timestamp,)"
|
||||
onCreateControl={onCreateControlSpy}
|
||||
onSaveControl={onCreateControlSpy}
|
||||
closeFlyout={jest.fn()}
|
||||
onEditControl={jest.fn()}
|
||||
onCancelControl={jest.fn()}
|
||||
search={searchMock}
|
||||
esqlVariables={[]}
|
||||
cursorPosition={{ lineNumber: 1, column: 1 } as monaco.Position}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
@ -138,13 +141,12 @@ describe('ValueControlForm', () => {
|
|||
const onCancelControlSpy = jest.fn();
|
||||
const { findByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
<ValueControlForm
|
||||
variableType={ESQLVariableType.TIME_LITERAL}
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.TIME_LITERAL}
|
||||
queryString="FROM foo | STATS BY BUCKET(@timestamp,)"
|
||||
onCreateControl={jest.fn()}
|
||||
onCancelControl={onCancelControlSpy}
|
||||
onSaveControl={jest.fn()}
|
||||
closeFlyout={jest.fn()}
|
||||
onEditControl={jest.fn()}
|
||||
onCancelControl={onCancelControlSpy}
|
||||
search={searchMock}
|
||||
esqlVariables={[]}
|
||||
/>
|
||||
|
@ -169,12 +171,12 @@ describe('ValueControlForm', () => {
|
|||
} as ESQLControlState;
|
||||
const { findByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
<ValueControlForm
|
||||
variableType={ESQLVariableType.TIME_LITERAL}
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.TIME_LITERAL}
|
||||
queryString="FROM foo | STATS BY BUCKET(@timestamp,)"
|
||||
onCreateControl={jest.fn()}
|
||||
onSaveControl={jest.fn()}
|
||||
closeFlyout={jest.fn()}
|
||||
onEditControl={jest.fn()}
|
||||
onCancelControl={jest.fn()}
|
||||
search={searchMock}
|
||||
initialState={initialState}
|
||||
esqlVariables={[]}
|
||||
|
@ -220,15 +222,16 @@ describe('ValueControlForm', () => {
|
|||
const onEditControlSpy = jest.fn();
|
||||
const { findByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
<ValueControlForm
|
||||
variableType={ESQLVariableType.FIELDS}
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.TIME_LITERAL}
|
||||
queryString="FROM foo | STATS BY BUCKET(@timestamp,)"
|
||||
onCreateControl={jest.fn()}
|
||||
onSaveControl={onEditControlSpy}
|
||||
closeFlyout={jest.fn()}
|
||||
onEditControl={onEditControlSpy}
|
||||
onCancelControl={jest.fn()}
|
||||
search={searchMock}
|
||||
initialState={initialState}
|
||||
esqlVariables={[]}
|
||||
cursorPosition={{ lineNumber: 1, column: 1 } as monaco.Position}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
@ -242,12 +245,12 @@ describe('ValueControlForm', () => {
|
|||
const { findByTestId } = render(
|
||||
<KibanaContextProvider services={services}>
|
||||
<IntlProvider locale="en">
|
||||
<ValueControlForm
|
||||
variableType={ESQLVariableType.VALUES}
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.VALUES}
|
||||
queryString="FROM foo | WHERE field =="
|
||||
onCreateControl={jest.fn()}
|
||||
onSaveControl={jest.fn()}
|
||||
closeFlyout={jest.fn()}
|
||||
onEditControl={jest.fn()}
|
||||
onCancelControl={jest.fn()}
|
||||
search={searchMock}
|
||||
esqlVariables={[]}
|
||||
/>
|
||||
|
@ -264,6 +267,34 @@ describe('ValueControlForm', () => {
|
|||
// values preview panel should be rendered
|
||||
expect(await findByTestId('esqlValuesPreview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be able to change in fields type', async () => {
|
||||
const { findByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
<ESQLControlsFlyout
|
||||
initialVariableType={ESQLVariableType.VALUES}
|
||||
queryString="FROM foo | WHERE field =="
|
||||
onSaveControl={jest.fn()}
|
||||
closeFlyout={jest.fn()}
|
||||
onCancelControl={jest.fn()}
|
||||
search={searchMock}
|
||||
esqlVariables={[]}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
// variable name input should be rendered and with the default value
|
||||
expect(await findByTestId('esqlVariableName')).toHaveValue('?field');
|
||||
// change the variable name to ?value
|
||||
const variableNameInput = await findByTestId('esqlVariableName');
|
||||
fireEvent.change(variableNameInput, { target: { value: '??field' } });
|
||||
|
||||
expect(await findByTestId('esqlControlTypeDropdown')).toBeInTheDocument();
|
||||
const controlTypeInputPopover = await findByTestId('esqlControlTypeInputPopover');
|
||||
expect(within(controlTypeInputPopover).getByRole('combobox')).toHaveValue(`Static values`);
|
||||
// identifiers dropdown should be rendered
|
||||
const identifiersOptionsDropdown = await findByTestId('esqlIdentifiersOptions');
|
||||
expect(identifiersOptionsDropdown).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,14 +7,14 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import React, { useCallback, useState, useEffect } from 'react';
|
||||
import useMountedState from 'react-use/lib/useMountedState';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { isEqual } from 'lodash';
|
||||
import {
|
||||
EuiComboBox,
|
||||
EuiComboBoxOptionOption,
|
||||
EuiFormRow,
|
||||
EuiFlyoutBody,
|
||||
EuiCallOut,
|
||||
type EuiSwitchEvent,
|
||||
EuiPanel,
|
||||
|
@ -23,98 +23,41 @@ import { css } from '@emotion/react';
|
|||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import type { ISearchGeneric } from '@kbn/search-types';
|
||||
import { ESQLVariableType } from '@kbn/esql-types';
|
||||
import { ESQLControlVariable } from '@kbn/esql-types';
|
||||
import { monaco } from '@kbn/monaco';
|
||||
import {
|
||||
getIndexPatternFromESQLQuery,
|
||||
getESQLResults,
|
||||
appendStatsByToQuery,
|
||||
getValuesFromQueryField,
|
||||
} from '@kbn/esql-utils';
|
||||
import { ESQLLangEditor } from '../../../create_editor';
|
||||
import type { ESQLControlState, ControlWidthOptions } from '../types';
|
||||
import {
|
||||
Header,
|
||||
Footer,
|
||||
ControlWidth,
|
||||
ControlType,
|
||||
VariableName,
|
||||
ControlLabel,
|
||||
} from './shared_form_components';
|
||||
import {
|
||||
getRecurrentVariableName,
|
||||
getFlyoutStyling,
|
||||
areValuesIntervalsValid,
|
||||
validateVariableName,
|
||||
getVariablePrefix,
|
||||
getVariableTypeFromQuery,
|
||||
} from './helpers';
|
||||
import { ControlWidth, ControlLabel } from './shared_form_components';
|
||||
import { EsqlControlType } from '../types';
|
||||
import { ChooseColumnPopover } from './choose_column_popover';
|
||||
|
||||
interface ValueControlFormProps {
|
||||
search: ISearchGeneric;
|
||||
variableType: ESQLVariableType;
|
||||
variableName: string;
|
||||
controlFlyoutType: EsqlControlType;
|
||||
queryString: string;
|
||||
esqlVariables: ESQLControlVariable[];
|
||||
closeFlyout: () => void;
|
||||
onCreateControl: (state: ESQLControlState, variableName: string) => void;
|
||||
onEditControl: (state: ESQLControlState) => void;
|
||||
onCancelControl?: () => void;
|
||||
setControlState: (state: ESQLControlState) => void;
|
||||
initialState?: ESQLControlState;
|
||||
cursorPosition?: monaco.Position;
|
||||
valuesRetrieval?: string;
|
||||
}
|
||||
|
||||
const SUGGESTED_INTERVAL_VALUES = ['5 minutes', '1 hour', '1 day', '1 week', '1 month'];
|
||||
const VALUE_VARIABLE_PREFIX = '?';
|
||||
|
||||
export function ValueControlForm({
|
||||
variableType,
|
||||
initialState,
|
||||
onCancelControl,
|
||||
queryString,
|
||||
esqlVariables,
|
||||
variableName,
|
||||
controlFlyoutType,
|
||||
search,
|
||||
closeFlyout,
|
||||
onCreateControl,
|
||||
onEditControl,
|
||||
cursorPosition,
|
||||
setControlState,
|
||||
valuesRetrieval,
|
||||
}: ValueControlFormProps) {
|
||||
const isMounted = useMountedState();
|
||||
const valuesField = useMemo(() => {
|
||||
if (variableType === ESQLVariableType.VALUES) {
|
||||
return getValuesFromQueryField(queryString, cursorPosition);
|
||||
}
|
||||
return null;
|
||||
}, [variableType, queryString, cursorPosition]);
|
||||
const suggestedVariableName = useMemo(() => {
|
||||
const existingVariables = new Set(
|
||||
esqlVariables
|
||||
.filter((variable) => variable.type === variableType)
|
||||
.map((variable) => variable.key)
|
||||
);
|
||||
|
||||
if (initialState) {
|
||||
return `${VALUE_VARIABLE_PREFIX}${initialState.variableName}`;
|
||||
}
|
||||
|
||||
let variablePrefix = getVariablePrefix(variableType);
|
||||
|
||||
if (valuesField && variableType === ESQLVariableType.VALUES) {
|
||||
// variables names can't have special characters, only underscore
|
||||
const fieldVariableName = valuesField.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
variablePrefix = fieldVariableName;
|
||||
}
|
||||
|
||||
return `${VALUE_VARIABLE_PREFIX}${getRecurrentVariableName(variablePrefix, existingVariables)}`;
|
||||
}, [esqlVariables, initialState, valuesField, variableType]);
|
||||
|
||||
const [controlFlyoutType, setControlFlyoutType] = useState<EsqlControlType>(
|
||||
initialState?.controlType ??
|
||||
(variableType === ESQLVariableType.TIME_LITERAL
|
||||
? EsqlControlType.STATIC_VALUES
|
||||
: EsqlControlType.VALUES_FROM_QUERY)
|
||||
);
|
||||
|
||||
const [availableValuesOptions, setAvailableValuesOptions] = useState<EuiComboBoxOptionOption[]>(
|
||||
variableType === ESQLVariableType.TIME_LITERAL
|
||||
|
@ -144,49 +87,17 @@ export function ValueControlForm({
|
|||
variableType === ESQLVariableType.VALUES ? initialState?.esqlQuery ?? '' : ''
|
||||
);
|
||||
const [esqlQueryErrors, setEsqlQueryErrors] = useState<Error[] | undefined>();
|
||||
const [formIsInvalid, setFormIsInvalid] = useState(false);
|
||||
const [queryColumns, setQueryColumns] = useState<string[]>(valuesField ? [valuesField] : []);
|
||||
const [variableName, setVariableName] = useState(suggestedVariableName);
|
||||
const [queryColumns, setQueryColumns] = useState<string[]>(
|
||||
valuesRetrieval ? [valuesRetrieval] : []
|
||||
);
|
||||
const [label, setLabel] = useState(initialState?.title ?? '');
|
||||
const [minimumWidth, setMinimumWidth] = useState(initialState?.width ?? 'medium');
|
||||
const [grow, setGrow] = useState(initialState?.grow ?? false);
|
||||
|
||||
const isControlInEditMode = useMemo(() => !!initialState, [initialState]);
|
||||
|
||||
const areValuesValid = useMemo(() => {
|
||||
return variableType === ESQLVariableType.TIME_LITERAL
|
||||
? areValuesIntervalsValid(selectedValues.map((option) => option.label))
|
||||
: true;
|
||||
}, [variableType, selectedValues]);
|
||||
|
||||
useEffect(() => {
|
||||
const variableExists =
|
||||
esqlVariables.some((variable) => variable.key === variableName.replace('?', '')) &&
|
||||
!isControlInEditMode;
|
||||
setFormIsInvalid(!variableName || variableExists || !areValuesValid || !selectedValues.length);
|
||||
}, [
|
||||
areValuesValid,
|
||||
esqlVariables,
|
||||
isControlInEditMode,
|
||||
selectedValues.length,
|
||||
valuesQuery,
|
||||
variableName,
|
||||
]);
|
||||
|
||||
const onValuesChange = useCallback((selectedOptions: EuiComboBoxOptionOption[]) => {
|
||||
setSelectedValues(selectedOptions);
|
||||
}, []);
|
||||
|
||||
const onFlyoutTypeChange = useCallback(
|
||||
(type: EsqlControlType) => {
|
||||
setControlFlyoutType(type);
|
||||
if (type !== controlFlyoutType && variableType === ESQLVariableType.TIME_LITERAL) {
|
||||
setSelectedValues([]);
|
||||
}
|
||||
},
|
||||
[controlFlyoutType, variableType]
|
||||
);
|
||||
|
||||
const onCreateOption = useCallback(
|
||||
(searchValue: string, flattenedOptions: EuiComboBoxOptionOption[] = []) => {
|
||||
if (!searchValue) {
|
||||
|
@ -214,14 +125,6 @@ export function ValueControlForm({
|
|||
[availableValuesOptions]
|
||||
);
|
||||
|
||||
const onVariableNameChange = useCallback(
|
||||
(e: { target: { value: React.SetStateAction<string> } }) => {
|
||||
const text = validateVariableName(String(e.target.value), VALUE_VARIABLE_PREFIX);
|
||||
setVariableName(text);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onLabelChange = useCallback((e: { target: { value: React.SetStateAction<string> } }) => {
|
||||
setLabel(e.target.value);
|
||||
}, []);
|
||||
|
@ -280,11 +183,11 @@ export function ValueControlForm({
|
|||
if (
|
||||
!selectedValues?.length &&
|
||||
controlFlyoutType === EsqlControlType.VALUES_FROM_QUERY &&
|
||||
valuesField
|
||||
valuesRetrieval
|
||||
) {
|
||||
const queryForValues =
|
||||
suggestedVariableName !== ''
|
||||
? `FROM ${getIndexPatternFromESQLQuery(queryString)} | STATS BY ${valuesField}`
|
||||
variableName !== ''
|
||||
? `FROM ${getIndexPatternFromESQLQuery(queryString)} | STATS BY ${valuesRetrieval}`
|
||||
: '';
|
||||
onValuesQuerySubmit(queryForValues);
|
||||
}
|
||||
|
@ -293,12 +196,11 @@ export function ValueControlForm({
|
|||
onValuesQuerySubmit,
|
||||
queryString,
|
||||
selectedValues?.length,
|
||||
suggestedVariableName,
|
||||
valuesField,
|
||||
valuesRetrieval,
|
||||
variableName,
|
||||
]);
|
||||
|
||||
const onCreateValueControl = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
const availableOptions = selectedValues.map((value) => value.label);
|
||||
// removes the question mark from the variable name
|
||||
const variableNameWithoutQuestionmark = variableName.replace(/^\?+/, '');
|
||||
|
@ -308,34 +210,26 @@ export function ValueControlForm({
|
|||
width: minimumWidth,
|
||||
title: label || variableNameWithoutQuestionmark,
|
||||
variableName: variableNameWithoutQuestionmark,
|
||||
variableType: getVariableTypeFromQuery(variableName, variableType),
|
||||
variableType,
|
||||
esqlQuery: valuesQuery || queryString,
|
||||
controlType: controlFlyoutType,
|
||||
grow,
|
||||
};
|
||||
|
||||
if (availableOptions.length) {
|
||||
if (!isControlInEditMode) {
|
||||
await onCreateControl(state, variableName);
|
||||
} else {
|
||||
onEditControl(state);
|
||||
}
|
||||
if (!isEqual(state, initialState)) {
|
||||
setControlState(state);
|
||||
}
|
||||
closeFlyout();
|
||||
}, [
|
||||
selectedValues,
|
||||
controlFlyoutType,
|
||||
minimumWidth,
|
||||
grow,
|
||||
initialState,
|
||||
label,
|
||||
minimumWidth,
|
||||
queryString,
|
||||
selectedValues,
|
||||
setControlState,
|
||||
valuesQuery,
|
||||
variableName,
|
||||
variableType,
|
||||
valuesQuery,
|
||||
queryString,
|
||||
grow,
|
||||
closeFlyout,
|
||||
isControlInEditMode,
|
||||
onCreateControl,
|
||||
onEditControl,
|
||||
]);
|
||||
|
||||
const updateQuery = useCallback(
|
||||
|
@ -346,149 +240,119 @@ export function ValueControlForm({
|
|||
[onValuesQuerySubmit, valuesQuery]
|
||||
);
|
||||
|
||||
const styling = useMemo(() => getFlyoutStyling(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header isInEditMode={isControlInEditMode} />
|
||||
<EuiFlyoutBody
|
||||
css={css`
|
||||
${styling}
|
||||
`}
|
||||
>
|
||||
<ControlType
|
||||
isDisabled={false}
|
||||
initialControlFlyoutType={controlFlyoutType}
|
||||
onFlyoutTypeChange={onFlyoutTypeChange}
|
||||
/>
|
||||
|
||||
<VariableName
|
||||
variableName={variableName}
|
||||
isControlInEditMode={isControlInEditMode}
|
||||
onVariableNameChange={onVariableNameChange}
|
||||
esqlVariables={esqlVariables}
|
||||
/>
|
||||
|
||||
{controlFlyoutType === EsqlControlType.VALUES_FROM_QUERY && (
|
||||
<>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('esql.flyout.valuesQueryEditor.label', {
|
||||
defaultMessage: 'Values query',
|
||||
})}
|
||||
fullWidth
|
||||
>
|
||||
<ESQLLangEditor
|
||||
query={{ esql: valuesQuery }}
|
||||
onTextLangQueryChange={(q) => {
|
||||
setValuesQuery(q.esql);
|
||||
}}
|
||||
hideTimeFilterInfo={true}
|
||||
disableAutoFocus={true}
|
||||
errors={esqlQueryErrors}
|
||||
editorIsInline
|
||||
hideRunQueryText
|
||||
onTextLangQuerySubmit={async (q, a) => {
|
||||
if (q) {
|
||||
await onValuesQuerySubmit(q.esql);
|
||||
}
|
||||
}}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
{queryColumns.length > 0 && (
|
||||
<EuiFormRow
|
||||
label={i18n.translate('esql.flyout.previewValues.placeholder', {
|
||||
defaultMessage: 'Values preview',
|
||||
})}
|
||||
fullWidth
|
||||
>
|
||||
{queryColumns.length === 1 ? (
|
||||
<EuiPanel
|
||||
paddingSize="s"
|
||||
color="primary"
|
||||
css={css`
|
||||
white-space: wrap;
|
||||
overflow-y: auto;
|
||||
max-height: 200px;
|
||||
`}
|
||||
data-test-subj="esqlValuesPreview"
|
||||
>
|
||||
{selectedValues.map((value) => value.label).join(', ')}
|
||||
</EuiPanel>
|
||||
) : (
|
||||
<EuiCallOut
|
||||
title={i18n.translate('esql.flyout.displayMultipleColsCallout.title', {
|
||||
defaultMessage: 'Your query must return a single column',
|
||||
})}
|
||||
color="warning"
|
||||
iconType="warning"
|
||||
size="s"
|
||||
data-test-subj="esqlMoreThanOneColumnCallout"
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="esql.flyout.displayMultipleColsCallout.description"
|
||||
defaultMessage="Your query is currently returning {totalColumns} columns. Choose column {chooseColumnPopover} or use {boldText}."
|
||||
values={{
|
||||
totalColumns: queryColumns.length,
|
||||
boldText: <strong>STATS BY</strong>,
|
||||
chooseColumnPopover: (
|
||||
<ChooseColumnPopover columns={queryColumns} updateQuery={updateQuery} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
)}
|
||||
</EuiFormRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{controlFlyoutType === EsqlControlType.STATIC_VALUES && (
|
||||
{controlFlyoutType === EsqlControlType.VALUES_FROM_QUERY && (
|
||||
<>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('esql.flyout.values.label', {
|
||||
defaultMessage: 'Values',
|
||||
label={i18n.translate('esql.flyout.valuesQueryEditor.label', {
|
||||
defaultMessage: 'Values query',
|
||||
})}
|
||||
fullWidth
|
||||
>
|
||||
<EuiComboBox
|
||||
aria-label={i18n.translate('esql.flyout.values.placeholder', {
|
||||
defaultMessage: 'Select or add values',
|
||||
})}
|
||||
placeholder={i18n.translate('esql.flyout.values.placeholder', {
|
||||
defaultMessage: 'Select or add values',
|
||||
})}
|
||||
data-test-subj="esqlValuesOptions"
|
||||
options={availableValuesOptions}
|
||||
selectedOptions={selectedValues}
|
||||
onChange={onValuesChange}
|
||||
onCreateOption={onCreateOption}
|
||||
fullWidth
|
||||
compressed
|
||||
css={css`
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
`}
|
||||
<ESQLLangEditor
|
||||
query={{ esql: valuesQuery }}
|
||||
onTextLangQueryChange={(q) => {
|
||||
setValuesQuery(q.esql);
|
||||
}}
|
||||
hideTimeFilterInfo={true}
|
||||
disableAutoFocus={true}
|
||||
errors={esqlQueryErrors}
|
||||
editorIsInline
|
||||
hideRunQueryText
|
||||
onTextLangQuerySubmit={async (q, a) => {
|
||||
if (q) {
|
||||
await onValuesQuerySubmit(q.esql);
|
||||
}
|
||||
}}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
<ControlLabel label={label} onLabelChange={onLabelChange} />
|
||||
{queryColumns.length > 0 && (
|
||||
<EuiFormRow
|
||||
label={i18n.translate('esql.flyout.previewValues.placeholder', {
|
||||
defaultMessage: 'Values preview',
|
||||
})}
|
||||
fullWidth
|
||||
>
|
||||
{queryColumns.length === 1 ? (
|
||||
<EuiPanel
|
||||
paddingSize="s"
|
||||
color="primary"
|
||||
css={css`
|
||||
white-space: wrap;
|
||||
overflow-y: auto;
|
||||
max-height: 200px;
|
||||
`}
|
||||
data-test-subj="esqlValuesPreview"
|
||||
>
|
||||
{selectedValues.map((value) => value.label).join(', ')}
|
||||
</EuiPanel>
|
||||
) : (
|
||||
<EuiCallOut
|
||||
title={i18n.translate('esql.flyout.displayMultipleColsCallout.title', {
|
||||
defaultMessage: 'Your query must return a single column',
|
||||
})}
|
||||
color="warning"
|
||||
iconType="warning"
|
||||
size="s"
|
||||
data-test-subj="esqlMoreThanOneColumnCallout"
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="esql.flyout.displayMultipleColsCallout.description"
|
||||
defaultMessage="Your query is currently returning {totalColumns} columns. Choose column {chooseColumnPopover} or use {boldText}."
|
||||
values={{
|
||||
totalColumns: queryColumns.length,
|
||||
boldText: <strong>STATS BY</strong>,
|
||||
chooseColumnPopover: (
|
||||
<ChooseColumnPopover columns={queryColumns} updateQuery={updateQuery} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
)}
|
||||
</EuiFormRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{controlFlyoutType === EsqlControlType.STATIC_VALUES && (
|
||||
<EuiFormRow
|
||||
label={i18n.translate('esql.flyout.values.label', {
|
||||
defaultMessage: 'Values',
|
||||
})}
|
||||
fullWidth
|
||||
>
|
||||
<EuiComboBox
|
||||
aria-label={i18n.translate('esql.flyout.values.placeholder', {
|
||||
defaultMessage: 'Select or add values',
|
||||
})}
|
||||
placeholder={i18n.translate('esql.flyout.values.placeholder', {
|
||||
defaultMessage: 'Select or add values',
|
||||
})}
|
||||
data-test-subj="esqlValuesOptions"
|
||||
options={availableValuesOptions}
|
||||
selectedOptions={selectedValues}
|
||||
onChange={onValuesChange}
|
||||
onCreateOption={onCreateOption}
|
||||
fullWidth
|
||||
compressed
|
||||
css={css`
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
`}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
<ControlLabel label={label} onLabelChange={onLabelChange} />
|
||||
|
||||
<ControlWidth
|
||||
minimumWidth={minimumWidth}
|
||||
grow={grow}
|
||||
onMinimumSizeChange={onMinimumSizeChange}
|
||||
onGrowChange={onGrowChange}
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
<Footer
|
||||
isControlInEditMode={isControlInEditMode}
|
||||
variableName={variableName}
|
||||
onCancelControl={onCancelControl}
|
||||
isSaveDisabled={formIsInvalid}
|
||||
closeFlyout={closeFlyout}
|
||||
onCreateControl={onCreateValueControl}
|
||||
<ControlWidth
|
||||
minimumWidth={minimumWidth}
|
||||
grow={grow}
|
||||
onMinimumSizeChange={onMinimumSizeChange}
|
||||
onGrowChange={onGrowChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -72,7 +72,7 @@ export async function executeAction({
|
|||
<LazyControlFlyout
|
||||
queryString={queryString}
|
||||
search={search}
|
||||
variableType={variableType}
|
||||
initialVariableType={variableType}
|
||||
closeFlyout={() => {
|
||||
handle.close();
|
||||
}}
|
||||
|
|
|
@ -13,6 +13,11 @@ export enum EsqlControlType {
|
|||
VALUES_FROM_QUERY = 'VALUES_FROM_QUERY',
|
||||
}
|
||||
|
||||
export enum VariableNamePrefix {
|
||||
IDENTIFIER = '??',
|
||||
VALUE = '?',
|
||||
}
|
||||
|
||||
export type ControlWidthOptions = 'small' | 'medium' | 'large';
|
||||
|
||||
export interface ESQLControlState {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue