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" >