Skip to content

Commit 585b1dd

Browse files
authored
feat(explore): Support literals in equations (#95052)
This adds support for literals in equations. Literals include positive and negative numbers. One caveat here is that it currently turns `-` into the subtration operator meaning typing a negative number is a a little awkward.
1 parent a679369 commit 585b1dd

File tree

13 files changed

+1091
-251
lines changed

13 files changed

+1091
-251
lines changed

static/app/components/arithmeticBuilder/grammar.pegjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
tokens = token*
66

77
token
8-
= spaces token:(paren / op / func / free_text) spaces {
8+
= spaces token:(paren / literal / op / func / free_text) spaces {
99
return token;
1010
}
1111

@@ -70,6 +70,11 @@ type_name
7070
return text();
7171
}
7272

73+
literal
74+
= [+-]?[0-9]+ ("." [0-9]*)? {
75+
return tc.tokenLiteral(text(), location());
76+
}
77+
7378
op = plus / minus / multiply / divide
7479

7580
plus

static/app/components/arithmeticBuilder/token/freeText.tsx

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {Token, TokenFreeText} from 'sentry/components/arithmeticBuilder/tok
1010
import {
1111
isTokenFreeText,
1212
isTokenFunction,
13+
isTokenLiteral,
1314
isTokenOperator,
1415
isTokenParenthesis,
1516
TokenKind,
@@ -82,6 +83,17 @@ export function ArithmeticTokenFreeText({
8283
);
8384
}
8485

86+
type FocusTokenFunction = {
87+
func: string;
88+
kind: TokenKind.FUNCTION;
89+
};
90+
91+
type FocusTokenLiteral = {
92+
kind: TokenKind.LITERAL;
93+
};
94+
95+
type FocusToken = FocusTokenFunction | FocusTokenLiteral;
96+
8597
interface InternalInputProps extends ArithmeticTokenFreeTextProps {
8698
rowRef: RefObject<HTMLDivElement | null>;
8799
}
@@ -121,13 +133,17 @@ function InternalInput({
121133
const {dispatch, aggregations, getFieldDefinition} = useArithmeticBuilder();
122134

123135
const getNextFocusOverride = useCallback(
124-
(func?: string): string => {
125-
if (defined(func)) {
126-
const definition = getFieldDefinition(func);
127-
const parameterDefinitions: AggregateParameter[] = definition?.parameters ?? [];
128-
if (parameterDefinitions.length > 0) {
129-
// if they selected a function with arguments, move focus into the function argument
130-
return nextTokenKeyOfKind(state, token, TokenKind.FUNCTION);
136+
(focusToken?: FocusToken): string => {
137+
if (defined(focusToken)) {
138+
if (focusToken.kind === TokenKind.FUNCTION) {
139+
const definition = getFieldDefinition(focusToken.func);
140+
const parameterDefinitions: AggregateParameter[] = definition?.parameters ?? [];
141+
if (parameterDefinitions.length > 0) {
142+
// if they selected a function with arguments, move focus into the function argument
143+
return nextTokenKeyOfKind(state, token, TokenKind.FUNCTION);
144+
}
145+
} else if (focusToken.kind === TokenKind.LITERAL) {
146+
return nextTokenKeyOfKind(state, token, TokenKind.LITERAL);
131147
}
132148
}
133149

@@ -180,15 +196,44 @@ function InternalInput({
180196
const tokens = tokenizeExpression(text);
181197

182198
for (const tok of tokens) {
183-
if (isTokenParenthesis(tok) || isTokenOperator(tok) || isTokenFunction(tok)) {
199+
if (isTokenParenthesis(tok) || isTokenOperator(tok)) {
200+
dispatch({
201+
type: 'REPLACE_TOKEN',
202+
token,
203+
text,
204+
focusOverride: {
205+
itemKey: getNextFocusOverride(),
206+
},
207+
});
208+
resetInputValue();
209+
return;
210+
}
211+
212+
if (isTokenFunction(tok)) {
213+
dispatch({
214+
type: 'REPLACE_TOKEN',
215+
token,
216+
text,
217+
focusOverride: {
218+
itemKey: getNextFocusOverride({
219+
kind: TokenKind.FUNCTION,
220+
func: tok.function,
221+
}),
222+
},
223+
});
224+
resetInputValue();
225+
return;
226+
}
227+
228+
if (isTokenLiteral(tok)) {
184229
dispatch({
185230
type: 'REPLACE_TOKEN',
186231
token,
187232
text,
188233
focusOverride: {
189-
itemKey: getNextFocusOverride(
190-
isTokenFunction(tok) ? tok.function : undefined
191-
),
234+
itemKey: getNextFocusOverride({
235+
kind: TokenKind.LITERAL,
236+
}),
192237
},
193238
});
194239
resetInputValue();
@@ -205,7 +250,12 @@ function InternalInput({
205250
type: 'REPLACE_TOKEN',
206251
token,
207252
text: `${input.substring(0, pos + 1)}${getFunctionDefault(maybeFunc)}`,
208-
focusOverride: {itemKey: getNextFocusOverride(maybeFunc)},
253+
focusOverride: {
254+
itemKey: getNextFocusOverride({
255+
kind: TokenKind.FUNCTION,
256+
func: maybeFunc,
257+
}),
258+
},
209259
});
210260
resetInputValue();
211261
return;
@@ -223,7 +273,6 @@ function InternalInput({
223273
getNextFocusOverride,
224274
getFunctionDefault,
225275
resetInputValue,
226-
setInputValue,
227276
token,
228277
]
229278
);
@@ -312,7 +361,9 @@ function InternalInput({
312361
token,
313362
text: isFunction ? getFunctionDefault(option.value) : option.value,
314363
focusOverride: {
315-
itemKey: getNextFocusOverride(isFunction ? option.value : undefined),
364+
itemKey: getNextFocusOverride(
365+
isFunction ? {kind: TokenKind.FUNCTION, func: option.value} : undefined
366+
),
316367
},
317368
});
318369
resetInputValue();

static/app/components/arithmeticBuilder/token/function.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export function ArithmeticTokenFunction({
5252
});
5353

5454
const isFocused = item.key === state.selectionManager.focusedKey;
55-
5655
const showUnfocusedState = !state.selectionManager.isFocused || !isFocused;
5756

5857
return (
@@ -79,7 +78,7 @@ export function ArithmeticTokenFunction({
7978
{showUnfocusedState && (
8079
// Inject a floating span with the attribute name so when it's
8180
// not focused, it doesn't look like the placeholder text
82-
<FunctionArgumentOverlay>{attribute.attribute}</FunctionArgumentOverlay>
81+
<UnfocusedOverlay>{attribute.attribute}</UnfocusedOverlay>
8382
)}
8483
</BaseGridCell>
8584
)}
@@ -91,13 +90,10 @@ export function ArithmeticTokenFunction({
9190
);
9291
}
9392

94-
interface InternalInputProps {
93+
interface InternalInputProps extends ArithmeticTokenFunctionProps {
9594
argumentIndex: number;
9695
attribute: TokenAttribute;
97-
item: Node<Token>;
9896
rowRef: RefObject<HTMLDivElement | null>;
99-
state: ListState<Token>;
100-
token: TokenFunction;
10197
}
10298

10399
function InternalInput({
@@ -428,7 +424,7 @@ const FunctionGridCell = styled(BaseGridCell)`
428424
padding-left: ${space(0.5)};
429425
`;
430426

431-
const FunctionArgumentOverlay = styled('div')`
427+
const UnfocusedOverlay = styled('div')`
432428
position: absolute;
433429
pointer-events: none;
434430
`;

static/app/components/arithmeticBuilder/token/grid.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import type {Token} from 'sentry/components/arithmeticBuilder/token';
1212
import {
1313
isTokenFreeText,
1414
isTokenFunction,
15+
isTokenLiteral,
1516
isTokenOperator,
1617
isTokenParenthesis,
1718
} from 'sentry/components/arithmeticBuilder/token';
1819
import {ArithmeticTokenFreeText} from 'sentry/components/arithmeticBuilder/token/freeText';
1920
import {ArithmeticTokenFunction} from 'sentry/components/arithmeticBuilder/token/function';
21+
import {ArithmeticTokenLiteral} from 'sentry/components/arithmeticBuilder/token/literal';
2022
import {ArithmeticTokenOperator} from 'sentry/components/arithmeticBuilder/token/operator';
2123
import {ArithmeticTokenParenthesis} from 'sentry/components/arithmeticBuilder/token/parenthesis';
2224
import {computeNextAllowedTokenKinds} from 'sentry/components/arithmeticBuilder/validator';
@@ -164,6 +166,17 @@ function GridList({showPlaceholder, ...props}: GridListProps) {
164166
);
165167
}
166168

169+
if (isTokenLiteral(token)) {
170+
return (
171+
<ArithmeticTokenLiteral
172+
key={item.key}
173+
item={item}
174+
state={state}
175+
token={token}
176+
/>
177+
);
178+
}
179+
167180
Sentry.captureMessage(`Unknown token: ${token.kind}`);
168181
return null;
169182
})}

static/app/components/arithmeticBuilder/token/index.spec.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,95 @@ describe('token', function () {
613613
});
614614
});
615615

616+
describe('ArithmeticTokenLiteral', function () {
617+
it.each(['1', '1.', '1.0', '+1', '+1.', '+1.0', '-1', '-1.', '-1.0'])(
618+
'renders literal %s',
619+
async function (expression) {
620+
const dispatch = jest.fn();
621+
render(<Tokens expression={expression} dispatch={dispatch} />);
622+
623+
expect(await screen.findByRole('row', {name: expression})).toBeInTheDocument();
624+
625+
const input = screen.getByRole('textbox', {
626+
name: 'Add a literal',
627+
});
628+
expect(input).toBeInTheDocument();
629+
}
630+
);
631+
632+
it('completes literal with space', async function () {
633+
const dispatch = jest.fn();
634+
render(<Tokens expression="1" dispatch={dispatch} />);
635+
636+
expect(await screen.findByRole('row', {name: '1'})).toBeInTheDocument();
637+
638+
const input = screen.getByRole('textbox', {
639+
name: 'Add a literal',
640+
});
641+
expect(input).toBeInTheDocument();
642+
643+
await userEvent.click(input);
644+
expect(input).toHaveFocus();
645+
expect(input).toHaveValue('1');
646+
await userEvent.type(input, '0 ');
647+
648+
await waitFor(() => expect(getLastInput()).toHaveFocus());
649+
650+
await userEvent.type(getLastInput(), '{Escape}');
651+
expect(await screen.findByRole('row', {name: '10'})).toBeInTheDocument();
652+
});
653+
654+
it('completes literal with escape', async function () {
655+
const dispatch = jest.fn();
656+
render(<Tokens expression="1" dispatch={dispatch} />);
657+
658+
expect(await screen.findByRole('row', {name: '1'})).toBeInTheDocument();
659+
660+
const input = screen.getByRole('textbox', {
661+
name: 'Add a literal',
662+
});
663+
expect(input).toBeInTheDocument();
664+
665+
await userEvent.click(input);
666+
expect(input).toHaveFocus();
667+
expect(input).toHaveValue('1');
668+
await userEvent.type(input, '0{escape}');
669+
670+
expect(await screen.findByRole('row', {name: '10'})).toBeInTheDocument();
671+
});
672+
673+
it.each([
674+
['+', 'icon-add'],
675+
['-', 'icon-subtract'],
676+
['*', 'icon-multiply'],
677+
['/', 'icon-divide'],
678+
['(', 'icon-parenthesis'],
679+
[')', 'icon-parenthesis'],
680+
])('completes literal with token %s', async function (token, dataTestId) {
681+
const dispatch = jest.fn();
682+
render(<Tokens expression="1" dispatch={dispatch} />);
683+
684+
expect(await screen.findByRole('row', {name: '1'})).toBeInTheDocument();
685+
686+
const input = screen.getByRole('textbox', {
687+
name: 'Add a literal',
688+
});
689+
expect(input).toBeInTheDocument();
690+
691+
await userEvent.click(input);
692+
expect(input).toHaveFocus();
693+
expect(input).toHaveValue('1');
694+
await userEvent.type(input, '0');
695+
await userEvent.type(input, token);
696+
697+
await waitFor(() => expect(getLastInput()).toHaveFocus());
698+
await userEvent.type(getLastInput(), '{Escape}');
699+
700+
expect(await screen.findByRole('row', {name: '10'})).toBeInTheDocument();
701+
expect(screen.getByTestId(dataTestId)).toBeInTheDocument();
702+
});
703+
});
704+
616705
describe('ArithmeticTokenOperator', function () {
617706
it('renders addition operator', async function () {
618707
const dispatch = jest.fn();

static/app/components/arithmeticBuilder/token/index.tsx

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type {LocationRange} from 'peggy';
1+
import type {Location, LocationRange} from 'peggy';
22

33
import {defined} from 'sentry/utils';
44

@@ -10,6 +10,7 @@ export enum TokenKind {
1010
FREE_TEXT = 'str',
1111
ATTRIBUTE = 'attr',
1212
FUNCTION = 'func',
13+
LITERAL = 'lit',
1314
}
1415

1516
export abstract class Token {
@@ -114,6 +115,53 @@ export class TokenFreeText extends Token {
114115
}
115116
}
116117

118+
export class TokenLiteral extends Token {
119+
kind: TokenKind = TokenKind.LITERAL;
120+
121+
value: number;
122+
123+
constructor(location: LocationRange, text: string) {
124+
super(location, text);
125+
const value = +text;
126+
if (isNaN(value)) {
127+
throw new Error(`Unable to initialize TokenLiteral with ${text}`);
128+
}
129+
this.value = value;
130+
}
131+
132+
get sign(): '+' | '-' | null {
133+
if (this.text.startsWith('-')) {
134+
return '-';
135+
}
136+
if (this.text.startsWith('+')) {
137+
return '+';
138+
}
139+
return null;
140+
}
141+
142+
split(): [TokenOperator, TokenLiteral] {
143+
const sign = this.sign;
144+
if (!defined(sign)) {
145+
throw new Error('Literal does not contain a sign to be split.');
146+
}
147+
148+
const pos: Location = {
149+
offset: this.location.start.offset + 1,
150+
line: this.location.start.line,
151+
column: this.location.start.column + 1,
152+
};
153+
const op = new TokenOperator(
154+
{source: undefined, start: this.location.start, end: pos},
155+
sign === '-' ? Operator.MINUS : Operator.PLUS
156+
);
157+
const lit = new TokenLiteral(
158+
{source: undefined, start: pos, end: this.location.end},
159+
this.text.substring(1)
160+
);
161+
return [op, lit];
162+
}
163+
}
164+
117165
export function isTokenParenthesis(
118166
token: Token | null | undefined
119167
): token is TokenParenthesis {
@@ -134,3 +182,7 @@ export function isTokenFreeText(token: Token | null | undefined): token is Token
134182
export function isTokenFunction(token: Token | null | undefined): token is TokenFunction {
135183
return token?.kind === TokenKind.FUNCTION;
136184
}
185+
186+
export function isTokenLiteral(token: Token | null | undefined): token is TokenLiteral {
187+
return token?.kind === TokenKind.LITERAL;
188+
}

0 commit comments

Comments
 (0)