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: ')'; } `;