diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx
index 9dcd04d4fb81..2d86a5bfabbf 100644
--- a/static/app/components/searchQueryBuilder/index.spec.tsx
+++ b/static/app/components/searchQueryBuilder/index.spec.tsx
@@ -8822,6 +8822,42 @@ describe('SearchQueryBuilder', () => {
);
});
});
+
+ it('displays the pattern in the filter when the operator is matches regex', async () => {
+ render(
+
+ );
+
+ expect(
+ within(
+ await screen.findByRole('row', {name: 'browser.name://fire.*fox//'})
+ ).getByText('fire.*fox')
+ ).toBeInTheDocument();
+ });
+
+ it('displays the pattern over the input when editing a regex value', async () => {
+ render(
+
+ );
+ await userEvent.click(
+ screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
+ );
+
+ expect(
+ await screen.findByRole('combobox', {name: 'Edit filter value'})
+ ).toHaveValue('fire.*fox');
+ expect(
+ screen.getByText('fire.*fox', {selector: '[aria-hidden="true"] *'})
+ ).toBeInTheDocument();
+ });
});
describe('regex pattern validation', () => {
diff --git a/static/app/components/searchQueryBuilder/tokens/combobox.tsx b/static/app/components/searchQueryBuilder/tokens/combobox.tsx
index e36f73d92136..5fb0b65c751e 100644
--- a/static/app/components/searchQueryBuilder/tokens/combobox.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/combobox.tsx
@@ -121,6 +121,11 @@ type SearchQueryBuilderComboboxProps;
+ /**
+ * Renders the input's value in an overlay drawn on top of the input, whose own
+ * text is hidden. Use to style the value beyond what an input can render.
+ */
+ renderInputValue?: (value: string) => ReactNode;
/**
* Function to determine whether the menu should close when interacting with
* other elements.
@@ -421,6 +426,7 @@ export function SearchQueryBuilderCombobox<
keepVisibleRef,
'data-test-id': dataTestId,
ref,
+ renderInputValue,
}: SearchQueryBuilderComboboxProps) {
const {clearSearchQuery, dispatch} = useSearchQueryBuilderState();
const {disabled} = useSearchQueryBuilderConfig();
@@ -666,55 +672,67 @@ export function SearchQueryBuilderCombobox<
const autosizeInput = useAutosizeInput({value: inputValue});
- return (
-
-
- )}
- type="text"
- placeholder={placeholder}
- onClick={handleInputClick}
- value={inputValue}
- onChange={handleInputChange}
- tabIndex={tabIndex}
- onPaste={onPaste}
- disabled={disabled}
- onKeyDownCapture={e => {
- if (isCtrlKeyPressed(e) && (e.key === 'Backspace' || e.key === 'Delete')) {
- if (token.type === Token.FREE_TEXT) {
- e.preventDefault();
- e.stopPropagation();
- state.close();
- dispatch({
- type: 'DELETE_TO_CURSOR',
- token,
- cursorPosition: e.currentTarget.selectionStart ?? 0,
- inputValue,
- direction: e.key === 'Backspace' ? 'before' : 'after',
- });
- return;
- }
-
- if (!inputValue) {
- e.preventDefault();
- e.stopPropagation();
- onSearchQueryClear?.();
- state.close();
- clearSearchQuery({reopenDropdown: true});
- return;
- }
+ const InputComponent = renderInputValue ? HighlightedInput : UnstyledInput;
+ const input = (
+
+ )}
+ type="text"
+ placeholder={placeholder}
+ onClick={handleInputClick}
+ value={inputValue}
+ onChange={handleInputChange}
+ tabIndex={tabIndex}
+ onPaste={onPaste}
+ disabled={disabled}
+ onKeyDownCapture={e => {
+ if (isCtrlKeyPressed(e) && (e.key === 'Backspace' || e.key === 'Delete')) {
+ if (token.type === Token.FREE_TEXT) {
+ e.preventDefault();
+ e.stopPropagation();
+ state.close();
+ dispatch({
+ type: 'DELETE_TO_CURSOR',
+ token,
+ cursorPosition: e.currentTarget.selectionStart ?? 0,
+ inputValue,
+ direction: e.key === 'Backspace' ? 'before' : 'after',
+ });
+ return;
}
- onKeyDownCapture?.(e, {state});
- }}
- data-test-id={dataTestId}
- />
+ if (!inputValue) {
+ e.preventDefault();
+ e.stopPropagation();
+ onSearchQueryClear?.();
+ state.close();
+ clearSearchQuery({reopenDropdown: true});
+ return;
+ }
+ }
+
+ onKeyDownCapture?.(e, {state});
+ }}
+ data-test-id={dataTestId}
+ />
+ );
+
+ return (
+
+ {renderInputValue ? (
+
+ {input}
+ {renderInputValue(inputValue)}
+
+ ) : (
+ input
+ )}
{description ? (
p.theme.tokens.content.primary};
+ font-kerning: none;
+ font-variant-ligatures: none;
+`;
+
+const HighlightedValue = styled('div')`
+ grid-area: 1 / 1;
+ align-self: center;
+ padding-right: ${p => p.theme.space['2xs']};
+ white-space: pre;
+ pointer-events: none;
+ font-family: ${p => p.theme.font.family.sans};
+ font-weight: ${p => p.theme.font.weight.sans.regular};
+ font-size: ${p => p.theme.form.md.fontSize};
+ font-kerning: none;
+ font-variant-ligatures: none;
+`;
+
const StyledPositionWrapper = styled('div')<{visible?: boolean}>`
display: ${p => (p.visible ? 'block' : 'none')};
z-index: ${p => p.theme.zIndex.tooltip};
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/filter.tsx b/static/app/components/searchQueryBuilder/tokens/filter/filter.tsx
index bf12b3b9071d..65ffb2b4a71f 100644
--- a/static/app/components/searchQueryBuilder/tokens/filter/filter.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/filter/filter.tsx
@@ -22,6 +22,7 @@ import {
import {AggregateKey} from 'sentry/components/searchQueryBuilder/tokens/filter/aggregateKey';
import {FilterKey} from 'sentry/components/searchQueryBuilder/tokens/filter/filterKey';
import {FilterOperator} from 'sentry/components/searchQueryBuilder/tokens/filter/filterOperator';
+import {renderRegexPattern} from 'sentry/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern';
import {UnstyledButton} from 'sentry/components/searchQueryBuilder/tokens/filter/unstyledButton';
import {useFilterButtonProps} from 'sentry/components/searchQueryBuilder/tokens/filter/useFilterButtonProps';
import {
@@ -38,7 +39,7 @@ import {
type ParseResultToken,
type TokenResult,
} from 'sentry/components/searchSyntax/parser';
-import {getKeyName} from 'sentry/components/searchSyntax/utils';
+import {getKeyName, isRegexOperator} from 'sentry/components/searchSyntax/utils';
import {isQueryBuilderPanelChrome} from 'sentry/components/tokenizedInput/token/comboBoxLayout';
import {IconClose} from 'sentry/icons';
import {t} from 'sentry/locale';
@@ -97,14 +98,18 @@ function fitMiddleEllipsisToElement(
return value;
}
- const previousText = element.textContent;
const previousWidth = element.style.width;
const fallback = ellipsizeFilterValue(value, fallbackMaxLength, multi);
+ // Detaching and reattaching the same nodes keeps the references React holds valid.
+ // Writing `element.textContent` instead would destroy any rendered child elements.
+ const children = Array.from(element.childNodes);
+ const measureNode = document.createTextNode(value);
+
try {
// Expand to the full value first so content-sized ancestors can grow up to their
// max-width when the window/search bar is no longer constraining them.
- element.textContent = value;
+ element.replaceChildren(measureNode);
element.style.width = '';
if (element.clientWidth <= 0) {
@@ -124,13 +129,13 @@ function fitMiddleEllipsisToElement(
let low = 1;
let high = value.length;
- element.textContent = ELLIPSIS;
+ measureNode.data = ELLIPSIS;
let best = element.scrollWidth <= availableWidth ? ELLIPSIS : '';
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const candidate = ellipsizeFilterValue(value, mid, multi);
- element.textContent = candidate;
+ measureNode.data = candidate;
if (element.scrollWidth <= availableWidth) {
best = candidate;
low = mid + 1;
@@ -141,7 +146,7 @@ function fitMiddleEllipsisToElement(
return best;
} finally {
- element.textContent = previousText;
+ element.replaceChildren(...children);
element.style.width = previousWidth;
}
}
@@ -150,10 +155,12 @@ function TruncatedFilterDisplayValue({
value,
fallbackMaxLength,
multi = false,
+ renderValue,
}: {
fallbackMaxLength: number;
value: string;
multi?: boolean;
+ renderValue?: (displayValue: string) => React.ReactNode;
}) {
const ref = useRef(null);
const [displayValue, setDisplayValue] = useState(() =>
@@ -203,7 +210,7 @@ function TruncatedFilterDisplayValue({
return (
- {displayValue}
+ {renderValue ? renderValue(displayValue) : displayValue}
);
}
@@ -269,6 +276,7 @@ export function FilterValueText({token}: {token: TokenResult}) {
);
}
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx
new file mode 100644
index 000000000000..091cd3e92794
--- /dev/null
+++ b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.spec.tsx
@@ -0,0 +1,51 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {loadPrismLanguage} from '@sentry/scraps/code';
+
+import {renderRegexPattern} from 'sentry/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern';
+
+jest.unmock('prismjs');
+
+describe('renderRegexPattern', () => {
+ beforeAll(async () => {
+ await loadPrismLanguage('regex', {});
+ });
+
+ it('marks up anchors, character sets, and quantifiers when given a pattern', () => {
+ render(renderRegexPattern('^GET /api/\\d+$'));
+
+ expect(screen.getByText('^')).toHaveClass('anchor');
+ expect(screen.getByText('GET /api/')).toBeInTheDocument();
+ expect(screen.getByText('\\d')).toHaveClass('char-set');
+ expect(screen.getByText('+')).toHaveClass('quantifier');
+ expect(screen.getByText('$')).toHaveClass('anchor');
+ });
+
+ it('marks up the brackets and range when given a character class', () => {
+ render(renderRegexPattern('[a-z]{2,3}'));
+
+ expect(screen.getByText('[')).toHaveClass('char-class-punctuation');
+ expect(screen.getByText('-')).toHaveClass('range-punctuation');
+ expect(screen.getByText(']')).toHaveClass('char-class-punctuation');
+ expect(screen.getByText('{2,3}')).toHaveClass('quantifier');
+ });
+
+ it('marks up the group and keeps the text when given a half-typed pattern', () => {
+ render(renderRegexPattern('(foo'));
+
+ expect(screen.getByText('(')).toHaveClass('group');
+ expect(screen.getByText('foo')).toBeInTheDocument();
+ });
+
+ it('keeps the ellipsis when given a middle-truncated pattern', () => {
+ render(renderRegexPattern('^abc…xyz$'));
+
+ expect(screen.getByText('abc…xyz')).toBeInTheDocument();
+ });
+
+ it('renders the text when given a pattern of only literals', () => {
+ render(renderRegexPattern('firefox'));
+
+ expect(screen.getByText('firefox')).toBeInTheDocument();
+ });
+});
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx
new file mode 100644
index 000000000000..993c65b5356a
--- /dev/null
+++ b/static/app/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern.tsx
@@ -0,0 +1,44 @@
+import styled from '@emotion/styled';
+
+import {usePrismTokens} from 'sentry/utils/usePrismTokens';
+
+export const renderRegexPattern = (pattern: string) => (
+
+);
+
+function HighlightedRegexPattern({pattern}: {pattern: string}) {
+ const [tokens] = usePrismTokens({code: pattern, language: 'regex'});
+
+ return (
+
+ {tokens?.length
+ ? tokens.map((token, index) => (
+
+ {token.children}
+
+ ))
+ : pattern}
+
+ );
+}
+
+const PatternTokens = styled('span')`
+ color: ${p => p.theme.tokens.content.primary};
+
+ .token.group,
+ .token.quantifier,
+ .token.alternation,
+ .token.anchor,
+ .token.char-class-punctuation,
+ .token.char-class-negation,
+ .token.range-punctuation {
+ color: ${p => p.theme.tokens.syntax.operator};
+ }
+
+ .token.char-set,
+ .token.escape,
+ .token.special-escape {
+ color: ${p =>
+ p.theme.type === 'dark' ? p.theme.colors.green600 : p.theme.tokens.content.success};
+ }
+`;
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx
index c1d44ead6585..18253b0cb2ab 100644
--- a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx
@@ -29,6 +29,7 @@ import {
type CustomComboboxMenu,
type CustomComboboxMenuProps,
} from 'sentry/components/searchQueryBuilder/tokens/combobox';
+import {renderRegexPattern} from 'sentry/components/searchQueryBuilder/tokens/filter/highlightedRegexPattern';
import {parseMultiSelectFilterValue} from 'sentry/components/searchQueryBuilder/tokens/filter/parsers/string/parser';
import {SpecificDatePicker} from 'sentry/components/searchQueryBuilder/tokens/filter/specificDatePicker';
import {useFrozenSuggestionSectionItems} from 'sentry/components/searchQueryBuilder/tokens/filter/useFrozenSuggestionSectionItems';
@@ -869,9 +870,14 @@ export function SearchQueryBuilderValueCombobox({
// a long neighbouring chip can't keep it off-screen.
const containerRect = container.getBoundingClientRect();
const inputRect = input.getBoundingClientRect();
+ // An input wider than the row can't be brought fully into view.
+ const isWiderThanRow = inputRect.width > containerRect.width;
+ if (isWiderThanRow && input.selectionStart !== input.value.length) {
+ return;
+ }
if (inputRect.right > containerRect.right) {
container.scrollLeft += inputRect.right - containerRect.right;
- } else if (inputRect.left < containerRect.left) {
+ } else if (inputRect.left < containerRect.left && !isWiderThanRow) {
container.scrollLeft -= containerRect.left - inputRect.left;
}
}, []);
@@ -1424,6 +1430,7 @@ export function SearchQueryBuilderValueCombobox({
inputValue={inputValue}
filterValue={filterValue}
placeholder={placeholder}
+ renderInputValue={isRegexValue ? renderRegexPattern : undefined}
token={token}
inputLabel={t('Edit filter value')}
keepVisibleRef={ref}
@@ -1464,6 +1471,7 @@ export function SearchQueryBuilderValueCombobox({
? committedValues.filter(v => v.index < editingChip.index).length
: chips.length;
const chipRow = [...chips.slice(0, inputSlot), valueInput, ...chips.slice(inputSlot)];
+ const rowScrolls = canSelectMultipleValues || isRegexValue;
return (
@@ -1473,8 +1481,8 @@ export function SearchQueryBuilderValueCombobox({
gap="2xs"
minWidth="0"
height="100%"
- overflowX={canSelectMultipleValues ? 'auto' : undefined}
- overflowY={canSelectMultipleValues ? 'hidden' : undefined}
+ overflowX={rowScrolls ? 'auto' : undefined}
+ overflowY={rowScrolls ? 'hidden' : undefined}
ref={ref}
data-test-id="filter-value-editing"
>