Skip to content

Commit

Permalink
[ES|QL] Integrating the editor with the time picker (#187047)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
stratoula and drewdaemon authored Jul 24, 2024
1 parent f5d9b9d commit dd25429
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 }> = [
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>?
Expand All @@ -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> = [
Expand All @@ -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));
}
});
};

Expand Down Expand Up @@ -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(
Expand All @@ -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),
' '
);
}
Expand Down Expand Up @@ -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,
],
'('
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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] ?? '';
Expand All @@ -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);

Expand Down Expand Up @@ -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) => {
Expand All @@ -300,6 +324,10 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
});
});

monaco.editor.registerCommand('esql.timepicker.choose', (...args) => {
openTimePickerPopover();
});

const styles = textBasedLanguageEditorStyles(
euiTheme,
isCompactFocused,
Expand Down Expand Up @@ -933,6 +961,9 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
setTimeout(() => {
editor.focus();
}, 100);
if (isDatePickerOpen) {
setPopoverPosition({});
}
});

editor.onDidFocusEditorText(() => {
Expand Down Expand Up @@ -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
)}
</>
);

Expand Down

0 comments on commit dd25429

Please sign in to comment.