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
77 changes: 67 additions & 10 deletions static/app/components/searchQueryBuilder/filterOperator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ import {CompactSelect, type SelectOption} from 'sentry/components/compactSelect'
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
import {useFilterButtonProps} from 'sentry/components/searchQueryBuilder/useFilterButtonProps';
import {getValidOpsForFilter} from 'sentry/components/searchQueryBuilder/utils';
import {
getValidOpsForFilter,
isDateToken,
} from 'sentry/components/searchQueryBuilder/utils';
import {
FilterType,
type ParseResultToken,
TermOperator,
type Token,
Expand All @@ -33,6 +37,36 @@ const OP_LABELS = {
[TermOperator.NOT_EQUAL]: 'is not',
};

const DATE_OP_LABELS = {
[TermOperator.GREATER_THAN]: 'is after',
[TermOperator.GREATER_THAN_EQUAL]: 'is on or after',
[TermOperator.LESS_THAN]: 'is before',
[TermOperator.LESS_THAN_EQUAL]: 'is on or before',
[TermOperator.EQUAL]: 'is',
};

const DATE_OPTIONS: TermOperator[] = [
TermOperator.GREATER_THAN,
TermOperator.GREATER_THAN_EQUAL,
TermOperator.LESS_THAN,
TermOperator.LESS_THAN_EQUAL,
TermOperator.EQUAL,
];

function getOperatorFromDateToken(token: TokenResult<Token.FILTER>) {
switch (token.filter) {
case FilterType.DATE:
case FilterType.SPECIFIC_DATE:
return token.operator;
case FilterType.RELATIVE_DATE:
return token.value.sign === '+'
? TermOperator.LESS_THAN
: TermOperator.GREATER_THAN;
default:
return TermOperator.DEFAULT;
}
}

function getTermOperatorFromToken(token: TokenResult<Token.FILTER>) {
if (token.negated) {
return TermOperator.NOT_EQUAL;
Expand All @@ -41,22 +75,45 @@ function getTermOperatorFromToken(token: TokenResult<Token.FILTER>) {
return token.operator;
}

export function FilterOperator({token, state, item}: FilterOperatorProps) {
const {dispatch} = useSearchQueryBuilder();
const operator = getTermOperatorFromToken(token);
const label = OP_LABELS[operator] ?? operator;
const filterButtonProps = useFilterButtonProps({state, item});
function getOperatorInfo(token: TokenResult<Token.FILTER>): {
label: string;
operator: TermOperator;
options: SelectOption<TermOperator>[];
} {
if (isDateToken(token)) {
const operator = getOperatorFromDateToken(token);
return {
operator,
label: DATE_OP_LABELS[operator] ?? operator,
options: DATE_OPTIONS.map(
(op): SelectOption<TermOperator> => ({
value: op,
label: DATE_OP_LABELS[op] ?? op,
})
),
};
}

const options = useMemo<SelectOption<TermOperator>[]>(() => {
return getValidOpsForFilter(token)
const operator = getTermOperatorFromToken(token);
return {
operator,
label: OP_LABELS[operator] ?? operator,
options: getValidOpsForFilter(token)
.filter(op => op !== TermOperator.EQUAL)
.map(
(op): SelectOption<TermOperator> => ({
value: op,
label: OP_LABELS[op] ?? op,
})
);
}, [token]);
),
};
}

export function FilterOperator({token, state, item}: FilterOperatorProps) {
const {dispatch} = useSearchQueryBuilder();
const filterButtonProps = useFilterButtonProps({state, item});

const {operator, label, options} = useMemo(() => getOperatorInfo(token), [token]);

return (
<CompactSelect
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
const { TokenConverter, config = {} } = options;
const tc = new TokenConverter({text, location, config});
}

value = iso_8601_date_format / rel_date_format

num2 = [0-9] [0-9]
num4 = [0-9] [0-9] [0-9] [0-9]

date_format = num4 "-" num2 "-" num2
time_format = "T" num2 ":" num2 ":" num2 ("." ms_format)?
ms_format = [0-9] [0-9]? [0-9]? [0-9]? [0-9]? [0-9]?
tz_format = [+-] num2 ":" num2

iso_8601_date_format
= date_format time_format? ("Z" / tz_format)? {
return tc.tokenValueIso8601Date(text());
}

rel_date_format
= sign:[+-] value:[0-9]+ unit:[wdhm] {
return tc.tokenValueRelativeDate(value.join(''), sign, unit);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {
type Token,
TokenConverter,
type TokenResult,
} from 'sentry/components/searchSyntax/parser';

import grammar from './grammar.pegjs';

/**
* This parser is specifically meant for parsing the value of a date filter.
* This should mirror the grammar used for search syntax, but we cannot
* use it directly since the grammar is designed to parse the entire search query
* and will fail if we just pass in a date value.
*/
export function parseFilterValueDate(
query: string
): TokenResult<Token.VALUE_ISO_8601_DATE | Token.VALUE_RELATIVE_DATE> | null {
try {
return grammar.parse(query, {TokenConverter});
} catch (e) {
return null;
}
}
119 changes: 105 additions & 14 deletions static/app/components/searchQueryBuilder/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const FITLER_KEY_SECTIONS: FilterKeySection[] = [
name: 'is',
alias: 'issue.status',
predefined: true,
values: ['resolved', 'unresolved', 'ignored'],
},
{
key: FieldKey.TIMES_SEEN,
Expand Down Expand Up @@ -308,32 +309,32 @@ describe('SearchQueryBuilder', function () {
});

it('can modify the value by clicking into it (single-select)', async function () {
// `age` is a duration filter which only accepts single values
render(<SearchQueryBuilder {...defaultProps} initialQuery="age:-1d" />);
// `is` only accepts single values
render(<SearchQueryBuilder {...defaultProps} initialQuery="is:unresolved" />);

// Should display as "-1d" to start
// Should display as "unresolved" to start
expect(
within(
screen.getByRole('button', {name: 'Edit value for filter: age'})
).getByText('-1d')
within(screen.getByRole('button', {name: 'Edit value for filter: is'})).getByText(
'unresolved'
)
).toBeInTheDocument();

await userEvent.click(
screen.getByRole('button', {name: 'Edit value for filter: age'})
screen.getByRole('button', {name: 'Edit value for filter: is'})
);
// Should have placeholder text of previous value
expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveAttribute(
'placeholder',
'-1d'
'unresolved'
);

// Clicking the "-14d" option should update the value
await userEvent.click(await screen.findByRole('option', {name: '-14d'}));
expect(screen.getByRole('row', {name: 'age:-14d'})).toBeInTheDocument();
// Clicking the "resolved" option should update the value
await userEvent.click(await screen.findByRole('option', {name: 'resolved'}));
expect(screen.getByRole('row', {name: 'is:resolved'})).toBeInTheDocument();
expect(
within(
screen.getByRole('button', {name: 'Edit value for filter: age'})
).getByText('-14d')
within(screen.getByRole('button', {name: 'Edit value for filter: is'})).getByText(
'resolved'
)
).toBeInTheDocument();
});

Expand Down Expand Up @@ -964,5 +965,95 @@ describe('SearchQueryBuilder', function () {
).toBeInTheDocument();
});
});

describe('date', function () {
it('new date filters start with a value', async function () {
render(<SearchQueryBuilder {...defaultProps} />);
await userEvent.click(screen.getByRole('grid'));
await userEvent.keyboard('age{ArrowDown}{Enter}');

// Should start with a relative date value
expect(await screen.findByRole('row', {name: 'age:-24h'})).toBeInTheDocument();
});

it('does not allow invalid values', async function () {
render(<SearchQueryBuilder {...defaultProps} initialQuery="age:-24h" />);
await userEvent.click(
screen.getByRole('button', {name: 'Edit value for filter: age'})
);
await userEvent.keyboard('a{Enter}');

// Should have the same value because "a" is not a date value
expect(screen.getByRole('row', {name: 'age:-24h'})).toBeInTheDocument();
});

it('shows default date suggestions', async function () {
render(<SearchQueryBuilder {...defaultProps} initialQuery="age:-24h" />);
await userEvent.click(
screen.getByRole('button', {name: 'Edit value for filter: age'})
);
await userEvent.click(await screen.findByRole('option', {name: '1 hour ago'}));
expect(screen.getByRole('row', {name: 'age:-1h'})).toBeInTheDocument();
});

it('shows date suggestions when typing', async function () {
render(<SearchQueryBuilder {...defaultProps} initialQuery="age:-24h" />);
await userEvent.click(
screen.getByRole('button', {name: 'Edit value for filter: age'})
);

// Typing "7" should show suggestions for 7 minutes, hours, days, and weeks
await userEvent.keyboard('7');
await screen.findByRole('option', {name: '7 minutes ago'});
expect(screen.getByRole('option', {name: '7 hours ago'})).toBeInTheDocument();
expect(screen.getByRole('option', {name: '7 days ago'})).toBeInTheDocument();
expect(screen.getByRole('option', {name: '7 weeks ago'})).toBeInTheDocument();

await userEvent.click(screen.getByRole('option', {name: '7 weeks ago'}));
expect(screen.getByRole('row', {name: 'age:-7w'})).toBeInTheDocument();
});

it('can search before a relative date', async function () {
render(<SearchQueryBuilder {...defaultProps} initialQuery="age:-24h" />);
await userEvent.click(
screen.getByRole('button', {name: 'Edit operator for filter: age'})
);
await userEvent.click(await screen.findByRole('option', {name: 'is before'}));

// Should flip from "-" to "+"
expect(await screen.findByRole('row', {name: 'age:+24h'})).toBeInTheDocument();
});

it('switches to an absolute date when choosing operator with equality', async function () {
render(<SearchQueryBuilder {...defaultProps} initialQuery="age:-24h" />);
await userEvent.click(
screen.getByRole('button', {name: 'Edit operator for filter: age'})
);
await userEvent.click(
await screen.findByRole('option', {name: 'is on or after'})
);

// Changes operator and fills in the current date (ISO format)
expect(
await screen.findByRole('row', {name: 'age:>=2017-10-17T02:41:20.000Z'})
).toBeInTheDocument();
});

it('changes operator when selecting a relative date', async function () {
render(<SearchQueryBuilder {...defaultProps} initialQuery="age:>=2017-10-17" />);
await userEvent.click(
screen.getByRole('button', {name: 'Edit value for filter: age'})
);
await userEvent.click(await screen.findByRole('option', {name: '1 hour ago'}));

// Because relative dates only work with ":", should change the operator to "is after"
expect(
within(
screen.getByRole('button', {name: 'Edit operator for filter: age'})
).getByText('is after')
).toBeInTheDocument();
expect(await screen.findByRole('row', {name: 'age:-1h'})).toBeInTheDocument();
});
});
});
});
5 changes: 0 additions & 5 deletions static/app/components/searchQueryBuilder/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,6 @@ const FITLER_KEY_SECTIONS: FilterKeySection[] = [
predefined: true,
values: ['Chrome', 'Firefox', 'Safari', 'Edge'],
},
{
key: FieldKey.LAST_SEEN,
name: 'Last Seen',
kind: FieldKind.FIELD,
},
],
},
{
Expand Down
Loading