Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/wrap-list-input-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'graphql-language-service': patch
---

Wrap autocompleted list input values in square brackets

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.
Original file line number Diff line number Diff line change
Expand Up @@ -1023,3 +1023,96 @@ describe('getAutocompleteSuggestions', () => {
]));
});
});

describe('getAutocompleteSuggestions - list input values (#587)', () => {
const listSchema = buildSchema(`
enum Episode { NEWHOPE EMPIRE JEDI }
input FilterInput {
episodes: [Episode]
one: Episode
}
type Query {
listEnum(values: [Episode]): String
listEnumNonNull(values: [Episode!]!): String
listBool(flags: [Boolean]): String
singleEnum(value: Episode): String
nestedList(values: [[Episode]]): String
nested(input: FilterInput): String
}
`);

function valueSuggestions(query: string, label: string) {
const caret = query.indexOf('|');
const text = query.replace('|', '');
const before = text.slice(0, caret);
const line = before.split('\n').length - 1;
const character = before.length - (before.lastIndexOf('\n') + 1);
return getAutocompleteSuggestions(
listSchema,
text,
new Position(line, character),
)
.filter(s => s.label === label)
.map(s => ({ label: s.label, insertText: s.insertText }));
}

it('wraps an enum value in brackets at a list argument value position', () => {
expect(valueSuggestions('{ listEnum(values: |) }', 'JEDI')).toEqual([
{ label: 'JEDI', insertText: '[JEDI]' },
]);
});

it('wraps an enum value for a non-null list of non-null items', () => {
expect(valueSuggestions('{ listEnumNonNull(values: |) }', 'JEDI')).toEqual([
{ label: 'JEDI', insertText: '[JEDI]' },
]);
});

it('wraps a boolean value in brackets at a list argument value position', () => {
expect(valueSuggestions('{ listBool(flags: |) }', 'true')).toEqual([
{ label: 'true', insertText: '[true]' },
]);
});

it('wraps an enum value at a list input-object field value position', () => {
expect(
valueSuggestions('{ nested(input: { episodes: | }) }', 'JEDI'),
).toEqual([{ label: 'JEDI', insertText: '[JEDI]' }]);
});

it('does not wrap when the cursor is already inside the list literal', () => {
expect(valueSuggestions('{ listEnum(values: [|]) }', 'JEDI')).toEqual([
{ label: 'JEDI', insertText: undefined },
]);
});

it('does not wrap a non-list (single) enum argument value', () => {
expect(valueSuggestions('{ singleEnum(value: |) }', 'JEDI')).toEqual([
{ label: 'JEDI', insertText: undefined },
]);
});

it('does not wrap a non-list (single) input-object enum field value', () => {
expect(valueSuggestions('{ nested(input: { one: | }) }', 'JEDI')).toEqual([
{ label: 'JEDI', insertText: undefined },
]);
});

it('wraps a nested list value with the matching number of brackets', () => {
expect(valueSuggestions('{ nestedList(values: |) }', 'JEDI')).toEqual([
{ label: 'JEDI', insertText: '[[JEDI]]' },
]);
});

it('wraps once when inside the outer bracket of a nested list', () => {
expect(valueSuggestions('{ nestedList(values: [|]) }', 'JEDI')).toEqual([
{ label: 'JEDI', insertText: '[JEDI]' },
]);
});

it('does not wrap when inside both brackets of a nested list', () => {
expect(valueSuggestions('{ nestedList(values: [[|]]) }', 'JEDI')).toEqual([
{ label: 'JEDI', insertText: undefined },
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
assertAbstractType,
doTypesOverlap,
getNamedType,
getNullableType,
isListType,
isAbstractType,
isCompositeType,
isInputType,
Expand Down Expand Up @@ -575,6 +577,25 @@ function getSuggestionsForInputValues(
): Array<CompletionItem> {
const namedInputType = getNamedType(typeInfo.inputType!);

// When the value position expects a list (e.g. `[Episode]`), a bare literal
// like `JEDI` is invalid — it must be wrapped as `[JEDI]`. The online parser
// unwraps one list level each time the cursor moves inside a list literal
// (`[ | ]`), so the number of list wrappers still present here is exactly how
// many brackets we need to add (handles nested lists like `[[Episode]]` too).
// Variable completions ($var) reference the whole list and must not be wrapped.
let listDepth = 0;
let unwrapped: GraphQLType | null | undefined = getNullableType(
typeInfo.inputType,
);
while (unwrapped && isListType(unwrapped)) {
listDepth++;
unwrapped = getNullableType(unwrapped.ofType);
}
const wrap = (literal: string): string | undefined =>
listDepth > 0
? '['.repeat(listDepth) + literal + ']'.repeat(listDepth)
: undefined;

const queryVariables: CompletionItem[] = getVariableCompletions(
queryText,
schema,
Expand All @@ -588,6 +609,7 @@ function getSuggestionsForInputValues(
values
.map<CompletionItem>((value: GraphQLEnumValue) => ({
label: value.name,
insertText: wrap(value.name),
detail: String(namedInputType),
documentation: value.description ?? undefined,
deprecated: Boolean(value.deprecationReason),
Expand All @@ -605,13 +627,15 @@ function getSuggestionsForInputValues(
queryVariables.concat([
{
label: 'true',
insertText: wrap('true'),
detail: String(GraphQLBoolean),
documentation: 'Not false.',
kind: CompletionItemKind.Variable,
type: GraphQLBoolean,
},
{
label: 'false',
insertText: wrap('false'),
detail: String(GraphQLBoolean),
documentation: 'Not true.',
kind: CompletionItemKind.Variable,
Expand Down
Loading