Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions static/app/components/searchQueryBuilder/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8822,6 +8822,42 @@ describe('SearchQueryBuilder', () => {
);
});
});

it('displays the pattern in the filter when the operator is matches regex', async () => {
render(
<SearchQueryBuilder
{...defaultProps}
allowRegexOperators
initialQuery="browser.name://fire.*fox//"
/>
);

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(
<SearchQueryBuilder
{...defaultProps}
allowRegexOperators
initialQuery="browser.name://fire.*fox//"
/>
);
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', () => {
Expand Down
143 changes: 96 additions & 47 deletions static/app/components/searchQueryBuilder/tokens/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ type SearchQueryBuilderComboboxProps<T extends SelectOptionOrSectionWithKey<stri
openOnFocus?: boolean;
placeholder?: string;
ref?: React.Ref<HTMLInputElement>;
/**
* 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.
Expand Down Expand Up @@ -421,6 +426,7 @@ export function SearchQueryBuilderCombobox<
keepVisibleRef,
'data-test-id': dataTestId,
ref,
renderInputValue,
}: SearchQueryBuilderComboboxProps<T>) {
const {clearSearchQuery, dispatch} = useSearchQueryBuilderState();
const {disabled} = useSearchQueryBuilderConfig();
Expand Down Expand Up @@ -666,55 +672,67 @@ export function SearchQueryBuilderCombobox<

const autosizeInput = useAutosizeInput({value: inputValue});

return (
<Flex align="stretch" width="100%" height="100%" position="relative">
<UnstyledInput
{...inputProps}
size="md"
ref={mergeRefs(
ref,
inputRef,
autosizeInput,
triggerProps.ref as React.Ref<HTMLInputElement>
)}
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 = (
<InputComponent
{...inputProps}
size="md"
ref={mergeRefs(
ref,
inputRef,
autosizeInput,
triggerProps.ref as React.Ref<HTMLInputElement>
)}
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 (
<Flex align="stretch" width="100%" height="100%" position="relative">
{renderInputValue ? (
<HighlightStack>
{input}
<HighlightedValue aria-hidden>{renderInputValue(inputValue)}</HighlightedValue>
</HighlightStack>
) : (
input
)}
{description ? (
<StyledPositionWrapper
{...descriptionPopper.attributes.popper}
Expand Down Expand Up @@ -769,6 +787,37 @@ const UnstyledInput = styled(Input)`
}
`;

// The input and its highlighted value share one grid cell at least as wide as the value's
// text, so the input never scrolls its own text out from under the highlighting.
const HighlightStack = styled('div')`
display: grid;
grid-template-columns: minmax(max-content, 1fr);
flex-grow: 1;
`;

// WebKit doesn't kern across the highlighted value's token spans, so neither side kerns.
const HighlightedInput = styled(UnstyledInput)`
grid-area: 1 / 1;
min-width: 100%;
color: transparent;
caret-color: ${p => 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};
Comment thread
JoshuaKGoldberg marked this conversation as resolved.
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};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -141,7 +146,7 @@ function fitMiddleEllipsisToElement(

return best;
} finally {
element.textContent = previousText;
element.replaceChildren(...children);
element.style.width = previousWidth;
}
}
Expand All @@ -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<HTMLDivElement>(null);
const [displayValue, setDisplayValue] = useState(() =>
Expand Down Expand Up @@ -203,7 +210,7 @@ function TruncatedFilterDisplayValue({

return (
<Truncated ref={ref} data-overflowing={displayValue === value ? undefined : 'true'}>
{displayValue}
{renderValue ? renderValue(displayValue) : displayValue}
</Truncated>
);
}
Expand Down Expand Up @@ -269,6 +276,7 @@ export function FilterValueText({token}: {token: TokenResult<Token.FILTER>}) {
<TruncatedFilterDisplayValue
value={formatFilterValue({token: token.value, valueType})}
fallbackMaxLength={FILTER_VALUE_FALLBACK_MAX_LENGTH}
renderValue={isRegexOperator(token.operator) ? renderRegexPattern : undefined}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading