import {Fragment, useEffect, useRef, useState} from 'react';
import {css, keyframes} from '@emotion/react';
import styled from '@emotion/styled';
import {useReducedMotion} from 'framer-motion';
import Tooltip from 'sentry/components/tooltip';
import space from 'sentry/styles/space';
import {ParseResult, Token, TokenResult} from './parser';
import {isWithinToken} from './utils';
type Props = {
/**
* The result from parsing the search query string
*/
parsedQuery: ParseResult;
/**
* The current location of the cursor within the query. This is used to
* highlight active tokens and trigger error tooltips.
*/
cursorPosition?: number;
};
/**
* Renders the parsed query with syntax highlighting.
*/
export default function HighlightQuery({parsedQuery, cursorPosition}: Props) {
const result = renderResult(parsedQuery, cursorPosition ?? -1);
return {result};
}
function renderResult(result: ParseResult, cursor: number) {
return result
.map(t => renderToken(t, cursor))
.map((renderedToken, i) => {renderedToken});
}
function renderToken(token: TokenResult, cursor: number) {
switch (token.type) {
case Token.Spaces:
return token.value;
case Token.Filter:
return ;
case Token.ValueTextList:
case Token.ValueNumberList:
return ;
case Token.ValueNumber:
return ;
case Token.ValueBoolean:
return {token.text};
case Token.ValueIso8601Date:
return {token.text};
case Token.LogicGroup:
return {renderResult(token.inner, cursor)};
case Token.LogicBoolean:
return {token.value};
default:
return token.text;
}
}
// XXX(epurkhiser): We have to animate `left` here instead of `transform` since
// inline elements cannot be transformed. The filter _must_ be inline to
// support text wrapping.
const shakeAnimation = keyframes`
${new Array(4)
.fill(0)
.map((_, i) => `${i * (100 / 4)}% { left: ${3 * (i % 2 === 0 ? 1 : -1)}px; }`)
.join('\n')}
`;
const FilterToken = ({
filter,
cursor,
}: {
cursor: number;
filter: TokenResult;
}) => {
const isActive = isWithinToken(filter, cursor);
// This state tracks if the cursor has left the filter token. We initialize it
// to !isActive in the case where the filter token is rendered without the
// cursor initially being in it.
const [hasLeft, setHasLeft] = useState(!isActive);
// Used to trigger the shake animation when the element becomes invalid
const filterElementRef = useRef(null);
// Trigger the effect when isActive changes to updated whether the cursor has
// left the token.
useEffect(() => {
if (!isActive && !hasLeft) {
setHasLeft(true);
}
}, [hasLeft, isActive]);
const showInvalid = hasLeft && !!filter.invalid;
const showTooltip = showInvalid && isActive;
const reduceMotion = useReducedMotion();
// Trigger the shakeAnimation when showInvalid is set to true. We reset the
// animation by clearing the style, set it to running, and re-applying the
// animation
useEffect(() => {
if (!filterElementRef.current || !showInvalid || reduceMotion) {
return;
}
const style = filterElementRef.current.style;
style.animation = 'none';
void filterElementRef.current.offsetTop;
window.requestAnimationFrame(
() => (style.animation = `${shakeAnimation.name} 300ms`)
);
}, [reduceMotion, showInvalid]);
return (
{filter.negated && !}
{filter.operator && {filter.operator}}
{renderToken(filter.value, cursor)}
);
};
const KeyToken = ({
token,
negated,
}: {
token: TokenResult;
negated?: boolean;
}) => {
let value: React.ReactNode = token.text;
if (token.type === Token.KeyExplicitTag) {
value = (
{token.key.quoted ? `"${token.key.value}"` : token.key.value}
);
}
return {value}:;
};
const ListToken = ({
token,
cursor,
}: {
cursor: number;
token: TokenResult;
}) => (
{token.items.map(({value, separator}) => [
{separator},
value && renderToken(value, cursor),
])}
);
const NumberToken = ({token}: {token: TokenResult}) => (
{token.value}
{token.unit}
);
type FilterProps = {
active: boolean;
invalid: boolean;
};
const colorType = (p: FilterProps) =>
`${p.invalid ? 'invalid' : 'valid'}${p.active ? 'Active' : ''}` as const;
const Filter = styled('span')`
--token-bg: ${p => p.theme.searchTokenBackground[colorType(p)]};
--token-border: ${p => p.theme.searchTokenBorder[colorType(p)]};
--token-value-color: ${p => (p.invalid ? p.theme.red300 : p.theme.blue300)};
position: relative;
animation-name: ${shakeAnimation};
`;
const filterCss = css`
background: var(--token-bg);
border: 0.5px solid var(--token-border);
padding: ${space(0.25)} 0;
`;
const Negation = styled('span')`
${filterCss};
border-right: none;
padding-left: 1px;
margin-left: -2px;
font-weight: bold;
border-radius: 2px 0 0 2px;
color: ${p => p.theme.red300};
`;
const Key = styled('span')<{negated: boolean}>`
${filterCss};
border-right: none;
font-weight: bold;
${p =>
!p.negated
? css`
border-radius: 2px 0 0 2px;
padding-left: 1px;
margin-left: -2px;
`
: css`
border-left: none;
margin-left: 0;
`};
`;
const ExplicitKey = styled('span')<{prefix: string}>`
&:before,
&:after {
color: ${p => p.theme.subText};
}
&:before {
content: '${p => p.prefix}[';
}
&:after {
content: ']';
}
`;
const Operator = styled('span')`
${filterCss};
border-left: none;
border-right: none;
margin: -1px 0;
color: ${p => p.theme.pink300};
`;
const Value = styled('span')`
${filterCss};
border-left: none;
border-radius: 0 2px 2px 0;
color: var(--token-value-color);
margin: -1px -2px -1px 0;
padding-right: 1px;
`;
const Unit = styled('span')`
font-weight: bold;
color: ${p => p.theme.green300};
`;
const LogicBoolean = styled('span')`
font-weight: bold;
color: ${p => p.theme.gray300};
`;
const Boolean = styled('span')`
color: ${p => p.theme.pink300};
`;
const DateTime = styled('span')`
color: ${p => p.theme.green300};
`;
const ListComma = styled('span')`
color: ${p => p.theme.gray300};
`;
const InList = styled('span')`
&:before {
content: '[';
font-weight: bold;
color: ${p => p.theme.purple300};
}
&:after {
content: ']';
font-weight: bold;
color: ${p => p.theme.purple300};
}
${Value} {
color: ${p => p.theme.purple300};
}
`;
const LogicGroup = styled(({children, ...props}) => (
(
{children}
)
))`
> span:first-child,
> span:last-child {
position: relative;
color: transparent;
&:before {
position: absolute;
top: -5px;
color: ${p => p.theme.pink300};
font-size: 16px;
font-weight: bold;
}
}
> span:first-child:before {
left: -3px;
content: '(';
}
> span:last-child:before {
right: -3px;
content: ')';
}
`;