Skip to content

Commit 8e1fc2c

Browse files
authored
Merge pull request #3247 from github/koesie10/suggest-box-diagnostics
Add diagnostics to suggest box
2 parents ac5038c + d6e4b7e commit 8e1fc2c

File tree

3 files changed

+84
-7
lines changed

3 files changed

+84
-7
lines changed

extensions/ql-vscode/src/stories/common/SuggestBox.stories.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { styled } from "styled-components";
55
import { Codicon } from "../../view/common";
66
import { SuggestBox as SuggestBoxComponent } from "../../view/common/SuggestBox/SuggestBox";
77
import { useCallback, useState } from "react";
8+
import type { Diagnostic } from "../../view/common/SuggestBox/diagnostics";
89

910
export default {
1011
title: "Suggest Box",
@@ -141,6 +142,27 @@ export const AccessPath = Template.bind({});
141142
AccessPath.args = {
142143
options: suggestedOptions,
143144
parseValueToTokens: (value: string) => value.split("."),
145+
validateValue: (value: string) => {
146+
let index = value.indexOf("|");
147+
148+
const diagnostics: Diagnostic[] = [];
149+
150+
while (index !== -1) {
151+
// For testing in this Storybook, disallow pipe characters to avoid a dependency on the
152+
// real access path validation.
153+
index = value.indexOf("|", index + 1);
154+
155+
diagnostics.push({
156+
message: "This cannot contain |",
157+
range: {
158+
start: index,
159+
end: index + 1,
160+
},
161+
});
162+
}
163+
164+
return diagnostics;
165+
},
144166
getIcon: (option: StoryOption) => <Icon name={option.icon} />,
145167
getDetails: (option: StoryOption) => option.details,
146168
};

extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,25 @@ import {
1313
useListNavigation,
1414
useRole,
1515
} from "@floating-ui/react";
16-
import { styled } from "styled-components";
16+
import { css, styled } from "styled-components";
1717
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react";
1818
import type { Option } from "./options";
1919
import { findMatchingOptions } from "./options";
2020
import { SuggestBoxItem } from "./SuggestBoxItem";
2121
import { LabelText } from "./LabelText";
22+
import type { Diagnostic } from "./diagnostics";
2223

23-
const Input = styled(VSCodeTextField)`
24+
const Input = styled(VSCodeTextField)<{ $error: boolean }>`
2425
width: 430px;
2526
2627
font-family: var(--vscode-editor-font-family);
28+
29+
${(props) =>
30+
props.$error &&
31+
css`
32+
--dropdown-border: var(--vscode-inputValidation-errorBorder);
33+
--focus-border: var(--vscode-inputValidation-errorBorder);
34+
`}
2735
`;
2836

2937
const Container = styled.div`
@@ -51,7 +59,10 @@ const NoSuggestionsText = styled.div`
5159
padding-left: 22px;
5260
`;
5361

54-
export type SuggestBoxProps<T extends Option<T>> = {
62+
export type SuggestBoxProps<
63+
T extends Option<T>,
64+
D extends Diagnostic = Diagnostic,
65+
> = {
5566
value?: string;
5667
onChange: (value: string) => void;
5768
options: T[];
@@ -63,6 +74,12 @@ export type SuggestBoxProps<T extends Option<T>> = {
6374
*/
6475
parseValueToTokens: (value: string) => string[];
6576

77+
/**
78+
* Validate the value. This is used to show syntax errors in the input.
79+
* @param value The user-entered value to validate.
80+
*/
81+
validateValue?: (value: string) => D[];
82+
6683
/**
6784
* Get the icon to display for an option.
6885
* @param option The option to get the icon for.
@@ -84,20 +101,29 @@ export type SuggestBoxProps<T extends Option<T>> = {
84101
* for easier testing.
85102
* @param props The props returned by `getReferenceProps` of {@link useInteractions}
86103
*/
87-
renderInputComponent?: (props: Record<string, unknown>) => ReactNode;
104+
renderInputComponent?: (
105+
props: Record<string, unknown>,
106+
hasError: boolean,
107+
) => ReactNode;
88108
};
89109

90-
export const SuggestBox = <T extends Option<T>>({
110+
export const SuggestBox = <
111+
T extends Option<T>,
112+
D extends Diagnostic = Diagnostic,
113+
>({
91114
value = "",
92115
onChange,
93116
options,
94117
parseValueToTokens,
118+
validateValue,
95119
getIcon,
96120
getDetails,
97121
disabled,
98122
"aria-label": ariaLabel,
99-
renderInputComponent = (props) => <Input {...props} />,
100-
}: SuggestBoxProps<T>) => {
123+
renderInputComponent = (props, hasError) => (
124+
<Input {...props} $error={hasError} />
125+
),
126+
}: SuggestBoxProps<T, D>) => {
101127
const [isOpen, setIsOpen] = useState(false);
102128
const [activeIndex, setActiveIndex] = useState<number | null>(null);
103129

@@ -156,6 +182,13 @@ export const SuggestBox = <T extends Option<T>>({
156182
return findMatchingOptions(options, tokens);
157183
}, [options, tokens]);
158184

185+
const diagnostics = useMemo(
186+
() => validateValue?.(value) ?? [],
187+
[validateValue, value],
188+
);
189+
190+
const hasSyntaxError = diagnostics.length > 0;
191+
159192
useEffect(() => {
160193
if (disabled) {
161194
setIsOpen(false);
@@ -185,6 +218,7 @@ export const SuggestBox = <T extends Option<T>>({
185218
},
186219
disabled,
187220
}),
221+
hasSyntaxError,
188222
)}
189223
{isOpen && (
190224
<FloatingPortal>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* A range of characters in a value. The start position is inclusive, the end position is exclusive.
3+
*/
4+
type DiagnosticRange = {
5+
/**
6+
* Zero-based index of the first character of the token.
7+
*/
8+
start: number;
9+
/**
10+
* Zero-based index of the character after the last character of the token.
11+
*/
12+
end: number;
13+
};
14+
15+
/**
16+
* A diagnostic message.
17+
*/
18+
export type Diagnostic = {
19+
range: DiagnosticRange;
20+
message: string;
21+
};

0 commit comments

Comments
 (0)