+
void;
placeholder?: string;
- onLanguageChange?: (language: 'sql' | 'lucene') => void;
- language?: 'sql' | 'lucene';
onSubmit?: () => void;
error?: React.ReactNode;
size?: string;
@@ -87,8 +85,6 @@ export default function SQLInlineEditor({
filterField,
onChange,
placeholder,
- onLanguageChange,
- language,
onSubmit,
error,
value,
@@ -162,9 +158,8 @@ export default function SQLInlineEditor({
};
}, [queryHistory, onSelectSearchHistory]);
- // Dashboard variables in scope, offered alongside the column identifiers, and
- // checked for the references that won't expand. This editor's content is
- // always SQL — `language` only drives the switch.
+ // Dashboard variables in scope, offered alongside the column identifiers,
+ // and checked for the references that won't expand.
const variableCompletions = useSqlVariableCompletions({
enabled: enableVariables,
});
@@ -337,29 +332,29 @@ export default function SQLInlineEditor({
}
}, []);
- // Only apply expanded styling when multiline is enabled and focused
- const isExpanded = allowMultiline && isFocused;
-
- const isVariableWarningOnly =
- variableIssues.errors.length === 0 && variableIssues.warnings.length > 0;
- const baseHeight = size === 'xs' ? 30 : 36;
+ const validationState =
+ error || variableIssues.errors.length > 0
+ ? 'error'
+ : variableIssues.warnings.length > 0
+ ? 'warning'
+ : undefined;
+ const baseHeight =
+ size === 'xs' ? EDITOR_INPUT_HEIGHTS.xs : EDITOR_INPUT_HEIGHTS.sm;
return (
- {/* When expanded, Paper is absolute; this keeps the wrapper width stable */}
- {isExpanded &&
}
0 ? styles.error : undefined,
- isVariableWarningOnly ? styles.warning : undefined,
- isExpanded ? styles.expanded : undefined,
- allowMultiline && !isExpanded ? styles.collapseFade : undefined,
+ validationState === 'error' ? styles.error : undefined,
+ validationState === 'warning' ? styles.warning : undefined,
+ !allowMultiline ? styles.clamped : undefined,
+ isFocused ? styles.focused : undefined,
)}
ps="4px"
>
@@ -386,8 +381,7 @@ export default function SQLInlineEditor({
className={cx(
styles.cmWrapper,
size === 'xs' ? styles.sizeXs : undefined,
- !isExpanded ? styles.collapsed : undefined,
- isExpanded ? 'cm-editor-multiline' : undefined,
+ allowMultiline ? 'cm-editor-multiline' : undefined,
)}
>
- {onLanguageChange != null && language != null && (
-
-
-
- )}
);
diff --git a/packages/app/src/components/SQLEditor/variableValidation.module.scss b/packages/app/src/components/SQLEditor/variableValidation.module.scss
index 30db348fdd..1462c5dede 100644
--- a/packages/app/src/components/SQLEditor/variableValidation.module.scss
+++ b/packages/app/src/components/SQLEditor/variableValidation.module.scss
@@ -6,8 +6,6 @@
line-height: 1;
cursor: help;
- /* In the SQL editor, centers the icon on the collapsed input's first line so
- it stays put when the editor expands to multiple lines. Elsewhere (the
- Lucene input's right section) it just fills its host. */
+ /* Keep the icon on the editor's first row as multiline content grows. */
min-height: var(--editor-base-height, 100%);
}
diff --git a/packages/app/src/components/SearchInput/AutocompleteInput.module.scss b/packages/app/src/components/SearchInput/AutocompleteInput.module.scss
index 2536b70f65..b26f89400d 100644
--- a/packages/app/src/components/SearchInput/AutocompleteInput.module.scss
+++ b/packages/app/src/components/SearchInput/AutocompleteInput.module.scss
@@ -1,13 +1,12 @@
.root {
- position: relative;
flex: 1;
- min-height: var(--autocomplete-base-height, 36px);
+ min-height: var(--editor-base-height, 36px);
}
/* The measured right section: an optional adornment, then the language switch */
.rightSection {
display: flex;
- align-items: center;
+ align-items: flex-start;
height: 100%;
}
@@ -17,9 +16,15 @@
border: 1px solid var(--color-border);
border-radius: var(--mantine-radius-default);
+ /* !important overrides the inline min-height react-textarea-autosize sets,
+ so one line is exactly the height .root reserves for it. Without this the
+ row is taller than the bordered box and anything beside it overhangs. */
textarea {
border: none !important;
word-break: break-all;
+ min-height: calc(
+ var(--editor-base-height, 36px) - var(--editor-border-inset, 2px)
+ ) !important;
}
textarea::placeholder {
@@ -27,35 +32,30 @@
}
&.focused {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- z-index: 100;
- box-shadow: var(--mb-shadow, var(--mantine-shadow-xs));
- border-bottom: 2px solid var(--color-bg-brand) !important;
+ border-color: var(--color-bg-brand);
}
+}
- /* Fade at the bottom when collapsed (single-line, not focused); applied to textarea */
- &.collapseFade {
- position: relative;
-
- &::after {
- content: '';
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- height: 12px;
- background: linear-gradient(
- to bottom,
- transparent,
- var(--color-bg-field)
- );
- pointer-events: none;
- border-radius: var(--mantine-radius-default);
- }
- }
+/* Empty placeholders can wrap; hide the unused scrollbar that wrapping creates. */
+.root[data-empty='true'] .textarea textarea {
+ overflow: hidden;
+}
+
+/* Single-line Lucene: one visual row, like SQL without line wrapping. A long
+ query scrolls horizontally instead of wrapping under the clip. */
+.root[data-single-line='true'] .textarea textarea {
+ white-space: pre;
+ word-break: normal;
+ overflow-wrap: normal;
+ overflow: auto hidden;
+}
+
+.root[data-validation-state='error'] .textarea {
+ border-color: var(--color-bg-danger);
+}
+
+.root[data-validation-state='warning'] .textarea {
+ border-color: var(--color-bg-warning);
}
/* Dropdown content */
diff --git a/packages/app/src/components/SearchInput/AutocompleteInput.tsx b/packages/app/src/components/SearchInput/AutocompleteInput.tsx
index 91849581b3..7d298cc090 100644
--- a/packages/app/src/components/SearchInput/AutocompleteInput.tsx
+++ b/packages/app/src/components/SearchInput/AutocompleteInput.tsx
@@ -3,6 +3,7 @@ import cx from 'classnames';
import Fuse from 'fuse.js';
import { Loader, Popover, Textarea, UnstyledButton } from '@mantine/core';
+import { EDITOR_INPUT_HEIGHTS } from '@/components/editorInputHeights';
import type { TokenInfo } from '@/hooks/useAutoCompleteOptions';
import { useQueryHistory } from '@/utils';
@@ -10,6 +11,8 @@ import InputLanguageSwitch from './InputLanguageSwitch';
import styles from './AutocompleteInput.module.scss';
+export type AutocompleteInputValidationState = 'error' | 'warning';
+
export default function AutocompleteInput({
inputRef,
value,
@@ -23,6 +26,7 @@ export default function AutocompleteInput({
aboveSuggestions,
belowSuggestions,
rightAdornment,
+ validationState,
showSuggestionsOnEmpty,
suggestionsHeader = 'Properties',
zIndex = 999,
@@ -30,6 +34,7 @@ export default function AutocompleteInput({
language,
onSubmit,
queryHistoryType,
+ allowMultiline = true,
'data-testid': dataTestId,
}: {
inputRef: React.RefObject
;
@@ -46,15 +51,19 @@ export default function AutocompleteInput({
belowSuggestions?: React.ReactNode;
/** Rendered at the right edge of the input, left of the language switch. */
rightAdornment?: React.ReactNode;
+ validationState?: AutocompleteInputValidationState;
showSuggestionsOnEmpty?: boolean;
suggestionsHeader?: string;
zIndex?: number;
onLanguageChange?: (language: 'sql' | 'lucene') => void;
language?: 'sql' | 'lucene';
queryHistoryType?: string;
+ allowMultiline?: boolean;
'data-testid'?: string;
}) {
const suggestionsLimit = 10;
+ // Rows the query is shown across before the textarea starts scrolling.
+ const maxVisibleRows = 4;
const [isSearchInputFocused, _setIsSearchInputFocused] = useState(false);
const [isInputDropdownOpen, setIsInputDropdownOpen] = useState(false);
@@ -193,14 +202,15 @@ export default function AutocompleteInput({
}
}, [language, onLanguageChange, rightAdornment, inputRef]);
- // Height including the 2px border from .textarea (1px top + 1px bottom)
- const baseHeight = size === 'xs' ? 30 : size === 'lg' ? 44 : 38;
+ const baseHeight = EDITOR_INPUT_HEIGHTS[size];
return (
onChange(e.target.value)}
onFocus={() => {
@@ -279,15 +288,14 @@ export default function AutocompleteInput({
e.preventDefault();
const selected = suggestions[selectedAutocompleteIndex];
onAcceptSuggestion(selected.value, selected.isVariable);
- } else {
- // Allow shift+enter to still create new lines
- if (!e.shiftKey) {
- e.preventDefault();
- if (queryHistoryType && value) {
- setQueryHistory(value);
- }
- onSubmit?.();
+ } else if (!e.shiftKey) {
+ e.preventDefault();
+ if (queryHistoryType && value) {
+ setQueryHistory(value);
}
+ onSubmit?.();
+ } else if (!allowMultiline) {
+ e.preventDefault();
}
}
if (
diff --git a/packages/app/src/components/SearchInput/SearchInputV2.tsx b/packages/app/src/components/SearchInput/SearchInputV2.tsx
index b5dd6d5146..d4ec866c1c 100644
--- a/packages/app/src/components/SearchInput/SearchInputV2.tsx
+++ b/packages/app/src/components/SearchInput/SearchInputV2.tsx
@@ -24,7 +24,9 @@ import {
} from '@/hooks/useAutoCompleteOptions';
import { useMetadataWithSettings } from '@/hooks/useMetadata';
-import AutocompleteInput from './AutocompleteInput';
+import AutocompleteInput, {
+ type AutocompleteInputValidationState,
+} from './AutocompleteInput';
import styles from './SearchInputV2.module.scss';
@@ -53,6 +55,7 @@ export default function SearchInputV2({
onSubmit,
additionalSuggestions,
queryHistoryType,
+ allowMultiline = true,
dateRange,
sourceId,
enableVariables = false,
@@ -68,6 +71,7 @@ export default function SearchInputV2({
onSubmit?: () => void;
additionalSuggestions?: string[];
queryHistoryType?: string;
+ allowMultiline?: boolean;
dateRange?: [Date, Date];
sourceId?: string;
enableVariables?: boolean;
@@ -96,6 +100,12 @@ export default function SearchInputV2({
language: 'lucene',
},
);
+ const validationState: AutocompleteInputValidationState | undefined =
+ variableIssues.errors.length > 0
+ ? 'error'
+ : variableIssues.warnings.length > 0
+ ? 'warning'
+ : undefined;
const {
options: autoCompleteOptions,
@@ -156,7 +166,9 @@ export default function SearchInputV2({
onLanguageChange={onLanguageChange}
onSubmit={onSubmit}
queryHistoryType={queryHistoryType}
+ allowMultiline={allowMultiline}
data-testid={dataTestId}
+ validationState={validationState}
rightAdornment={
hasVariableIssues(variableIssues) ? (
diff --git a/packages/app/src/components/SearchInput/SearchWhereInput.module.scss b/packages/app/src/components/SearchInput/SearchWhereInput.module.scss
index 21674397ad..f964001bf1 100644
--- a/packages/app/src/components/SearchInput/SearchWhereInput.module.scss
+++ b/packages/app/src/components/SearchInput/SearchWhereInput.module.scss
@@ -1,44 +1,25 @@
.root {
- position: relative;
+ --editor-border-inset: 2px;
+
flex: 1 1 0%;
min-width: 0;
display: flex;
flex-direction: row;
align-items: stretch;
-
- /* visible so expanded SQL editor (position:absolute overlay) is not clipped */
- overflow: visible;
}
.inputWrapper {
- position: relative;
flex: 1;
min-width: 0;
- /* When SQLInlineEditor is expanded, round the Paper bottom-left corner */
- /* stylelint-disable-next-line selector-pseudo-class-no-unknown */
- :global([data-expanded='true']) :global(.mantine-Paper-root) {
- border-bottom-left-radius: var(--mantine-radius-default);
- min-height: 38px;
- border-bottom: 2px solid var(--color-bg-brand) !important;
- }
-
- /* When AutocompleteInput (Lucene) is expanded, round the input wrapper bottom-left corner */
- /* stylelint-disable-next-line selector-pseudo-class-no-unknown */
- :global([data-expanded='true']) :global(.mantine-InputWrapper-root) {
- border-bottom-left-radius: var(--mantine-radius-default);
- border-bottom: 2px solid var(--color-bg-brand);
-
- textarea {
- min-height: 38px !important;
- }
- }
-
/* target only the SQLInlineEditor Paper (not the AutocompleteInput) */
/* stylelint-disable-next-line selector-pseudo-class-no-unknown */
:global(.mantine-Paper-root) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
+
+ /* The Paper theme sets `border` inline, which outranks this rule. */
+ border-left: none !important;
}
/* target only the AutocompleteInput Paper (not the SQLInlineEditor) */
@@ -46,71 +27,44 @@
:global(.mantine-InputWrapper-root) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
+ border-left: none;
textarea {
border-radius: 0 var(--mantine-radius-default)
var(--mantine-radius-default) 0;
-
- /* Prevent scrollbar when placeholder text wraps in narrow containers (maxRows=1 when unfocused) */
- overflow: hidden;
}
}
-
- /* !important overrides the inline min-height set by react-textarea-autosize.
- Inner textarea is 2px shorter than the wrapper because the wrapper adds
- a 1px border top/bottom, so 28px + 2px border = 30px outer (matches the
- SQL inline editor's xs height). */
- /* stylelint-disable-next-line selector-pseudo-class-no-unknown */
- &.sizeXs :global(.mantine-InputWrapper-root) textarea {
- min-height: 28px !important;
- }
-
- /* stylelint-disable-next-line selector-pseudo-class-no-unknown */
- &.sizeSm :global(.mantine-InputWrapper-root) textarea {
- min-height: 34px !important;
- }
}
+/* Stretches with the input so the seam between the two stays flush however
+ many lines the query takes. */
.languageSwitch {
- position: relative;
flex-shrink: 0;
- align-items: center;
padding-left: 4px;
border: 1px solid var(--color-border);
border-right: none;
border-radius: var(--mantine-radius-default) 0 0 var(--mantine-radius-default);
background-color: var(--color-bg-field-addon);
+ min-height: var(--editor-base-height);
+}
- &.sizeXs {
- height: 30px;
- }
+/* The switch and the input meet at a seam, so a focus border on only one of
+ them reads as half-focused. Keyed off focus inside the input specifically:
+ tabbing to the syntax-reference button shouldn't light up the switch alone. */
+.root:has(.inputWrapper :focus) .languageSwitch {
+ border-color: var(--color-bg-brand);
+}
- &.sizeSm {
- height: 36px;
- }
+.root:has(.inputWrapper [data-validation-state='error']) .languageSwitch {
+ border-color: var(--color-bg-danger);
}
-.shortcutHint {
- position: absolute;
- top: 50%;
- right: 8px;
- transform: translateY(-50%);
- display: flex;
- align-items: center;
- justify-content: center;
- pointer-events: none;
+.root:has(.inputWrapper [data-validation-state='warning']) .languageSwitch {
+ border-color: var(--color-bg-warning);
+}
- /* Perfect square Kbd: Mantine Kbd renders as */
- /* stylelint-disable-next-line selector-pseudo-class-no-unknown */
- :global(kbd) {
- width: 22px;
- height: 22px;
- min-width: 22px;
- min-height: 22px;
- padding: 0;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- box-sizing: border-box;
- }
+/* Subtract the switch's borders so its controls align with the first row. */
+.languageSwitchRow {
+ flex-shrink: 0;
+ height: calc(var(--editor-base-height) - var(--editor-border-inset, 2px));
}
diff --git a/packages/app/src/components/SearchInput/SearchWhereInput.stories.tsx b/packages/app/src/components/SearchInput/SearchWhereInput.stories.tsx
index 0f5ca85121..6b37fdb762 100644
--- a/packages/app/src/components/SearchInput/SearchWhereInput.stories.tsx
+++ b/packages/app/src/components/SearchInput/SearchWhereInput.stories.tsx
@@ -22,12 +22,10 @@ const mockTableConnection = {
function SearchWhereInputWrapper({
defaultLanguage = 'lucene',
width,
- showLabel,
allowMultiline,
}: {
defaultLanguage?: 'sql' | 'lucene';
width?: string;
- showLabel?: boolean;
allowMultiline?: boolean;
}) {
const { control } = useForm({
@@ -46,7 +44,6 @@ function SearchWhereInputWrapper({
name="where"
enableHotkey
width={width}
- showLabel={showLabel}
allowMultiline={allowMultiline}
/>
@@ -67,11 +64,6 @@ export const CustomWidth = () => (
);
CustomWidth.storyName = 'Custom Width (50%)';
-export const NoLabel = () => (
-
-);
-NoLabel.storyName = 'SQL Without Label';
-
export const NoMultiline = () => (
);
diff --git a/packages/app/src/components/SearchInput/SearchWhereInput.tsx b/packages/app/src/components/SearchInput/SearchWhereInput.tsx
index e405c7d77d..5b09bab036 100644
--- a/packages/app/src/components/SearchInput/SearchWhereInput.tsx
+++ b/packages/app/src/components/SearchInput/SearchWhereInput.tsx
@@ -1,9 +1,10 @@
import { FieldPath, useController, UseControllerProps } from 'react-hook-form';
import { TableConnectionChoice } from '@hyperdx/common-utils/dist/core/metadata';
-import { ActionIcon, Box, Flex, Kbd, Tooltip } from '@mantine/core';
+import { ActionIcon, Box, Flex, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconHelp } from '@tabler/icons-react';
+import { EDITOR_INPUT_HEIGHTS } from '@/components/editorInputHeights';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import InputLanguageSwitch from './InputLanguageSwitch';
@@ -58,14 +59,6 @@ export type SearchWhereInputProps = {
* Size of the input
*/
size?: 'xs' | 'sm';
- /**
- * Show label on SQL input (default true). Use label to customize text (e.g. "GLOBAL WHERE").
- */
- showLabel?: boolean;
- /**
- * Label text when showLabel is true (default "WHERE")
- */
- label?: string;
/**
* Enable multiline for SQL input
*/
@@ -154,8 +147,6 @@ export default function SearchWhereInput({
onLanguageChange,
enableHotkey,
size = 'sm',
- showLabel = true,
- label: labelText = 'WHERE',
allowMultiline = true,
sqlQueryHistoryType,
luceneQueryHistoryType,
@@ -190,7 +181,8 @@ export default function SearchWhereInput({
};
const tc = tableConnection ? { tableConnection } : { tableConnections };
- const sizeClass = size === 'xs' ? styles.sizeXs : styles.sizeSm;
+ const baseHeight =
+ size === 'xs' ? EDITOR_INPUT_HEIGHTS.xs : EDITOR_INPUT_HEIGHTS.sm;
return (
<>
@@ -205,31 +197,34 @@ export default function SearchWhereInput({
width,
maxWidth,
minWidth,
+ ['--editor-base-height' as string]: `${baseHeight}px`,
}}
>
e.preventDefault()}
>
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
{isSql ? (
)}
- {enableHotkey && (
-
- /
-
- )}
>
diff --git a/packages/app/src/components/SearchInput/__tests__/SearchWhereInput.test.tsx b/packages/app/src/components/SearchInput/__tests__/SearchWhereInput.test.tsx
index 059e4a064d..bc9f2dd2a9 100644
--- a/packages/app/src/components/SearchInput/__tests__/SearchWhereInput.test.tsx
+++ b/packages/app/src/components/SearchInput/__tests__/SearchWhereInput.test.tsx
@@ -79,13 +79,17 @@ describe('SearchWhereInput', () => {
describe('Lucene Mode', () => {
it('renders Lucene input when whereLanguage is lucene', () => {
- renderWithMantine();
+ const { container } = renderWithMantine(
+ ,
+ );
// Lucene mode uses a textarea from AutocompleteInput
const input = screen.getByPlaceholderText(
/Search your events w\/ Lucene/i,
);
expect(input).toBeInTheDocument();
+ // `/` and `s` still focus the input; the overlay keycap is gone.
+ expect(container.querySelector('kbd')).not.toBeInTheDocument();
});
it('allows typing in Lucene mode', async () => {
@@ -101,24 +105,55 @@ describe('SearchWhereInput', () => {
expect(input).toHaveValue('level:error');
});
});
+
+ it('does not insert a newline in Lucene when allowMultiline is false', async () => {
+ const user = userEvent.setup();
+ const onSubmit = jest.fn();
+ renderWithMantine(
+
+ {({ control }) => (
+
+ )}
+ ,
+ );
+
+ const input = screen.getByPlaceholderText(
+ /Search your events w\/ Lucene/i,
+ );
+ expect(input.closest('[data-single-line]')).toHaveAttribute(
+ 'data-single-line',
+ 'true',
+ );
+ await user.click(input);
+ await user.keyboard('first{Shift>}{Enter}{/Shift}second');
+
+ expect(input).toHaveValue('firstsecond');
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
});
describe('SQL Mode', () => {
- it('renders SQL input with WHERE label when whereLanguage is sql', () => {
+ it('renders the SQL editor when whereLanguage is sql', () => {
renderWithMantine();
- // SQL mode shows the WHERE label
- expect(screen.getByText('WHERE')).toBeInTheDocument();
+ expect(
+ screen.getByRole('combobox', { name: 'Query language' }),
+ ).toHaveValue('SQL');
+ expect(
+ screen.queryByPlaceholderText(/Search your events w\/ Lucene/i),
+ ).not.toBeInTheDocument();
});
it('renders SQL placeholder', () => {
renderWithMantine();
- // Check for placeholder text in the CodeMirror editor
- // Note: CodeMirror may render placeholder differently
- screen.queryByText(/SQL WHERE clause/i);
- // If placeholder is not directly visible, the component should still render
- expect(screen.getByText('WHERE')).toBeInTheDocument();
+ expect(screen.getByText(/SQL WHERE clause/i)).toBeInTheDocument();
});
});
@@ -152,7 +187,7 @@ describe('SearchWhereInput', () => {
describe('Component Props', () => {
it('respects width prop in SQL mode', () => {
- renderWithMantine(
+ const { container } = renderWithMantine(
{({ control }) => (
{
,
);
- // The Box wrapper should have the width style
- expect(screen.getByText('WHERE')).toBeInTheDocument();
- });
-
- it('hides label when showLabel is false', () => {
- renderWithMantine(
-
- {({ control }) => (
-
- )}
- ,
- );
-
- expect(screen.queryByText('WHERE')).not.toBeInTheDocument();
+ expect(
+ container.querySelector('[style*="width: 50%"]'),
+ ).toBeInTheDocument();
});
it('uses custom placeholders when provided', () => {
@@ -342,6 +361,11 @@ describe('SearchWhereInput', () => {
'This expression references unknown variable $srvice. Available variables: svc.',
),
);
+ expect(
+ screen
+ .getByTestId('variable-validation')
+ .closest('[data-validation-state]'),
+ ).toHaveAttribute('data-validation-state', 'warning');
});
it('warns that a Lucene expression references a variable that does not exist', async () => {
@@ -355,6 +379,11 @@ describe('SearchWhereInput', () => {
'This expression references unknown variable $srvice. Available variables: svc.',
),
);
+ expect(
+ screen
+ .getByTestId('variable-validation')
+ .closest('[data-validation-state]'),
+ ).toHaveAttribute('data-validation-state', 'warning');
});
it('errors when a SQL reference is wrapped in quotes', async () => {
@@ -366,6 +395,11 @@ describe('SearchWhereInput', () => {
await waitFor(() =>
expect(issueMessages()).toContain('is wrapped in quotes'),
);
+ expect(
+ screen
+ .getByTestId('variable-validation')
+ .closest('[data-validation-state]'),
+ ).toHaveAttribute('data-validation-state', 'error');
});
it('leaves a quoted reference alone in Lucene, where each value is quoted anyway', async () => {
diff --git a/packages/app/src/components/Sources/SourceForm/HighlightedAttributes.tsx b/packages/app/src/components/Sources/SourceForm/HighlightedAttributes.tsx
index 7b82510c19..2fe0c81631 100644
--- a/packages/app/src/components/Sources/SourceForm/HighlightedAttributes.tsx
+++ b/packages/app/src/components/Sources/SourceForm/HighlightedAttributes.tsx
@@ -8,6 +8,7 @@ import {
IconTrash,
} from '@tabler/icons-react';
+import { EDITOR_INPUT_HEIGHTS } from '@/components/editorInputHeights';
import { ErrorCollapse } from '@/components/Error/ErrorCollapse';
import { InputControlled } from '@/components/InputControlled';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
@@ -83,34 +84,39 @@ function HighlightedAttributeRow({
-
- AS
+ {/* The label and icons stay level with the editors' first line as they grow */}
+
+
+ AS
+
-
+
+
+
+
+
+
removeHighlightedAttribute(index)}
>
-
+
-
- removeHighlightedAttribute(index)}
- >
-
-
+
diff --git a/packages/app/src/components/Sources/SourceForm/MaterializedViews.tsx b/packages/app/src/components/Sources/SourceForm/MaterializedViews.tsx
index 07ccdea0f3..764e6327b2 100644
--- a/packages/app/src/components/Sources/SourceForm/MaterializedViews.tsx
+++ b/packages/app/src/components/Sources/SourceForm/MaterializedViews.tsx
@@ -6,6 +6,7 @@ import {
Box,
Button,
Divider,
+ Flex,
Grid,
Group,
Select,
@@ -19,6 +20,7 @@ import { IconCirclePlus, IconHelpCircle, IconTrash } from '@tabler/icons-react';
import { DatabaseSelectControlled } from '@/components/DatabaseSelect';
import { DBTableSelectControlled } from '@/components/DBTableSelect';
+import { EDITOR_INPUT_HEIGHTS } from '@/components/editorInputHeights';
import SelectControlled from '@/components/SelectControlled';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import { useMetadataWithSettings } from '@/hooks/useMetadata';
@@ -556,7 +558,7 @@ function AggregatedColumnRow({
)}
-
+
-
-
-
+ {/* Stays level with the editor's first line as it grows */}
+
+
+
+
+
>
diff --git a/packages/app/src/components/Sources/SourceForm/OrderByFormRow.tsx b/packages/app/src/components/Sources/SourceForm/OrderByFormRow.tsx
index bcc09a0e73..8b3e87b6ae 100644
--- a/packages/app/src/components/Sources/SourceForm/OrderByFormRow.tsx
+++ b/packages/app/src/components/Sources/SourceForm/OrderByFormRow.tsx
@@ -6,6 +6,7 @@ import { ActionIcon, Box, Flex, Text, Tooltip } from '@mantine/core';
import { useDebouncedCallback, useDidUpdate } from '@mantine/hooks';
import { IconCheck } from '@tabler/icons-react';
+import { EDITOR_INPUT_HEIGHTS } from '@/components/editorInputHeights';
import { ErrorCollapse } from '@/components/Error/ErrorCollapse';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import { useExplainQuery } from '@/hooks/useExplainQuery';
@@ -73,7 +74,7 @@ export function OrderByFormRow({
label="Default Order By"
helpText="Custom ORDER BY expression that overrides the default ordering. Leave empty to use the auto-detected default. (This can be customized per search later)"
>
-
+
-
-
-
-
-
+ {/* Stays level with the editor's first line as it grows */}
+
+
+
+
+
+
+
{shouldShowResult && (
diff --git a/packages/app/src/components/editorInputHeights.ts b/packages/app/src/components/editorInputHeights.ts
new file mode 100644
index 0000000000..b78aef8d24
--- /dev/null
+++ b/packages/app/src/components/editorInputHeights.ts
@@ -0,0 +1,5 @@
+export const EDITOR_INPUT_HEIGHTS = {
+ xs: 30,
+ sm: 36,
+ lg: 44,
+} as const;
diff --git a/packages/app/tests/e2e/components/ChartEditorComponent.ts b/packages/app/tests/e2e/components/ChartEditorComponent.ts
index bd0f748526..7b726ffbf8 100644
--- a/packages/app/tests/e2e/components/ChartEditorComponent.ts
+++ b/packages/app/tests/e2e/components/ChartEditorComponent.ts
@@ -14,6 +14,7 @@ import { switchWhereToLucene } from '../utils/lucene-autocomplete';
import { addTagViaPicker, removeTagViaPicker } from '../utils/tags';
import { WebhookAlertModalComponent } from './WebhookAlertModalComponent';
+import { WhereInputComponent } from './WhereInputComponent';
export class ChartEditorComponent {
readonly page: Page;
@@ -98,30 +99,21 @@ export class ChartEditorComponent {
/**
* The editor renders one WHERE input per series (the series' agg condition)
- * followed by the chart-level WHERE, and they share a placeholder and testid.
- * `'series'` takes the first, `'chart'` the last — so `'series'` only
- * addresses the first series, which is all the tests need so far.
+ * followed by the chart-level WHERE. `'series'` takes the first, `'chart'`
+ * the last — so `'series'` only addresses the first series, which is all the
+ * tests need so far.
*/
- private whereInput(locator: Locator, scope: 'chart' | 'series'): Locator {
- return scope === 'series' ? locator.first() : locator.last();
- }
-
- /**
- * A whole WHERE input — its language switch, the SQL or Lucene editor, and
- * anything the input renders beside them. Located from the language switch,
- * which is the one part present in both languages and whichever state the
- * editor is in.
- */
- private whereRow(scope: 'chart' | 'series' = 'chart'): Locator {
- return this.whereInput(
- this.editorForm().getByTestId('where-language-switch'),
- scope,
- ).locator('xpath=..');
+ private whereInput(scope: 'chart' | 'series' = 'chart'): WhereInputComponent {
+ return new WhereInputComponent(
+ this.page,
+ this.editorForm(),
+ scope === 'series' ? 'first' : 'last',
+ );
}
/** The warning icon a WHERE input shows about the variables it references. */
whereVariableWarning(scope: 'chart' | 'series' = 'chart'): Locator {
- return this.whereRow(scope).getByTestId('variable-validation');
+ return this.whereInput(scope).variableWarning;
}
/**
@@ -147,26 +139,7 @@ export class ChartEditorComponent {
) {
// A completion popup left open by a prior editor can overlay the switch.
await dismissSqlAutocomplete(this.page);
- const select = this.whereInput(
- this.editorForm().getByTestId('where-language-switch'),
- scope,
- ).getByLabel('Query language');
- await select.click();
- await this.page
- .getByRole('option', { name: language, exact: true })
- .click();
- }
-
- /** Focus a WHERE input and replace its contents with `expression`. */
- private async fillWhereEditor(expression: string, scope: 'chart' | 'series') {
- // Located through the row rather than the placeholder, which CodeMirror
- // drops as soon as there is content — so this can refill an input it has
- // already filled once.
- const editor = this.whereRow(scope).locator('.cm-content');
- await editor.click();
- await this.page.keyboard.press('ControlOrMeta+A');
- await this.page.keyboard.press('Delete');
- await this.page.keyboard.type(expression);
+ await this.whereInput(scope).selectLanguage(language);
}
/**
@@ -175,7 +148,7 @@ export class ChartEditorComponent {
*/
async setSqlWhere(expression: string, scope: 'chart' | 'series' = 'chart') {
await this.setWhereLanguage('SQL', scope);
- await this.fillWhereEditor(expression, scope);
+ await this.whereInput(scope).fillSql(expression);
await dismissSqlAutocomplete(this.page);
}
@@ -184,12 +157,7 @@ export class ChartEditorComponent {
* plain textarea rather than CodeMirror. Leaves the suggestion dropdown open.
*/
async typeLuceneWhere(text: string, scope: 'chart' | 'series' = 'chart') {
- const input = this.whereInput(
- this.editorForm().getByPlaceholder(/Search your events w\/ Lucene/i),
- scope,
- );
- await input.click();
- await input.fill(text);
+ await this.whereInput(scope).typeLucene(text);
}
/**
@@ -204,7 +172,7 @@ export class ChartEditorComponent {
scope: 'chart' | 'series' = 'chart',
): Promise<{ labels: string[]; info: string }> {
await this.setWhereLanguage('SQL', scope);
- await this.fillWhereEditor(prefix, scope);
+ await this.whereInput(scope).fillSql(prefix);
const popup = this.page.locator('.cm-tooltip-autocomplete');
await popup.waitFor({ state: 'visible', timeout: 10000 });
diff --git a/packages/app/tests/e2e/components/SidePanelComponent.ts b/packages/app/tests/e2e/components/SidePanelComponent.ts
index 854111514e..b64077fb04 100644
--- a/packages/app/tests/e2e/components/SidePanelComponent.ts
+++ b/packages/app/tests/e2e/components/SidePanelComponent.ts
@@ -4,6 +4,8 @@
*/
import { Locator, Page } from '@playwright/test';
+import { WhereInputComponent } from './WhereInputComponent';
+
export class SidePanelComponent {
readonly page: Page;
private readonly panelContainer: Locator;
@@ -192,6 +194,11 @@ export class SidePanelComponent {
return this.page.getByTestId('trace-logs-filter');
}
+ /** The spans filter's WHERE control. Call `toggleTraceFilters` first. */
+ get traceSpansWhereInput() {
+ return new WhereInputComponent(this.page, this.traceSpansFilter);
+ }
+
/** Expand or collapse the trace waterfall's filter inputs. */
async toggleTraceFilters() {
await this.page.getByTestId('trace-filters-toggle').click();
diff --git a/packages/app/tests/e2e/components/WhereInputComponent.ts b/packages/app/tests/e2e/components/WhereInputComponent.ts
new file mode 100644
index 0000000000..d17d72f32f
--- /dev/null
+++ b/packages/app/tests/e2e/components/WhereInputComponent.ts
@@ -0,0 +1,178 @@
+/**
+ * WhereInputComponent - the WHERE control rendered by `SearchWhereInput`:
+ * a language switch sitting flush against a SQL or Lucene input.
+ *
+ * Shared by every page that renders one (search, dashboards, sessions), so the
+ * traversal to the input — which carries no visible label and, in SQL, no test
+ * id — lives here rather than in each page object or spec.
+ */
+import { expect, Locator, Page } from '@playwright/test';
+
+import { borderedBox, replaceEditorText } from '../utils/locators';
+import { switchWhereLanguage } from '../utils/lucene-autocomplete';
+import type { MultilineField } from '../utils/multiline-input';
+
+export type QueryLanguage = 'SQL' | 'Lucene';
+
+/** One half of the seam, as the user sees it. */
+type SeamHalf = {
+ height: number;
+ borderColor: string;
+};
+
+export type WhereSeam = {
+ languageSwitch: SeamHalf;
+ input: SeamHalf;
+};
+
+/**
+ * Sub-pixel rounding makes exact height equality one layout change away from
+ * flaking; the overhang this guards against was 2px.
+ */
+const SEAM_TOLERANCE_PX = 1;
+
+export class WhereInputComponent {
+ readonly page: Page;
+
+ /**
+ * The control as a whole, located from its language switch — the one part
+ * present in both languages.
+ */
+ readonly row: Locator;
+
+ /**
+ * `pick` chooses among several WHERE inputs under the same `root` (a chart
+ * editor renders one per series, then the chart-level input).
+ */
+ constructor(
+ page: Page,
+ root: Page | Locator = page,
+ pick: 'first' | 'last' = 'first',
+ ) {
+ this.page = page;
+ const languageSwitch = root.getByTestId('where-language-switch');
+ this.row = (
+ pick === 'last' ? languageSwitch.last() : languageSwitch.first()
+ ).locator('xpath=..');
+ }
+
+ get languageSwitch(): Locator {
+ return this.row.getByTestId('where-language-switch');
+ }
+
+ /** The CodeMirror editor rendered in SQL mode. */
+ get sqlEditor(): Locator {
+ return this.row.locator('.cm-editor').first();
+ }
+
+ /** The autosizing textarea rendered in Lucene mode. */
+ get luceneInput(): Locator {
+ return this.row.locator('textarea').first();
+ }
+
+ async selectLanguage(language: QueryLanguage): Promise {
+ await switchWhereLanguage(this.languageSwitch, language);
+ // Wait for the dropdown to close so it can't cover the input.
+ await this.page
+ .getByRole('option', { name: language, exact: true })
+ .waitFor({ state: 'hidden', timeout: 5000 });
+ }
+
+ /** Replace the SQL editor's contents. The input must already be in SQL mode. */
+ async fillSql(expression: string): Promise {
+ await replaceEditorText(
+ this.page,
+ this.sqlEditor.locator('.cm-content').first(),
+ expression,
+ );
+ }
+
+ /**
+ * Type into the Lucene textarea, leaving the suggestion dropdown open.
+ * The input must already be in Lucene mode.
+ */
+ async typeLucene(text: string): Promise {
+ await this.luceneInput.click();
+ await this.luceneInput.fill(text);
+ }
+
+ /** The warning icon this WHERE input shows about the variables it references. */
+ get variableWarning(): Locator {
+ return this.row.getByTestId('variable-validation');
+ }
+
+ /** The input for `language`, as the growth assertions need it. */
+ field(language: QueryLanguage): MultilineField {
+ if (language === 'Lucene') {
+ const input = this.luceneInput;
+ return { focusTarget: input, growthBox: input, visibleBox: input };
+ }
+ // `.cm-editor` can stay fixed while `.cm-content` tracks the line count,
+ // and the Paper around the editor is the box that would clip it.
+ const content = this.sqlEditor.locator('.cm-content').first();
+ return {
+ focusTarget: content,
+ growthBox: content,
+ visibleBox: borderedBox(this.sqlEditor),
+ };
+ }
+
+ /** Move focus into the input, whichever language it is rendering. */
+ async focus(): Promise {
+ const sqlContent = this.sqlEditor.locator('.cm-content').first();
+ if ((await sqlContent.count()) > 0) {
+ await sqlContent.focus();
+ return;
+ }
+ await this.luceneInput.focus();
+ }
+
+ /**
+ * Heights and border colors of the two halves of the control. They sit flush
+ * against each other, so a height mismatch leaves the switch overhanging the
+ * input, and a focus color on only one reads as half-focused.
+ */
+ async readSeam(): Promise {
+ return this.row.evaluate(el => {
+ const addon = el.querySelector('[data-testid="where-language-switch"]');
+ // The box the user sees a border around: the SQL editor's Paper, or the
+ // Lucene textarea's wrapper.
+ const input = Array.from(el.querySelectorAll('*')).find(
+ node =>
+ addon?.contains(node) === false &&
+ parseFloat(getComputedStyle(node).borderTopWidth) > 0,
+ );
+ const read = (node?: Element | null) => ({
+ height: node?.getBoundingClientRect().height ?? 0,
+ borderColor: node ? getComputedStyle(node).borderTopColor : '',
+ });
+ return { languageSwitch: read(addon), input: read(input) };
+ });
+ }
+
+ /**
+ * The switch and the input are sized independently — the switch from its own
+ * SCSS, the input from whichever editor the language selects — so an editor
+ * that reserves a taller row than its bordered box leaves the switch
+ * overhanging it.
+ */
+ async expectSeamFlush(): Promise {
+ await expect
+ .poll(async () => {
+ const seam = await this.readSeam();
+ if (seam.languageSwitch.height <= 0 || seam.input.height <= 0) {
+ return Number.POSITIVE_INFINITY;
+ }
+ return Math.abs(seam.languageSwitch.height - seam.input.height);
+ })
+ .toBeLessThanOrEqual(SEAM_TOLERANCE_PX);
+ }
+
+ async borderColors(): Promise<{ languageSwitch: string; input: string }> {
+ const seam = await this.readSeam();
+ return {
+ languageSwitch: seam.languageSwitch.borderColor,
+ input: seam.input.borderColor,
+ };
+ }
+}
diff --git a/packages/app/tests/e2e/features/shared/input-focus.spec.ts b/packages/app/tests/e2e/features/shared/input-focus.spec.ts
new file mode 100644
index 0000000000..984d80f7ea
--- /dev/null
+++ b/packages/app/tests/e2e/features/shared/input-focus.spec.ts
@@ -0,0 +1,56 @@
+import { SearchPage } from '../../page-objects/SearchPage';
+import { expect, test } from '../../utils/base-test';
+
+/**
+ * The query fields mark focus by recoloring their border. The Paper theme sets
+ * that border inline, which outranks any stylesheet rule, so these assertions
+ * read the color that actually lands on the element rather than trusting the
+ * class that asks for it.
+ */
+test.describe('Query input focus states', { tag: '@search' }, () => {
+ const languages = [
+ { name: 'Lucene', select: (p: SearchPage) => p.switchToLuceneMode() },
+ { name: 'SQL', select: (p: SearchPage) => p.switchToSQLMode() },
+ ];
+
+ test('marks the WHERE input as focused in both languages', async ({
+ page,
+ }) => {
+ const searchPage = new SearchPage(page);
+ await searchPage.goto();
+
+ for (const language of languages) {
+ await language.select(searchPage);
+
+ const blurred = await searchPage.getWhereBorderColors();
+ expect(blurred.input).not.toBe('');
+
+ await searchPage.focusWhereInput();
+
+ // Both halves take the focus color, so the control doesn't read as
+ // half-focused.
+ await expect
+ .poll(async () => (await searchPage.getWhereBorderColors()).input)
+ .not.toBe(blurred.input);
+ const focused = await searchPage.getWhereBorderColors();
+ expect(focused.languageSwitch).toBe(focused.input);
+ }
+ });
+
+ test('marks SELECT and ORDER BY as focused', async ({ page }) => {
+ const searchPage = new SearchPage(page);
+ await searchPage.goto();
+
+ const selectBlurred = await searchPage.getSelectBorderColor();
+ await searchPage.getSELECTEditor().focus();
+ await expect
+ .poll(() => searchPage.getSelectBorderColor())
+ .not.toBe(selectBlurred);
+
+ const orderByBlurred = await searchPage.getOrderByBorderColor();
+ await searchPage.getOrderByEditor().focus();
+ await expect
+ .poll(() => searchPage.getOrderByBorderColor())
+ .not.toBe(orderByBlurred);
+ });
+});
diff --git a/packages/app/tests/e2e/features/shared/multiline.spec.ts b/packages/app/tests/e2e/features/shared/multiline.spec.ts
index 9cb2ea07d9..0696d75cef 100644
--- a/packages/app/tests/e2e/features/shared/multiline.spec.ts
+++ b/packages/app/tests/e2e/features/shared/multiline.spec.ts
@@ -1,139 +1,87 @@
-import { DashboardsListPage } from 'tests/e2e/page-objects/DashboardsListPage';
-import type { Locator, Page } from '@playwright/test';
+import type { Page } from '@playwright/test';
+import {
+ QueryLanguage,
+ WhereInputComponent,
+} from '../../components/WhereInputComponent';
+import { DashboardsListPage } from '../../page-objects/DashboardsListPage';
import { SearchPage } from '../../page-objects/SearchPage';
import { expect, test } from '../../utils/base-test';
+import {
+ expectExpandsAndStaysExpanded,
+ expectStaysOneRow,
+} from '../../utils/multiline-input';
test.describe('Multiline Input', { tag: '@search' }, () => {
- const testInputExpansion = async (
- page: Page,
- editor: Locator,
- /** For CodeMirror, pass the content element that grows (e.g. .cm-content); for textarea, omit to use editor. */
- measureLocator?: Locator,
- ): Promise => {
- const measureEl = measureLocator ?? editor;
- // Scroll into view then focus (more reliable than click for textarea/input in CI)
- await editor.scrollIntoViewIfNeeded();
- await editor.focus();
- await page.keyboard.type('first line');
-
- // Get initial single line height from the element that reflects content height
- const singleLineBox = await measureEl.boundingBox();
- const singleLineHeight = singleLineBox?.height || 0;
-
- // Add a line break and type second line
- await page.keyboard.press('Shift+Enter');
- await page.keyboard.type('second line');
-
- // Verify newline was inserted: CodeMirror has .cm-line per line; textarea value contains newline
- const isCodeMirror = (await editor.locator('.cm-line').count()) > 0;
- if (isCodeMirror) {
- await expect(editor.locator('.cm-line')).toHaveCount(2, {
- timeout: 2000,
- });
- } else {
- await expect(editor).toHaveValue(/first line[\r\n]+second line/, {
- timeout: 2000,
- });
- }
-
- // Verify height did not shrink (may stay same on some layouts e.g. scrollable area)
- const multiLineBox = await measureEl.boundingBox();
- const multiLineHeight = multiLineBox?.height || 0;
- expect(multiLineHeight).toBeGreaterThanOrEqual(singleLineHeight);
- };
-
- const getEditor = (
- page: Page,
- mode: 'SQL' | 'Lucene',
- formSelector?: string,
- whereText = 'WHERE',
- ): Locator => {
- if (mode === 'SQL') {
- const container = formSelector ? page.locator(formSelector) : page;
- const whereContainer = container.locator(
- `div:has(div.mantine-Text-root:has-text("${whereText}"))`,
- );
- return whereContainer.locator('.cm-editor').first();
- }
- // Target the textarea so the click hits the typing area, not the Query language Select in the right section
- return page
- .locator('[data-testid="search-input"] textarea')
- .or(page.locator('textarea[data-testid="search-input"]'))
- .first();
- };
-
- // Test configurations
- const tests = [
+ /** Pages that render a WHERE input, and how to get to one. */
+ const pages = [
{
- path: '/search',
name: 'Search Page',
- formSelector: '[data-testid="search-form"]',
- whereText: 'WHERE',
+ openWhereInput: async (page: Page) => {
+ const searchPage = new SearchPage(page);
+ await searchPage.goto();
+ return searchPage.whereInput;
+ },
},
{
- path: '/dashboards',
name: 'Dashboard Page',
- formSelector: undefined,
- whereText: 'WHERE',
- },
- ];
-
- tests.forEach(({ path, name, formSelector, whereText }) => {
- test(`should expand SQL input on line break on ${name}`, async ({
- page,
- }) => {
- // Navigate using page object
- // eslint-disable-next-line playwright/no-conditional-in-test
- if (path === '/search') {
- const searchPage = new SearchPage(page);
- await searchPage.goto();
- await searchPage.switchToSQLMode();
- } else {
+ openWhereInput: async (page: Page) => {
const dashboardsListPage = new DashboardsListPage(page);
await dashboardsListPage.goto();
await dashboardsListPage.createNewDashboard();
- // Dashboard uses Controller + SQL/SearchInputV2 directly (no where-language-switch wrapper)
- await page.getByRole('combobox', { name: 'Query language' }).click();
- await page.getByRole('option', { name: 'SQL', exact: true }).click();
- // Wait for dropdown to close so the WHERE input is not covered
- await page
- .getByRole('option', { name: 'SQL', exact: true })
- .waitFor({ state: 'hidden', timeout: 5000 });
- }
+ return new WhereInputComponent(page);
+ },
+ },
+ ];
- const editor = getEditor(page, 'SQL', formSelector, whereText);
- await expect(editor).toBeVisible();
- // CodeMirror: .cm-editor can stay fixed; .cm-content height reflects line count
- const measureEl = editor.locator('.cm-content').first();
- await testInputExpansion(page, editor, measureEl);
- });
+ const languages: QueryLanguage[] = ['SQL', 'Lucene'];
- test(`should expand Lucene input on line break on ${name}`, async ({
- page,
- }) => {
- // Navigate using page object
- // eslint-disable-next-line playwright/no-conditional-in-test
- if (path === '/search') {
- const searchPage = new SearchPage(page);
- await searchPage.goto();
- await searchPage.switchToLuceneMode();
- } else {
- const dashboardsListPage = new DashboardsListPage(page);
- await dashboardsListPage.goto();
- await dashboardsListPage.createNewDashboard();
- // Dashboard has no where-language-switch wrapper; use Query language textbox directly
- await page.getByRole('combobox', { name: 'Query language' }).click();
- await page.getByRole('option', { name: 'Lucene', exact: true }).click();
- // Wait for dropdown to close so the search input is not covered
- await page
- .getByRole('option', { name: 'Lucene', exact: true })
- .waitFor({ state: 'hidden', timeout: 5000 });
- }
+ pages.forEach(({ name, openWhereInput }) => {
+ languages.forEach(language => {
+ test(`should expand ${language} input on line break on ${name}`, async ({
+ page,
+ }) => {
+ const whereInput = await openWhereInput(page);
+ await whereInput.selectLanguage(language);
- const editor = getEditor(page, 'Lucene', formSelector, whereText);
- await expect(editor).toBeVisible();
- await testInputExpansion(page, editor);
+ const field = whereInput.field(language);
+ await expect(field.focusTarget).toBeVisible();
+
+ await expectExpandsAndStaysExpanded(page, field, {
+ onSingleLine: () => whereInput.expectSeamFlush(),
+ });
+ });
});
});
+
+ test('should keep SELECT and ORDER BY expanded after blur', async ({
+ page,
+ }) => {
+ const searchPage = new SearchPage(page);
+ await searchPage.goto();
+
+ for (const field of [
+ searchPage.selectClauseField(),
+ searchPage.orderByClauseField(),
+ ]) {
+ await expect(field.focusTarget).toBeVisible();
+ await searchPage.clearClause(field);
+ await expectExpandsAndStaysExpanded(page, field);
+ }
+ });
+
+ test('should keep a single-line WHERE one row after blur', async ({
+ page,
+ }) => {
+ const searchPage = new SearchPage(page);
+ await searchPage.goto();
+ const whereInput = await searchPage.openTraceSpansFilter();
+
+ for (const language of languages) {
+ await whereInput.selectLanguage(language);
+ const field = whereInput.field(language);
+ await expect(field.focusTarget).toBeVisible();
+ await expectStaysOneRow(page, field);
+ }
+ });
});
diff --git a/packages/app/tests/e2e/page-objects/SearchPage.ts b/packages/app/tests/e2e/page-objects/SearchPage.ts
index 611098b692..a14e0a752a 100644
--- a/packages/app/tests/e2e/page-objects/SearchPage.ts
+++ b/packages/app/tests/e2e/page-objects/SearchPage.ts
@@ -12,7 +12,10 @@ import { SearchPageAlertModalComponent } from '../components/SearchPageAlertModa
import { SidePanelComponent } from '../components/SidePanelComponent';
import { TableComponent } from '../components/TableComponent';
import { TimePickerComponent } from '../components/TimePickerComponent';
-import { dismissSqlAutocomplete } from '../utils/locators';
+import { WhereInputComponent } from '../components/WhereInputComponent';
+import { DEFAULT_TRACES_SOURCE_NAME } from '../utils/constants';
+import { borderedBox, dismissSqlAutocomplete } from '../utils/locators';
+import type { MultilineField } from '../utils/multiline-input';
type SaveSearchModalProps = {
update: boolean;
@@ -25,6 +28,7 @@ export class SearchPage {
readonly patternSidePanel: PatternSidePanelComponent;
readonly infrastructure: InfrastructurePanelComponent;
readonly filters: FilterComponent;
+ readonly whereInput: WhereInputComponent;
readonly savedSearchModal: SavedSearchModalComponent;
readonly savedSearchNameTitle: Locator;
readonly alertModal: SearchPageAlertModalComponent;
@@ -36,10 +40,7 @@ export class SearchPage {
private readonly searchInput: Locator;
private readonly searchButton: Locator;
private readonly saveSearchButton: Locator;
- private readonly languageSelect: Locator;
private readonly updateSearchButton: Locator;
- private readonly luceneTab: Locator;
- private readonly sqlTab: Locator;
private readonly sourceSelector: Locator;
constructor(page: Page, defaultTimeout: number = 3000) {
@@ -64,16 +65,11 @@ export class SearchPage {
// Define page-specific locators
this.searchForm = page.getByTestId('search-form');
+ this.whereInput = new WhereInputComponent(page, this.searchForm);
this.searchInput = page.getByTestId('search-input');
this.searchButton = page.getByTestId('search-submit-button');
this.saveSearchButton = page.getByTestId('save-search-button');
this.updateSearchButton = page.getByTestId('update-search-button');
- const whereLanguageSwitch = page.getByTestId('where-language-switch');
- this.languageSelect = whereLanguageSwitch.getByRole('combobox', {
- name: 'Query language',
- });
- this.sqlTab = page.getByRole('option', { name: 'SQL', exact: true });
- this.luceneTab = page.getByRole('option', { name: 'Lucene', exact: true });
this.sourceSelector = page.getByTestId('source-selector');
}
@@ -205,6 +201,27 @@ export class SearchPage {
await expect(this.sidePanel.tabs).toBeVisible();
}
+ /**
+ * Open the waterfall's spans filter — a `SearchWhereInput` with
+ * `allowMultiline={false}` in both SQL and Lucene.
+ */
+ async openTraceSpansFilter(): Promise {
+ await this.selectSource(DEFAULT_TRACES_SOURCE_NAME);
+ await this.timePicker.selectRelativeTime('Last 1 days');
+ await this.submitEmptySearch();
+ await expect(this.table.firstRow).toBeVisible();
+ await this.table.clickFirstRow();
+ await expect(this.sidePanel.container).toBeVisible();
+ await expect(this.sidePanel.getTab('trace')).toBeVisible({
+ timeout: 15_000,
+ });
+ await this.sidePanel.clickTab('trace');
+ await this.sidePanel.toggleTraceFilters();
+ const whereInput = this.sidePanel.traceSpansWhereInput;
+ await expect(whereInput.languageSwitch).toBeVisible();
+ return whereInput;
+ }
+
/**
* Clear the search input
*/
@@ -216,16 +233,14 @@ export class SearchPage {
* Switch to SQL mode
*/
async switchToSQLMode() {
- await this.languageSelect.click();
- await this.sqlTab.click();
+ await this.whereInput.selectLanguage('SQL');
}
/**
* Switch to Lucene mode
*/
async switchToLuceneMode() {
- await this.languageSelect.click();
- await this.luceneTab.click();
+ await this.whereInput.selectLanguage('Lucene');
}
/**
@@ -312,6 +327,22 @@ export class SearchPage {
return this.page.getByText(/Error loading/i);
}
+ /** Move focus into the WHERE input, whichever language it is rendering. */
+ async focusWhereInput() {
+ await this.whereInput.focus();
+ }
+
+ /**
+ * Border colors of the two halves of the WHERE control. They sit flush
+ * against each other, so a focus state on only one reads as half-focused.
+ */
+ async getWhereBorderColors(): Promise<{
+ languageSwitch: string;
+ input: string;
+ }> {
+ return this.whereInput.borderColors();
+ }
+
/**
* Get SELECT editor (CodeMirror)
*/
@@ -319,6 +350,45 @@ export class SearchPage {
return this.page.locator('.cm-content').first();
}
+ async getSelectBorderColor(): Promise {
+ return this.selectClauseField().visibleBox.evaluate(
+ el => getComputedStyle(el).borderTopColor,
+ );
+ }
+
+ async getOrderByBorderColor(): Promise {
+ return this.orderByClauseField().visibleBox.evaluate(
+ el => getComputedStyle(el).borderTopColor,
+ );
+ }
+
+ /**
+ * The SELECT / ORDER BY clause editors as growth-assertable fields.
+ *
+ * Located from their labels rather than by `.cm-content` index, which shifts
+ * with the WHERE language: in SQL mode the WHERE input is a CodeMirror editor
+ * too, and it comes first.
+ */
+ selectClauseField(): MultilineField {
+ return this.clauseField('SELECT');
+ }
+
+ orderByClauseField(): MultilineField {
+ return this.clauseField('ORDER BY');
+ }
+
+ /** Empty a clause editor so it starts from one short line. */
+ async clearClause(field: MultilineField) {
+ await field.focusTarget.press('ControlOrMeta+A');
+ await field.focusTarget.press('Backspace');
+ }
+
+ private clauseField(label: 'SELECT' | 'ORDER BY'): MultilineField {
+ const paper = borderedBox(this.page.getByText(label, { exact: true }));
+ const content = paper.locator('.cm-content').first();
+ return { focusTarget: content, growthBox: content, visibleBox: paper };
+ }
+
/**
* Get ORDER BY editor (CodeMirror)
*/
@@ -419,11 +489,11 @@ export class SearchPage {
}
get luceneModeTab() {
- return this.luceneTab;
+ return this.page.getByRole('option', { name: 'Lucene', exact: true });
}
get sqlModeTab() {
- return this.sqlTab;
+ return this.page.getByRole('option', { name: 'SQL', exact: true });
}
get sourceDropdown() {
diff --git a/packages/app/tests/e2e/utils/locators.ts b/packages/app/tests/e2e/utils/locators.ts
index 65e3c87af6..bc62158736 100644
--- a/packages/app/tests/e2e/utils/locators.ts
+++ b/packages/app/tests/e2e/utils/locators.ts
@@ -27,6 +27,30 @@ export const getSqlEditor = (page: Page, placeholder?: string) => {
return page.locator(locator).first();
};
+/**
+ * The bordered box around a query editor: the nearest Mantine Paper ancestor,
+ * which is what the user sees grow when the query wraps. Takes any node inside
+ * it — the editor itself, or the clause label beside it.
+ */
+export const borderedBox = (inner: Locator) =>
+ inner.locator(
+ 'xpath=ancestor::div[contains(@class, "mantine-Paper-root")][1]',
+ );
+
+/**
+ * Move focus off whatever holds it, without pressing a key.
+ *
+ * Keyboard-driven blurs are not interchangeable here: Escape inside a Mantine
+ * Modal closes the modal (see `dismissSqlAutocomplete`), and Tab moves focus to
+ * a neighbour that may itself change the layout under test.
+ */
+export const blurActiveElement = async (page: Page) => {
+ await page.evaluate(() => {
+ const el = document.activeElement;
+ if (el instanceof HTMLElement) el.blur();
+ });
+};
+
/**
* Dismiss the CodeMirror autocomplete dropdown by blurring the focused editor.
*
@@ -41,10 +65,7 @@ export const getSqlEditor = (page: Page, placeholder?: string) => {
* intercepted by a still-animating popup.
*/
export const dismissSqlAutocomplete = async (page: Page) => {
- await page.evaluate(() => {
- const el = document.activeElement;
- if (el instanceof HTMLElement) el.blur();
- });
+ await blurActiveElement(page);
await page
.locator('.cm-tooltip-autocomplete')
.waitFor({ state: 'hidden', timeout: 2000 })
diff --git a/packages/app/tests/e2e/utils/multiline-input.ts b/packages/app/tests/e2e/utils/multiline-input.ts
new file mode 100644
index 0000000000..ecfc418369
--- /dev/null
+++ b/packages/app/tests/e2e/utils/multiline-input.ts
@@ -0,0 +1,152 @@
+/**
+ * Helpers for asserting a query input grows to fit a second line and keeps that
+ * height after focus leaves it.
+ *
+ * The regression these guard against is a field that only reserves room for
+ * extra lines while focused, so blurring it clips or overlays everything the
+ * user typed past the first line.
+ */
+import { expect, Locator, Page } from '@playwright/test';
+
+import { blurActiveElement } from './locators';
+
+/** The three elements a growth assertion needs from one input. */
+export type MultilineField = {
+ /**
+ * What the user focuses and types into: a `textarea`, or CodeMirror's
+ * contenteditable `.cm-content` — never the `.cm-editor` wrapper, which never
+ * holds focus.
+ */
+ focusTarget: Locator;
+ /** The element whose height tracks the content. */
+ growthBox: Locator;
+ /** The bordered box the user sees, which must not clip the content. */
+ visibleBox: Locator;
+};
+
+/** Consecutive equal reads before a height counts as settled. */
+const SETTLED_READS = 3;
+const SETTLE_INTERVAL_MS = 100;
+const SETTLE_TIMEOUT_MS = 5_000;
+
+const boxHeight = async (box: Locator): Promise =>
+ (await box.boundingBox())?.height ?? 0;
+
+/**
+ * The height of a box once it stops changing.
+ *
+ * A single `boundingBox()` read taken right after typing or blurring can land
+ * before the re-render that follows it — `react-textarea-autosize` recomputes
+ * its rows in an effect, CodeMirror re-measures on its own schedule — so a
+ * collapse would arrive after the read and the assertion would pass on a stale
+ * value. Polling for a stable window instead of sleeping keeps that
+ * deterministic.
+ */
+export const settledHeight = async (box: Locator): Promise => {
+ let height = 0;
+ let previous = -1;
+ let stableReads = 0;
+
+ await expect
+ .poll(
+ async () => {
+ height = await boxHeight(box);
+ stableReads = height === previous ? stableReads + 1 : 0;
+ previous = height;
+ return stableReads;
+ },
+ { intervals: [SETTLE_INTERVAL_MS], timeout: SETTLE_TIMEOUT_MS },
+ )
+ .toBeGreaterThanOrEqual(SETTLED_READS);
+
+ return height;
+};
+
+/** Assert the field now holds two lines, whichever editor renders it. */
+const expectTwoLines = async (focusTarget: Locator): Promise => {
+ const lines = focusTarget.locator('.cm-line');
+ if ((await lines.count()) > 0) {
+ await expect(lines).toHaveCount(2, { timeout: 2000 });
+ return;
+ }
+ await expect(focusTarget).toHaveValue(/first line[\r\n]+second line/, {
+ timeout: 2000,
+ });
+};
+
+/**
+ * Type two lines into a field, assert it grows to fit them, and assert it still
+ * fits them once focus moves away.
+ *
+ * @param onSingleLine runs while the field holds one short line — its minimum
+ * height, and the only state where a row reserving more space than its bordered
+ * box shows up. An empty field is no good for that check: the placeholder wraps
+ * at narrow widths and the content then drives the height.
+ */
+export const expectExpandsAndStaysExpanded = async (
+ page: Page,
+ { focusTarget, growthBox, visibleBox }: MultilineField,
+ { onSingleLine }: { onSingleLine?: () => Promise } = {},
+): Promise => {
+ await focusTarget.scrollIntoViewIfNeeded();
+ await focusTarget.focus();
+ await page.keyboard.type('first line');
+
+ await onSingleLine?.();
+
+ const singleLineHeight = await settledHeight(growthBox);
+ const singleLineVisibleHeight = await settledHeight(visibleBox);
+
+ await page.keyboard.press('Shift+Enter');
+ await page.keyboard.type('second line');
+ await expectTwoLines(focusTarget);
+
+ expect(await settledHeight(growthBox)).toBeGreaterThanOrEqual(
+ singleLineHeight,
+ );
+
+ // The visible box has to actually grow, not clip the second line.
+ await expect
+ .poll(() => boxHeight(visibleBox), { timeout: SETTLE_TIMEOUT_MS })
+ .toBeGreaterThan(singleLineVisibleHeight);
+ const expandedVisibleHeight = await settledHeight(visibleBox);
+
+ await blurActiveElement(page);
+ await expect(focusTarget).not.toBeFocused();
+
+ expect(await settledHeight(visibleBox)).toBeGreaterThanOrEqual(
+ expandedVisibleHeight,
+ );
+};
+
+/**
+ * A value long enough that a wrapping editor would grow past one row at the
+ * compact (`xs`) size used by the waterfall filters.
+ */
+export const WRAPPING_SINGLE_LINE_QUERY =
+ 'StatusCode:"Error" AND ServiceName:"checkout-service" AND SpanName:"GET /api/v1/checkout"';
+
+/**
+ * Type a long value with no newline and assert the field stays one row while
+ * focused and after blur. Guards `allowMultiline={false}` against growing on
+ * focus and clipping on blur.
+ */
+export const expectStaysOneRow = async (
+ page: Page,
+ { focusTarget, visibleBox }: MultilineField,
+): Promise => {
+ await focusTarget.scrollIntoViewIfNeeded();
+ await focusTarget.focus();
+ await page.keyboard.type('short');
+ const oneRowHeight = await settledHeight(visibleBox);
+
+ await page.keyboard.press('ControlOrMeta+A');
+ await page.keyboard.type(WRAPPING_SINGLE_LINE_QUERY);
+
+ expect(await settledHeight(visibleBox)).toBeLessThanOrEqual(oneRowHeight + 1);
+
+ await blurActiveElement(page);
+ await expect(focusTarget).not.toBeFocused();
+
+ expect(await settledHeight(visibleBox)).toBeLessThanOrEqual(oneRowHeight + 1);
+};