diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx
index 69cdfbdcbaa00a..af1e1828ae3723 100644
--- a/static/app/components/searchQueryBuilder/index.spec.tsx
+++ b/static/app/components/searchQueryBuilder/index.spec.tsx
@@ -2010,4 +2010,22 @@ describe('SearchQueryBuilder', function () {
).toBeInTheDocument();
});
});
+
+ describe('disallowFreeText', function () {
+ it('should mark free text invalid', async function () {
+ render(
+
+ );
+
+ expect(screen.getByRole('row', {name: 'foo'})).toHaveAttribute(
+ 'aria-invalid',
+ 'true'
+ );
+
+ await userEvent.click(getLastInput());
+ expect(
+ await screen.findByText('Free text is not supported in this search')
+ ).toBeInTheDocument();
+ });
+ });
});
diff --git a/static/app/components/searchQueryBuilder/index.stories.tsx b/static/app/components/searchQueryBuilder/index.stories.tsx
index 1eb1e4604afb88..6b5ce33602da5c 100644
--- a/static/app/components/searchQueryBuilder/index.stories.tsx
+++ b/static/app/components/searchQueryBuilder/index.stories.tsx
@@ -114,7 +114,7 @@ export default storyBook(SearchQueryBuilder, story => {
});
story('Config Options', () => {
- const configs = ['disallowLogicalOperators', 'disallowWildcard'];
+ const configs = ['disallowFreeText', 'disallowLogicalOperators', 'disallowWildcard'];
const [enabledConfigs, setEnabledConfigs] = useState([...configs]);
const queryBuilderOptions = enabledConfigs.reduce((acc, config) => {
@@ -141,7 +141,7 @@ export default storyBook(SearchQueryBuilder, story => {
))}
parseQueryBuilderValue(state.query, fieldDefinitionGetter, {
+ disallowFreeText,
disallowLogicalOperators,
disallowWildcard,
filterKeys,
}),
[
+ disallowFreeText,
disallowLogicalOperators,
disallowWildcard,
fieldDefinitionGetter,
diff --git a/static/app/components/searchQueryBuilder/tokens/combobox.tsx b/static/app/components/searchQueryBuilder/tokens/combobox.tsx
index 2978a7f11b83cd..255e46698c35f6 100644
--- a/static/app/components/searchQueryBuilder/tokens/combobox.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/combobox.tsx
@@ -87,6 +87,7 @@ type SearchQueryBuilderComboboxProps;
onKeyDown?: (e: KeyboardEvent) => void;
onKeyUp?: (e: KeyboardEvent) => void;
+ onOpenChange?: (newOpenState: boolean) => void;
onPaste?: (e: React.ClipboardEvent) => void;
openOnFocus?: boolean;
placeholder?: string;
@@ -469,6 +470,7 @@ function SearchQueryBuilderComboboxInner {
+ onOpenChange?.(isOpen);
+ }, [onOpenChange, isOpen]);
+
const {
overlayProps,
triggerProps,
diff --git a/static/app/components/searchQueryBuilder/tokens/freeText.tsx b/static/app/components/searchQueryBuilder/tokens/freeText.tsx
index 2f5b4f591b63e0..f14245c1866ddc 100644
--- a/static/app/components/searchQueryBuilder/tokens/freeText.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/freeText.tsx
@@ -22,6 +22,7 @@ import type {
FocusOverride,
} from 'sentry/components/searchQueryBuilder/types';
import {
+ InvalidReason,
type ParseResultToken,
Token,
type TokenResult,
@@ -261,13 +262,38 @@ function KeyDescription({tag}: {tag: Tag}) {
);
}
+function shouldHideInvalidTooltip({
+ token,
+ inputValue,
+ isOpen,
+}: {
+ inputValue: string;
+ isOpen: boolean;
+ token: TokenResult;
+}) {
+ if (!token.invalid || isOpen) {
+ return true;
+ }
+
+ switch (token.invalid.type) {
+ case InvalidReason.FREE_TEXT_NOT_ALLOWED:
+ return inputValue === '';
+ case InvalidReason.WILDCARD_NOT_ALLOWED:
+ return !inputValue.includes('*');
+ default:
+ return false;
+ }
+}
+
function InvalidText({
token,
state,
item,
inputValue,
+ isOpen,
}: {
inputValue: string;
+ isOpen: boolean;
item: Node;
state: ListState;
token: TokenResult;
@@ -280,7 +306,9 @@ function InvalidText({
state={state}
token={token}
item={item}
- forceVisible={!inputValue.includes('*') ? false : undefined}
+ forceVisible={
+ shouldHideInvalidTooltip({token, inputValue, isOpen}) ? false : undefined
+ }
skipWrapper={false}
>
{inputValue}
@@ -297,6 +325,7 @@ function SearchQueryBuilderInputInternal({
const organization = useOrganization();
const inputRef = useRef(null);
const trimmedTokenValue = token.text.trim();
+ const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState(trimmedTokenValue);
const [selectionIndex, setSelectionIndex] = useState(0);
const isFocused =
@@ -486,6 +515,7 @@ function SearchQueryBuilderInputInternal({
setSelectionIndex(e.target.selectionStart ?? 0);
}}
onKeyDown={onKeyDown}
+ onOpenChange={setIsOpen}
tabIndex={isFocused ? 0 : -1}
maxOptions={50}
onPaste={onPaste}
@@ -515,7 +545,13 @@ function SearchQueryBuilderInputInternal({
)
}
-
+
);
}
diff --git a/static/app/components/searchQueryBuilder/utils.tsx b/static/app/components/searchQueryBuilder/utils.tsx
index 3314e464d49956..22d4028165618c 100644
--- a/static/app/components/searchQueryBuilder/utils.tsx
+++ b/static/app/components/searchQueryBuilder/utils.tsx
@@ -65,6 +65,7 @@ export function parseQueryBuilderValue(
getFieldDefinition: FieldDefinitionGetter,
options?: {
filterKeys: TagCollection;
+ disallowFreeText?: boolean;
disallowLogicalOperators?: boolean;
disallowWildcard?: boolean;
}
@@ -72,6 +73,7 @@ export function parseQueryBuilderValue(
return collapseTextTokens(
parseSearch(value || ' ', {
flattenParenGroups: true,
+ disallowFreeText: options?.disallowFreeText,
disallowWildcard: options?.disallowWildcard,
disallowedLogicalOperators: options?.disallowLogicalOperators
? new Set([BooleanOperator.AND, BooleanOperator.OR])