mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[Console] Fix autocomplete insertText (#215911)
Fixes https://github.com/elastic/kibana/issues/212851 ## Summary This PR fixes the autocomplete insert text, which was incorrectly always adding a template due to the changes made in https://github.com/elastic/kibana/pull/210187. This PR reverts most of these changes and instead fixes https://github.com/elastic/kibana/issues/208862 by fixing the value of `context.addTemplate`. It also adds unit tests for the `getInsertText` function. Requests to test: **Test 1:** ``` GET index/_search {"query": {te}} ``` should autocomplete to ```GET index/_search { "query": { "term": { "FIELD": { "value": "VALUE" } } } } ``` Same for the request below: ``` GET index/_search { "query": { te } ``` **Test 2:** In the following request, deleting `AGG_TYPE` and replacing it with `terms` is correctly autocompleted: ``` GET /_search { "aggs": { "NAME": { "AGG_TYPE": {} } } } ``` autocomplete to: ``` GET /_search { "aggs": { "NAME": { "terms": {} } } } ``` **Test 3:** Insert the following request ``` GET /_search { "query": { "match_all": {} } } ``` Put the cursor at the end of the `match_all` field (right before the closing quote) and then delete a few of the last characters. Retype one character in order to get the suggestions popup displayed. Then press Enter to add a suggestion. Verify that the suggestion is added with no extra quote in the beginning. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
0f1dd979cd
commit
f1c61e43b0
2 changed files with 114 additions and 87 deletions
|
@ -11,7 +11,6 @@
|
|||
* Mock the function "populateContext" that accesses the autocomplete definitions
|
||||
*/
|
||||
import { monaco } from '@kbn/monaco';
|
||||
import { MonacoEditorActionsProvider } from '../monaco_editor_actions_provider';
|
||||
|
||||
const mockPopulateContext = jest.fn();
|
||||
|
||||
|
@ -22,12 +21,12 @@ jest.mock('../../../../lib/autocomplete/engine', () => {
|
|||
},
|
||||
};
|
||||
});
|
||||
import { AutoCompleteContext } from '../../../../lib/autocomplete/types';
|
||||
import { AutoCompleteContext, ResultTerm } from '../../../../lib/autocomplete/types';
|
||||
import {
|
||||
getDocumentationLinkFromAutocomplete,
|
||||
getUrlPathCompletionItems,
|
||||
shouldTriggerSuggestions,
|
||||
getBodyCompletionItems,
|
||||
getInsertText,
|
||||
} from './autocomplete_utils';
|
||||
|
||||
describe('autocomplete_utils', () => {
|
||||
|
@ -219,79 +218,78 @@ describe('autocomplete_utils', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('inline JSON body completion', () => {
|
||||
it('completes "term" inside {"query": {te}} without extra quotes or missing template', async () => {
|
||||
// 1) Set up a mock monaco model with two lines of text
|
||||
// - Line 1: GET index/_search
|
||||
// - Line 2: {"query": {te}}
|
||||
// In a real editor, requestStartLineNumber = 1 (0-based vs 1-based might differ),
|
||||
// so we adjust accordingly in the test.
|
||||
const mockModel = {
|
||||
getLineContent: (lineNumber: number) => {
|
||||
if (lineNumber === 1) {
|
||||
// request line
|
||||
return 'GET index/_search';
|
||||
} else if (lineNumber === 2) {
|
||||
// inline JSON with partial property 'te'
|
||||
return '{"query": {te}}';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
// getValueInRange will return everything from line 2 up to our position
|
||||
getValueInRange: ({ startLineNumber, endLineNumber }: monaco.IRange) => {
|
||||
if (startLineNumber === 2 && endLineNumber === 2) {
|
||||
// partial body up to cursor (we can just return the entire line for simplicity)
|
||||
return '{"query": {te}}';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
getWordUntilPosition: () => ({
|
||||
startColumn: 13, // approximate "te" start
|
||||
endColumn: 15,
|
||||
word: 'te',
|
||||
}),
|
||||
getLineMaxColumn: () => 999, // large max
|
||||
} as unknown as monaco.editor.ITextModel;
|
||||
describe('getInsertText', () => {
|
||||
const mockContext = { addTemplate: false } as AutoCompleteContext;
|
||||
|
||||
// 2) The user is on line 2, at column ~15 (after 'te').
|
||||
const mockPosition = {
|
||||
lineNumber: 2,
|
||||
column: 15,
|
||||
} as monaco.Position;
|
||||
it('returns empty string if name is undefined', () => {
|
||||
expect(getInsertText({ name: undefined } as ResultTerm, '', mockContext)).toBe('');
|
||||
});
|
||||
|
||||
mockPopulateContext.mockImplementation((...args) => {
|
||||
const context = args[0][1];
|
||||
context.autoCompleteSet = [
|
||||
{
|
||||
name: 'term',
|
||||
},
|
||||
];
|
||||
});
|
||||
it('handles unclosed quotes correctly', () => {
|
||||
expect(
|
||||
getInsertText(
|
||||
{ name: 'match_all' } as ResultTerm,
|
||||
'{\n' + ' "query": {\n' + ' "match_a',
|
||||
mockContext
|
||||
)
|
||||
).toBe('match_all"');
|
||||
});
|
||||
|
||||
// 4) We call getBodyCompletionItems, passing requestStartLineNumber = 1
|
||||
// because line 1 has "GET index/_search", so line 2 is the body.
|
||||
const mockEditor = {} as MonacoEditorActionsProvider;
|
||||
const suggestions = await getBodyCompletionItems(
|
||||
mockModel,
|
||||
mockPosition,
|
||||
1, // the line number where the request method/URL is
|
||||
mockEditor
|
||||
it('wraps insertValue with quotes when appropriate', () => {
|
||||
expect(
|
||||
getInsertText(
|
||||
{ name: 'match_all' } as ResultTerm,
|
||||
'{\n' + ' "query": {\n' + ' ',
|
||||
mockContext
|
||||
)
|
||||
).toBe('"match_all"');
|
||||
});
|
||||
|
||||
it('appends template when available and context.addTemplate is true', () => {
|
||||
expect(
|
||||
getInsertText({ name: 'query', template: {} } as ResultTerm, '{\n' + ' ', {
|
||||
...mockContext,
|
||||
addTemplate: true,
|
||||
})
|
||||
).toBe('"query": {$0}');
|
||||
});
|
||||
|
||||
it('inserts template when provided directly and context.addTemplate is true', () => {
|
||||
expect(
|
||||
getInsertText(
|
||||
{ name: 'terms', template: { field: '' } },
|
||||
'{\n' + ' "aggs": {\n' + ' "NAME": {\n' + ' "',
|
||||
{ ...mockContext, addTemplate: true }
|
||||
)
|
||||
).toBe('terms": {\n' + ' "field": ""\n' + '}');
|
||||
});
|
||||
|
||||
it('inserts only field name when template is provided and context.addTemplate is false', () => {
|
||||
expect(
|
||||
getInsertText(
|
||||
{ name: 'terms', template: { field: '' } },
|
||||
'{\n' + ' "aggs": {\n' + ' "NAME": {\n' + ' "',
|
||||
mockContext
|
||||
)
|
||||
).toBe('terms"');
|
||||
});
|
||||
|
||||
it('inserts template inline', () => {
|
||||
expect(
|
||||
getInsertText({ name: 'term', template: { FIELD: { value: 'VALUE' } } }, '{"query": {te', {
|
||||
...mockContext,
|
||||
addTemplate: true,
|
||||
})
|
||||
).toBe('"term": {\n' + ' "FIELD": {\n' + ' "value": "VALUE"\n' + ' }\n' + '}');
|
||||
});
|
||||
|
||||
it('adds cursor placeholder inside empty objects and arrays', () => {
|
||||
expect(getInsertText({ name: 'field', value: '{' } as ResultTerm, '', mockContext)).toBe(
|
||||
'"field": {$0}'
|
||||
);
|
||||
expect(getInsertText({ name: 'field', value: '[' } as ResultTerm, '', mockContext)).toBe(
|
||||
'"field": [$0]'
|
||||
);
|
||||
|
||||
// 5) We should get 1 suggestion for "term"
|
||||
expect(suggestions).toHaveLength(1);
|
||||
const termSuggestion = suggestions[0];
|
||||
|
||||
// 6) Check the snippet text. For example, if your final snippet logic
|
||||
// inserts `"term": $0`, we ensure there's no extra quote like ""term"
|
||||
// and if you have a template for "term", we can check that too.
|
||||
const insertText = termSuggestion.insertText;
|
||||
|
||||
// No double quotes at the start:
|
||||
expect(insertText).not.toContain('""term"');
|
||||
// Valid JSON snippet
|
||||
expect(insertText).toContain('"term"');
|
||||
expect(insertText).toContain('$0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -305,9 +305,10 @@ const getSuggestions = (
|
|||
endLineNumber: position.lineNumber,
|
||||
endColumn: model.getLineMaxColumn(position.lineNumber),
|
||||
});
|
||||
// if the rest of the line is empty or there is only "
|
||||
// if the rest of the line is empty or there is only " or ends with closing parentheses
|
||||
// then template can be inserted, otherwise only name
|
||||
context.addTemplate = isEmptyOrDoubleQuote(lineContentAfterPosition);
|
||||
context.addTemplate =
|
||||
isEmptyOrDoubleQuote(lineContentAfterPosition) || /^}*$/.test(lineContentAfterPosition);
|
||||
|
||||
// if there is " after the cursor, include it in the insert range
|
||||
let endColumn = position.column;
|
||||
|
@ -340,7 +341,7 @@ const getSuggestions = (
|
|||
})
|
||||
);
|
||||
};
|
||||
const getInsertText = (
|
||||
export const getInsertText = (
|
||||
{ name, insertValue, template, value }: ResultTerm,
|
||||
bodyContent: string,
|
||||
context: AutoCompleteContext
|
||||
|
@ -349,10 +350,25 @@ const getInsertText = (
|
|||
return '';
|
||||
}
|
||||
|
||||
// Always create the insert text with the name first, check the end of the body content
|
||||
// to decide if we need to add a double quote after the name.
|
||||
// This is done to avoid adding a double quote if the user is typing a value after the name.
|
||||
let insertText = bodyContent.trim().endsWith('"') ? `${name}"` : `"${name}"`;
|
||||
let insertText = '';
|
||||
if (typeof name === 'string') {
|
||||
const bodyContentLines = bodyContent.split('\n');
|
||||
const currentContentLine = bodyContentLines[bodyContentLines.length - 1].trim();
|
||||
if (hasUnclosedQuote(currentContentLine)) {
|
||||
// The cursor is after an unmatched quote (e.g. '..."abc', '..."')
|
||||
insertText = '';
|
||||
} else {
|
||||
// The cursor is at the beginning of a field so the insert text should start with a quote
|
||||
insertText = '"';
|
||||
}
|
||||
if (insertValue && insertValue !== '{' && insertValue !== '[') {
|
||||
insertText += `${insertValue}"`;
|
||||
} else {
|
||||
insertText += `${name}"`;
|
||||
}
|
||||
} else {
|
||||
insertText = name + '';
|
||||
}
|
||||
|
||||
// check if there is template to add
|
||||
const conditionalTemplate = getConditionalTemplate(name, bodyContent, context.endpoint);
|
||||
|
@ -360,7 +376,7 @@ const getInsertText = (
|
|||
template = conditionalTemplate;
|
||||
}
|
||||
|
||||
if (template) {
|
||||
if (template && context.addTemplate) {
|
||||
let templateLines;
|
||||
const { __raw, value: templateValue } = template;
|
||||
if (__raw && templateValue) {
|
||||
|
@ -370,14 +386,9 @@ const getInsertText = (
|
|||
}
|
||||
insertText += ': ' + templateLines.join('\n');
|
||||
} else if (value === '{') {
|
||||
insertText += ': {$0}';
|
||||
insertText += ': {}';
|
||||
} else if (value === '[') {
|
||||
insertText += ': [$0]';
|
||||
} else if (insertValue && insertValue !== '{' && insertValue !== '[') {
|
||||
insertText = `"${insertValue}"`;
|
||||
insertText += ': $0';
|
||||
} else {
|
||||
insertText += ': $0';
|
||||
insertText += ': []';
|
||||
}
|
||||
|
||||
// the string $0 is used to move the cursor between empty curly/square brackets
|
||||
|
@ -441,3 +452,21 @@ export const isEmptyOrDoubleQuote = (lineContent: string): boolean => {
|
|||
lineContent = lineContent.trim();
|
||||
return !lineContent || lineContent === '"';
|
||||
};
|
||||
|
||||
export const hasUnclosedQuote = (lineContent: string): boolean => {
|
||||
let insideString = false;
|
||||
let prevChar = '';
|
||||
for (let i = 0; i < lineContent.length; i++) {
|
||||
const char = lineContent[i];
|
||||
|
||||
if (!insideString && char === '"') {
|
||||
insideString = true;
|
||||
} else if (insideString && char === '"' && prevChar !== '\\') {
|
||||
insideString = false;
|
||||
}
|
||||
|
||||
prevChar = char;
|
||||
}
|
||||
|
||||
return insideString;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue