[ES|QL] Integrating the editor with the time picker (#187047)

Part of #189010

## Summary

It displays the date picker for date fields. It is currently only
possible for where and eval commands (and not in stats bucker) exactly
as the earliest and latest params mostly because the bucket command is
not well defined and we need to fix the autocomplete first before we
integrate the latest / earliest params and the timepicker


![meow](https://github.com/user-attachments/assets/f5cc176d-e01e-445b-9500-6898a465e3ad)


### 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
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: Drew Tate <andrew.tate@elastic.co>
This commit is contained in:
Stratoula Kalafateli 2024-07-24 16:46:48 +02:00 committed by GitHub
parent f5d9b9db35
commit dd25429d8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 170 additions and 36 deletions

View file

@ -15,7 +15,7 @@ import { timeUnitsToSuggest } from '../../definitions/literals';
import { groupingFunctionDefinitions } from '../../definitions/grouping';
import * as autocomplete from '../autocomplete';
import type { ESQLCallbacks } from '../../shared/types';
import type { EditorContext } from '../types';
import type { EditorContext, SuggestionRawDefinition } from '../types';
import { TIME_SYSTEM_PARAMS } from '../factories';
export interface Integration {
@ -28,6 +28,13 @@ export interface Integration {
}>;
}
export type PartialSuggestionWithText = Partial<SuggestionRawDefinition> & { text: string };
export const TIME_PICKER_SUGGESTION: PartialSuggestionWithText = {
text: '',
label: 'Choose from the time picker',
};
export const triggerCharacters = [',', '(', '=', ' '];
export const fields: Array<{ name: string; type: string; suggestedAs?: string }> = [
@ -224,7 +231,7 @@ export function getLiteralsByType(_type: string | string[]) {
export function getDateLiteralsByFieldType(_requestedType: string | string[]) {
const requestedType = Array.isArray(_requestedType) ? _requestedType : [_requestedType];
return requestedType.includes('date') ? TIME_SYSTEM_PARAMS : [];
return requestedType.includes('date') ? [TIME_PICKER_SUGGESTION, ...TIME_SYSTEM_PARAMS] : [];
}
export function createCustomCallbackMocks(

View file

@ -25,12 +25,14 @@ import {
createCustomCallbackMocks,
createCompletionContext,
getPolicyFields,
PartialSuggestionWithText,
TIME_PICKER_SUGGESTION,
} from './__tests__/helpers';
describe('autocomplete', () => {
type TestArgs = [
string,
string[],
Array<string | PartialSuggestionWithText>,
string?,
number?,
Parameters<typeof createCustomCallbackMocks>?
@ -39,7 +41,7 @@ describe('autocomplete', () => {
const _testSuggestionsFn = (
{ only, skip }: { only?: boolean; skip?: boolean } = {},
statement: string,
expected: string[],
expected: Array<string | PartialSuggestionWithText>,
triggerCharacter?: string,
_offset?: number,
customCallbacksArgs: Parameters<typeof createCustomCallbackMocks> = [
@ -66,10 +68,20 @@ describe('autocomplete', () => {
callbackMocks
);
const sortedSuggestions = suggestions.map((suggestion) => suggestion.text).sort();
const sortedExpected = expected.sort();
const sortedSuggestionTexts = suggestions.map((suggestion) => suggestion.text).sort();
const sortedExpectedTexts = expected
.map((suggestion) => (typeof suggestion === 'string' ? suggestion : suggestion.text ?? ''))
.sort();
expect(sortedSuggestions).toEqual(sortedExpected);
expect(sortedSuggestionTexts).toEqual(sortedExpectedTexts);
const expectedNonStringSuggestions = expected.filter(
(suggestion) => typeof suggestion !== 'string'
) as PartialSuggestionWithText[];
for (const expectedSuggestion of expectedNonStringSuggestions) {
const suggestion = suggestions.find((s) => s.text === expectedSuggestion.text);
expect(suggestion).toEqual(expect.objectContaining(expectedSuggestion));
}
});
};
@ -755,28 +767,30 @@ describe('autocomplete', () => {
const suggestedConstants = param.literalSuggestions || param.literalOptions;
const addCommaIfRequired = (s: string | PartialSuggestionWithText) => {
// don't add commas to the empty string or if there are no more required args
if (!requiresMoreArgs || s === '' || (typeof s === 'object' && s.text === '')) {
return s;
}
return typeof s === 'string' ? `${s},` : { ...s, text: `${s.text},` };
};
testSuggestions(
`from a | eval ${fn.name}(${Array(i).fill('field').join(', ')}${i ? ',' : ''} )`,
suggestedConstants?.length
? suggestedConstants.map((option) => `"${option}"${requiresMoreArgs ? ',' : ''}`)
: [
...getDateLiteralsByFieldType(
getTypesFromParamDefs(acceptsFieldParamDefs)
).map((l) => (requiresMoreArgs ? `${l},` : l)),
...getFieldNamesByType(getTypesFromParamDefs(acceptsFieldParamDefs)).map(
(f) => (requiresMoreArgs ? `${f},` : f)
),
...getDateLiteralsByFieldType(getTypesFromParamDefs(acceptsFieldParamDefs)),
...getFieldNamesByType(getTypesFromParamDefs(acceptsFieldParamDefs)),
...getFunctionSignaturesByReturnType(
'eval',
getTypesFromParamDefs(acceptsFieldParamDefs),
{ evalMath: true },
undefined,
[fn.name]
).map((l) => (requiresMoreArgs ? `${l},` : l)),
...getLiteralsByType(getTypesFromParamDefs(constantOnlyParamDefs)).map((d) =>
requiresMoreArgs ? `${d},` : d
),
],
...getLiteralsByType(getTypesFromParamDefs(constantOnlyParamDefs)),
].map(addCommaIfRequired),
' '
);
testSuggestions(
@ -786,23 +800,17 @@ describe('autocomplete', () => {
suggestedConstants?.length
? suggestedConstants.map((option) => `"${option}"${requiresMoreArgs ? ',' : ''}`)
: [
...getDateLiteralsByFieldType(
getTypesFromParamDefs(acceptsFieldParamDefs)
).map((l) => (requiresMoreArgs ? `${l},` : l)),
...getFieldNamesByType(getTypesFromParamDefs(acceptsFieldParamDefs)).map(
(f) => (requiresMoreArgs ? `${f},` : f)
),
...getDateLiteralsByFieldType(getTypesFromParamDefs(acceptsFieldParamDefs)),
...getFieldNamesByType(getTypesFromParamDefs(acceptsFieldParamDefs)),
...getFunctionSignaturesByReturnType(
'eval',
getTypesFromParamDefs(acceptsFieldParamDefs),
{ evalMath: true },
undefined,
[fn.name]
).map((l) => (requiresMoreArgs ? `${l},` : l)),
...getLiteralsByType(getTypesFromParamDefs(constantOnlyParamDefs)).map((d) =>
requiresMoreArgs ? `${d},` : d
),
],
...getLiteralsByType(getTypesFromParamDefs(constantOnlyParamDefs)),
].map(addCommaIfRequired),
' '
);
}
@ -860,12 +868,13 @@ describe('autocomplete', () => {
testSuggestions(
'from a | eval var0=date_trunc()',
[
...TIME_SYSTEM_PARAMS.map((t) => `${t},`),
...[...TIME_SYSTEM_PARAMS].map((t) => `${t},`),
...getLiteralsByType('time_literal').map((t) => `${t},`),
...getFunctionSignaturesByReturnType('eval', 'date', { evalMath: true }, undefined, [
'date_trunc',
]).map((t) => `${t},`),
...getFieldNamesByType('date').map((t) => `${t},`),
TIME_PICKER_SUGGESTION,
],
'('
);

View file

@ -394,11 +394,39 @@ export function getCompatibleLiterals(commandName: string, types: string[], name
}
export function getDateLiterals() {
return buildConstantsDefinitions(
TIME_SYSTEM_PARAMS,
i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.namedParamDefinition', {
defaultMessage: 'Named parameter',
}),
'1A'
);
return [
...buildConstantsDefinitions(
TIME_SYSTEM_PARAMS,
i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.namedParamDefinition', {
defaultMessage: 'Named parameter',
}),
'1A'
),
{
label: i18n.translate(
'kbn-esql-validation-autocomplete.esql.autocomplete.chooseFromTimePickerLabel',
{
defaultMessage: 'Choose from the time picker',
}
),
text: '',
kind: 'Issue',
detail: i18n.translate(
'kbn-esql-validation-autocomplete.esql.autocomplete.chooseFromTimePicker',
{
defaultMessage: 'Click to choose',
}
),
sortText: '1A',
command: {
id: 'esql.timepicker.choose',
title: i18n.translate(
'kbn-esql-validation-autocomplete.esql.autocomplete.chooseFromTimePicker',
{
defaultMessage: 'Click to choose',
}
),
},
} as SuggestionRawDefinition,
];
}

View file

@ -14,7 +14,9 @@ import {
EuiOutsideClickDetector,
EuiToolTip,
useEuiTheme,
EuiDatePicker,
} from '@elastic/eui';
import moment from 'moment';
import { CodeEditor, CodeEditorProps } from '@kbn/code-editor';
import type { CoreStart } from '@kbn/core/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
@ -33,6 +35,7 @@ import { ESQLLang, ESQL_LANG_ID, ESQL_THEME_ID, monaco, type ESQLCallbacks } fro
import classNames from 'classnames';
import memoize from 'lodash/memoize';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { css } from '@emotion/react';
import { EditorFooter } from './editor_footer';
import { ErrorsWarningsCompactViewPopover } from './errors_warnings_popover';
@ -143,6 +146,7 @@ let clickedOutside = false;
let initialRender = true;
let updateLinesFromModel = false;
let lines = 1;
let isDatePickerOpen = false;
export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
query,
@ -166,6 +170,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
hideQueryHistory,
hideHeaderWhenExpanded,
}: TextBasedLanguagesEditorProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const { euiTheme } = useEuiTheme();
const language = getAggregateQueryMode(query);
const queryString: string = query[language] ?? '';
@ -187,7 +192,8 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
const [editorHeight, setEditorHeight] = useState(
isCodeEditorExpanded ? EDITOR_INITIAL_HEIGHT_EXPANDED : EDITOR_INITIAL_HEIGHT
);
const [popoverPosition, setPopoverPosition] = useState<{ top?: number; left?: number }>({});
const [timePickerDate, setTimePickerDate] = useState(moment());
const [measuredEditorWidth, setMeasuredEditorWidth] = useState(0);
const [measuredContentWidth, setMeasuredContentWidth] = useState(0);
@ -291,6 +297,24 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
setIsHistoryOpen(status);
}, []);
const openTimePickerPopover = useCallback(() => {
const currentCursorPosition = editor1.current?.getPosition();
const editorCoords = editor1.current?.getDomNode()!.getBoundingClientRect();
if (currentCursorPosition && editorCoords) {
const editorPosition = editor1.current!.getScrolledVisiblePosition(currentCursorPosition);
const editorTop = editorCoords.top;
const editorLeft = editorCoords.left;
// Calculate the absolute position of the popover
const absoluteTop = editorTop + (editorPosition?.top ?? 0) + 20;
const absoluteLeft = editorLeft + (editorPosition?.left ?? 0);
setPopoverPosition({ top: absoluteTop, left: absoluteLeft });
isDatePickerOpen = true;
popoverRef.current?.focus();
}
}, []);
// Registers a command to redirect users to the index management page
// to create a new policy. The command is called by the buildNoPoliciesAvailableDefinition
monaco.editor.registerCommand('esql.policies.create', (...args) => {
@ -300,6 +324,10 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
});
});
monaco.editor.registerCommand('esql.timepicker.choose', (...args) => {
openTimePickerPopover();
});
const styles = textBasedLanguageEditorStyles(
euiTheme,
isCompactFocused,
@ -933,6 +961,9 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
setTimeout(() => {
editor.focus();
}, 100);
if (isDatePickerOpen) {
setPopoverPosition({});
}
});
editor.onDidFocusEditorText(() => {
@ -1107,6 +1138,65 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
editorIsInline={editorIsInline}
/>
)}
{createPortal(
Object.keys(popoverPosition).length !== 0 && popoverPosition.constructor === Object && (
<div
tabIndex={0}
style={{
...popoverPosition,
backgroundColor: euiTheme.colors.emptyShade,
borderRadius: euiTheme.border.radius.small,
position: 'absolute',
overflow: 'auto',
}}
ref={popoverRef}
data-test-subj="TextBasedLangEditor-timepicker-popover"
>
<EuiDatePicker
selected={timePickerDate}
autoFocus
onChange={(date) => {
if (date) {
setTimePickerDate(date);
}
}}
onSelect={(date, event) => {
if (date && event) {
const currentCursorPosition = editor1.current?.getPosition();
const lineContent = editorModel.current?.getLineContent(
currentCursorPosition?.lineNumber ?? 0
);
const contentAfterCursor = lineContent?.substring(
(currentCursorPosition?.column ?? 0) - 1,
lineContent.length + 1
);
const addition = `"${date.toISOString()}"${contentAfterCursor}`;
editor1.current?.executeEdits('time', [
{
range: {
startLineNumber: currentCursorPosition?.lineNumber ?? 0,
startColumn: currentCursorPosition?.column ?? 0,
endLineNumber: currentCursorPosition?.lineNumber ?? 0,
endColumn: (currentCursorPosition?.column ?? 0) + addition.length + 1,
},
text: addition,
forceMoveMarkers: true,
},
]);
setPopoverPosition({});
isDatePickerOpen = false;
}
}}
inline
showTimeSelect={true}
shadow={true}
/>
</div>
),
document.body
)}
</>
);