[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:
Elena Stoeva 2025-03-28 12:17:15 +00:00 committed by GitHub
parent 0f1dd979cd
commit f1c61e43b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 114 additions and 87 deletions

View file

@ -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');
});
});
});

View file

@ -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;
};