Skip to content

Commit b9aa9aa

Browse files
committed
fix(graphql-language-service): wrap list input values in brackets on autocomplete
Completing an enum or boolean value for a list-typed argument or input field (e.g. `[Episode]`) inserted a bare `JEDI`, which isn't valid there. The online parser already unwraps one list level once the caret is inside a `[ ]`, so the remaining list depth tells us how many brackets to add — this also covers nested lists like `[[Episode]]`. Values typed inside an existing list literal, single (non-list) values and variables are left untouched. Closes #587
1 parent d8d6dad commit b9aa9aa

3 files changed

Lines changed: 124 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'graphql-language-service': patch
3+
---
4+
5+
Wrap autocompleted list input values in square brackets
6+
7+
When you complete an enum or boolean value for a list-typed argument or input field (e.g. `[Episode]`), the suggestion now inserts `[JEDI]` instead of a bare `JEDI`, which produced an invalid query. Values completed inside an existing list literal, and non-list values, are left as they were.

packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,3 +1023,96 @@ describe('getAutocompleteSuggestions', () => {
10231023
]));
10241024
});
10251025
});
1026+
1027+
describe('getAutocompleteSuggestions - list input values (#587)', () => {
1028+
const listSchema = buildSchema(`
1029+
enum Episode { NEWHOPE EMPIRE JEDI }
1030+
input FilterInput {
1031+
episodes: [Episode]
1032+
one: Episode
1033+
}
1034+
type Query {
1035+
listEnum(values: [Episode]): String
1036+
listEnumNonNull(values: [Episode!]!): String
1037+
listBool(flags: [Boolean]): String
1038+
singleEnum(value: Episode): String
1039+
nestedList(values: [[Episode]]): String
1040+
nested(input: FilterInput): String
1041+
}
1042+
`);
1043+
1044+
function valueSuggestions(query: string, label: string) {
1045+
const caret = query.indexOf('|');
1046+
const text = query.replace('|', '');
1047+
const before = text.slice(0, caret);
1048+
const line = before.split('\n').length - 1;
1049+
const character = before.length - (before.lastIndexOf('\n') + 1);
1050+
return getAutocompleteSuggestions(
1051+
listSchema,
1052+
text,
1053+
new Position(line, character),
1054+
)
1055+
.filter(s => s.label === label)
1056+
.map(s => ({ label: s.label, insertText: s.insertText }));
1057+
}
1058+
1059+
it('wraps an enum value in brackets at a list argument value position', () => {
1060+
expect(valueSuggestions('{ listEnum(values: |) }', 'JEDI')).toEqual([
1061+
{ label: 'JEDI', insertText: '[JEDI]' },
1062+
]);
1063+
});
1064+
1065+
it('wraps an enum value for a non-null list of non-null items', () => {
1066+
expect(valueSuggestions('{ listEnumNonNull(values: |) }', 'JEDI')).toEqual([
1067+
{ label: 'JEDI', insertText: '[JEDI]' },
1068+
]);
1069+
});
1070+
1071+
it('wraps a boolean value in brackets at a list argument value position', () => {
1072+
expect(valueSuggestions('{ listBool(flags: |) }', 'true')).toEqual([
1073+
{ label: 'true', insertText: '[true]' },
1074+
]);
1075+
});
1076+
1077+
it('wraps an enum value at a list input-object field value position', () => {
1078+
expect(
1079+
valueSuggestions('{ nested(input: { episodes: | }) }', 'JEDI'),
1080+
).toEqual([{ label: 'JEDI', insertText: '[JEDI]' }]);
1081+
});
1082+
1083+
it('does not wrap when the cursor is already inside the list literal', () => {
1084+
expect(valueSuggestions('{ listEnum(values: [|]) }', 'JEDI')).toEqual([
1085+
{ label: 'JEDI', insertText: undefined },
1086+
]);
1087+
});
1088+
1089+
it('does not wrap a non-list (single) enum argument value', () => {
1090+
expect(valueSuggestions('{ singleEnum(value: |) }', 'JEDI')).toEqual([
1091+
{ label: 'JEDI', insertText: undefined },
1092+
]);
1093+
});
1094+
1095+
it('does not wrap a non-list (single) input-object enum field value', () => {
1096+
expect(valueSuggestions('{ nested(input: { one: | }) }', 'JEDI')).toEqual([
1097+
{ label: 'JEDI', insertText: undefined },
1098+
]);
1099+
});
1100+
1101+
it('wraps a nested list value with the matching number of brackets', () => {
1102+
expect(valueSuggestions('{ nestedList(values: |) }', 'JEDI')).toEqual([
1103+
{ label: 'JEDI', insertText: '[[JEDI]]' },
1104+
]);
1105+
});
1106+
1107+
it('wraps once when inside the outer bracket of a nested list', () => {
1108+
expect(valueSuggestions('{ nestedList(values: [|]) }', 'JEDI')).toEqual([
1109+
{ label: 'JEDI', insertText: '[JEDI]' },
1110+
]);
1111+
});
1112+
1113+
it('does not wrap when inside both brackets of a nested list', () => {
1114+
expect(valueSuggestions('{ nestedList(values: [[|]]) }', 'JEDI')).toEqual([
1115+
{ label: 'JEDI', insertText: undefined },
1116+
]);
1117+
});
1118+
});

packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import {
3939
assertAbstractType,
4040
doTypesOverlap,
4141
getNamedType,
42+
getNullableType,
43+
isListType,
4244
isAbstractType,
4345
isCompositeType,
4446
isInputType,
@@ -575,6 +577,25 @@ function getSuggestionsForInputValues(
575577
): Array<CompletionItem> {
576578
const namedInputType = getNamedType(typeInfo.inputType!);
577579

580+
// When the value position expects a list (e.g. `[Episode]`), a bare literal
581+
// like `JEDI` is invalid — it must be wrapped as `[JEDI]`. The online parser
582+
// unwraps one list level each time the cursor moves inside a list literal
583+
// (`[ | ]`), so the number of list wrappers still present here is exactly how
584+
// many brackets we need to add (handles nested lists like `[[Episode]]` too).
585+
// Variable completions ($var) reference the whole list and must not be wrapped.
586+
let listDepth = 0;
587+
let unwrapped: GraphQLType | null | undefined = getNullableType(
588+
typeInfo.inputType,
589+
);
590+
while (unwrapped && isListType(unwrapped)) {
591+
listDepth++;
592+
unwrapped = getNullableType(unwrapped.ofType);
593+
}
594+
const wrap = (literal: string): string | undefined =>
595+
listDepth > 0
596+
? '['.repeat(listDepth) + literal + ']'.repeat(listDepth)
597+
: undefined;
598+
578599
const queryVariables: CompletionItem[] = getVariableCompletions(
579600
queryText,
580601
schema,
@@ -588,6 +609,7 @@ function getSuggestionsForInputValues(
588609
values
589610
.map<CompletionItem>((value: GraphQLEnumValue) => ({
590611
label: value.name,
612+
insertText: wrap(value.name),
591613
detail: String(namedInputType),
592614
documentation: value.description ?? undefined,
593615
deprecated: Boolean(value.deprecationReason),
@@ -605,13 +627,15 @@ function getSuggestionsForInputValues(
605627
queryVariables.concat([
606628
{
607629
label: 'true',
630+
insertText: wrap('true'),
608631
detail: String(GraphQLBoolean),
609632
documentation: 'Not false.',
610633
kind: CompletionItemKind.Variable,
611634
type: GraphQLBoolean,
612635
},
613636
{
614637
label: 'false',
638+
insertText: wrap('false'),
615639
detail: String(GraphQLBoolean),
616640
documentation: 'Not true.',
617641
kind: CompletionItemKind.Variable,

0 commit comments

Comments
 (0)