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
7 changes: 7 additions & 0 deletions static/app/components/compactSelect/control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ export interface ControlProps
* true).
*/
onClear?: () => void;
/**
* Called when the menu is opened or closed.
*/
onOpenChange?: (newOpenState: boolean) => void;
/**
* Called when the search input's value changes (applicable only when `searchable`
* is true).
Expand Down Expand Up @@ -233,6 +237,7 @@ export function Control({
menuHeaderTrailingItems,
menuBody,
menuFooter,
onOpenChange,

// Select props
size = 'md',
Expand Down Expand Up @@ -327,6 +332,8 @@ export function Control({
preventOverflowOptions,
flipOptions,
onOpenChange: open => {
onOpenChange?.(open);

nextFrameCallback(() => {
if (open) {
// Focus on search box if present
Expand Down
41 changes: 41 additions & 0 deletions static/app/components/searchQueryBuilder/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1969,4 +1969,45 @@ describe('SearchQueryBuilder', function () {
).toBeInTheDocument();
});
});

describe('disallowWildcard', function () {
it('should mark tokens with wildcards invalid', async function () {
render(
<SearchQueryBuilder
{...defaultProps}
disallowWildcard
initialQuery="browser.name:Firefox*"
/>
);

expect(screen.getByRole('row', {name: 'browser.name:Firefox*'})).toHaveAttribute(
'aria-invalid',
'true'
);

// Put focus into token, should show error message
await userEvent.click(getLastInput());
await userEvent.keyboard('{ArrowLeft}');

expect(
await screen.findByText('Wildcards not supported in search')
).toBeInTheDocument();
});

it('should mark free text with wildcards invalid', async function () {
render(
<SearchQueryBuilder {...defaultProps} disallowWildcard initialQuery="foo*" />
);

expect(screen.getByRole('row', {name: 'foo*'})).toHaveAttribute(
'aria-invalid',
'true'
);

await userEvent.click(getLastInput());
expect(
await screen.findByText('Wildcards not supported in search')
).toBeInTheDocument();
});
});
});
42 changes: 41 additions & 1 deletion static/app/components/searchQueryBuilder/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {Fragment} from 'react';
import {Fragment, useState} from 'react';
import styled from '@emotion/styled';

import Alert from 'sentry/components/alert';
import MultipleCheckbox from 'sentry/components/forms/controls/multipleCheckbox';
import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder';
import type {FilterKeySection} from 'sentry/components/searchQueryBuilder/types';
import SizingWindow from 'sentry/components/stories/sizingWindow';
Expand Down Expand Up @@ -111,6 +112,45 @@ export default storyBook(SearchQueryBuilder, story => {
</Fragment>
);
});

story('Config Options', () => {
const configs = ['disallowLogicalOperators', 'disallowWildcard'];

const [enabledConfigs, setEnabledConfigs] = useState<string[]>([...configs]);
const queryBuilderOptions = enabledConfigs.reduce((acc, config) => {
acc[config] = true;
return acc;
}, {});

return (
<Fragment>
<p>
There are some config options which allow you to customize which types of syntax
are considered valid. This should be used when the search backend does not
support certain operators like boolean logic or wildcards.
</p>
<MultipleCheckbox
onChange={setEnabledConfigs}
value={enabledConfigs}
name="enabled configs"
>
{configs.map(config => (
<MultipleCheckbox.Item key={config} value={config}>
{config}
</MultipleCheckbox.Item>
))}
</MultipleCheckbox>
<SearchQueryBuilder
initialQuery="(browser.name:Firefox OR browser.name:Internet*) TypeError*"
filterKeySections={FITLER_KEY_SECTIONS}
filterKeys={FILTER_KEYS}
getTagValues={getTagValues}
searchSource="storybook"
{...queryBuilderOptions}
/>
</Fragment>
);
});
});

const MinHeightSizingWindow = styled(SizingWindow)`
Expand Down
14 changes: 13 additions & 1 deletion static/app/components/searchQueryBuilder/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export interface SearchQueryBuilderProps {
* When true, parens and logical operators (AND, OR) will be marked as invalid.
*/
disallowLogicalOperators?: boolean;
/**
* When true, the wildcard (*) in filter values or free text will be marked as invalid.
*/
disallowWildcard?: boolean;
/**
* The lookup strategy for field definitions.
* Each SearchQueryBuilder instance can support a different list of fields and
Expand Down Expand Up @@ -92,6 +96,7 @@ function ActionButtons() {
export function SearchQueryBuilder({
className,
disallowLogicalOperators,
disallowWildcard,
label,
initialQuery,
fieldDefinitionGetter = getFieldDefinition,
Expand All @@ -113,9 +118,16 @@ export function SearchQueryBuilder({
() =>
parseQueryBuilderValue(state.query, fieldDefinitionGetter, {
disallowLogicalOperators,
disallowWildcard,
filterKeys,
}),
[disallowLogicalOperators, fieldDefinitionGetter, filterKeys, state.query]
[
disallowLogicalOperators,
disallowWildcard,
fieldDefinitionGetter,
filterKeys,
state.query,
]
);

useEffectAfterFirstRender(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ function Grid(props: GridProps) {
/>
);
case Token.FREE_TEXT:
case Token.SPACES:
return (
<SearchQueryBuilderFreeText
key={item.key}
Expand Down
14 changes: 3 additions & 11 deletions static/app/components/searchQueryBuilder/tokens/deletableToken.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {Node} from '@react-types/shared';
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
import {useQueryBuilderGridItem} from 'sentry/components/searchQueryBuilder/hooks/useQueryBuilderGridItem';
import {InvalidTokenTooltip} from 'sentry/components/searchQueryBuilder/tokens/invalidTokenTooltip';
import {
shiftFocusToChild,
useShiftFocusToChild,
Expand All @@ -16,7 +17,6 @@ import type {
InvalidReason,
ParseResultToken,
} from 'sentry/components/searchSyntax/parser';
import {Tooltip} from 'sentry/components/tooltip';
import {IconClose} from 'sentry/icons';
import {t} from 'sentry/locale';

Expand Down Expand Up @@ -55,8 +55,6 @@ export function DeletableToken({
shiftFocusToChild(e.currentTarget, item, state);
};

const isFocused =
state.selectionManager.isFocused && state.selectionManager.focusedKey === item.key;
const isInvalid = Boolean(invalid);

return (
Expand All @@ -66,13 +64,7 @@ export function DeletableToken({
ref={ref}
>
{children}
<Tooltip
skipWrapper
disabled={!isInvalid}
forceVisible={isFocused ? true : undefined}
position="bottom"
title={invalid?.reason ?? t('This token is invalid')}
>
<InvalidTokenTooltip token={token} state={state} item={item}>
<HoverFocusBorder>
<FloatingCloseButton
{...gridCellProps}
Expand All @@ -87,7 +79,7 @@ export function DeletableToken({
<IconClose legacySize="10px" />
</FloatingCloseButton>
</HoverFocusBorder>
</Tooltip>
</InvalidTokenTooltip>
</Wrapper>
);
}
Expand Down
70 changes: 52 additions & 18 deletions static/app/components/searchQueryBuilder/tokens/filter/filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {FilterKeyOperator} from 'sentry/components/searchQueryBuilder/tokens/fil
import {useFilterButtonProps} from 'sentry/components/searchQueryBuilder/tokens/filter/useFilterButtonProps';
import {formatFilterValue} from 'sentry/components/searchQueryBuilder/tokens/filter/utils';
import {SearchQueryBuilderValueCombobox} from 'sentry/components/searchQueryBuilder/tokens/filter/valueCombobox';
import {InvalidTokenTooltip} from 'sentry/components/searchQueryBuilder/tokens/invalidTokenTooltip';
import {
type ParseResultToken,
Token,
Expand All @@ -31,6 +32,7 @@ interface SearchQueryTokenProps {

interface FilterValueProps extends SearchQueryTokenProps {
filterRef: React.RefObject<HTMLDivElement>;
onActiveChange: (active: boolean) => void;
}

function FilterValueText({token}: {token: TokenResult<Token.FILTER>}) {
Expand Down Expand Up @@ -81,7 +83,7 @@ function FilterValueText({token}: {token: TokenResult<Token.FILTER>}) {
}
}

function FilterValue({token, state, item, filterRef}: FilterValueProps) {
function FilterValue({token, state, item, filterRef, onActiveChange}: FilterValueProps) {
const ref = useRef<HTMLDivElement>(null);
const {dispatch, focusOverride} = useSearchQueryBuilder();

Expand All @@ -94,9 +96,10 @@ function FilterValue({token, state, item, filterRef}: FilterValueProps) {
focusOverride.part === 'value'
) {
setIsEditing(true);
onActiveChange(true);
dispatch({type: 'RESET_FOCUS_OVERRIDE'});
}
}, [dispatch, focusOverride, isEditing, item.key]);
}, [dispatch, focusOverride, isEditing, item.key, onActiveChange]);

const {focusWithinProps} = useFocusWithin({
onBlurWithin: () => {
Expand All @@ -116,9 +119,11 @@ function FilterValue({token, state, item, filterRef}: FilterValueProps) {
filterRef.current?.focus();
state.selectionManager.setFocusedKey(item.key);
setIsEditing(false);
onActiveChange(false);
}}
onCommit={() => {
setIsEditing(false);
onActiveChange(false);
if (state.collection.getKeyAfter(item.key)) {
state.selectionManager.setFocusedKey(
state.collection.getKeyAfter(item.key)
Expand All @@ -133,7 +138,10 @@ function FilterValue({token, state, item, filterRef}: FilterValueProps) {
return (
<ValueButton
aria-label={t('Edit value for filter: %s', token.key.text)}
onClick={() => setIsEditing(true)}
onClick={() => {
setIsEditing(true);
onActiveChange(true);
}}
{...filterButtonProps}
>
<InteractionStateLayer />
Expand All @@ -160,6 +168,7 @@ function FilterDelete({token, state, item}: SearchQueryTokenProps) {

export function SearchQueryBuilderFilter({item, state, token}: SearchQueryTokenProps) {
const ref = useRef<HTMLDivElement>(null);
const [filterMenuOpen, setFilterMenuOpen] = useState(false);

const isFocused = item.key === state.selectionManager.focusedKey;

Expand All @@ -185,38 +194,52 @@ export function SearchQueryBuilderFilter({item, state, token}: SearchQueryTokenP
onKeyDown,
});

// TODO(malwilley): Add better error messaging
const tokenHasError = 'invalid' in token && defined(token.invalid);

return (
<FilterWrapper
aria-label={token.text}
data-invalid={tokenHasError}
aria-invalid={tokenHasError}
ref={ref}
{...modifiedRowProps}
>
<BaseGridCell {...gridCellProps}>
<FilterKeyOperator token={token} state={state} item={item} />
</BaseGridCell>
<FilterValueGridCell {...gridCellProps}>
<FilterValue token={token} state={state} item={item} filterRef={ref} />
</FilterValueGridCell>
<BaseGridCell {...gridCellProps}>
<FilterDelete token={token} state={state} item={item} />
</BaseGridCell>
<GridInvalidTokenTooltip
token={token}
state={state}
item={item}
containerDisplayMode="grid"
forceVisible={filterMenuOpen ? false : undefined}
>
<BaseGridCell {...gridCellProps}>
<FilterKeyOperator
token={token}
state={state}
item={item}
onOpenChange={setFilterMenuOpen}
/>
</BaseGridCell>
<FilterValueGridCell {...gridCellProps}>
<FilterValue
token={token}
state={state}
item={item}
filterRef={ref}
onActiveChange={setFilterMenuOpen}
/>
</FilterValueGridCell>
<BaseGridCell {...gridCellProps}>
<FilterDelete token={token} state={state} item={item} />
</BaseGridCell>
</GridInvalidTokenTooltip>
</FilterWrapper>
);
}

const FilterWrapper = styled('div')`
position: relative;
display: grid;
grid-template-columns: auto auto auto auto;
align-items: stretch;
border: 1px solid ${p => p.theme.innerBorder};
border-radius: ${p => p.theme.borderRadius};
height: 24px;

/* Ensures that filters do not grow outside of the container */
min-width: 0;

Expand All @@ -228,6 +251,17 @@ const FilterWrapper = styled('div')`
&[aria-selected='true'] {
background-color: ${p => p.theme.gray100};
}

&[aria-invalid='true'] {
border-color: ${p => p.theme.red400};
}
`;

const GridInvalidTokenTooltip = styled(InvalidTokenTooltip)`
display: grid;
grid-template-columns: auto auto auto auto;
align-items: stretch;
height: 22px;
`;

const BaseGridCell = styled('div')`
Expand Down
Loading