import {createRef, Fragment, PureComponent} from 'react'; import styled from '@emotion/styled'; import isEqual from 'lodash/isEqual'; import type {InputProps} from 'sentry/components/input'; import Input from 'sentry/components/input'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Column} from 'sentry/utils/discover/fields'; import {generateFieldAsString, isLegalEquationColumn} from 'sentry/utils/discover/fields'; const NONE_SELECTED = -1; type DropdownOption = { active: boolean; kind: 'field' | 'operator'; value: string; }; type DropdownOptionGroup = { options: DropdownOption[]; title: string; }; type DefaultProps = { options: Column[]; }; type Props = DefaultProps & InputProps & { onUpdate: (value: string) => void; value: string; hideFieldOptions?: boolean; }; type State = { activeSelection: number; dropdownOptionGroups: DropdownOptionGroup[]; dropdownVisible: boolean; partialTerm: string | null; query: string; rawOptions: Column[]; }; export default class ArithmeticInput extends PureComponent { static defaultProps: DefaultProps = { options: [], }; static getDerivedStateFromProps(props: Readonly, state: State): State { const changed = !isEqual(state.rawOptions, props.options); if (changed) { return { ...state, rawOptions: props.options, dropdownOptionGroups: makeOptions( props.options, state.partialTerm, props.hideFieldOptions ), activeSelection: NONE_SELECTED, }; } return {...state}; } state: State = { query: this.props.value, partialTerm: null, rawOptions: this.props.options, dropdownVisible: false, dropdownOptionGroups: makeOptions( this.props.options, null, this.props.hideFieldOptions ), activeSelection: NONE_SELECTED, }; input = createRef(); blur = () => { this.input.current?.blur(); }; focus = (position: number) => { this.input.current?.focus(); this.input.current?.setSelectionRange(position, position); }; getCursorPosition(): number { return this.input.current?.selectionStart ?? -1; } splitQuery() { const {query} = this.state; const currentPosition = this.getCursorPosition(); // The current term is delimited by whitespaces. So if no spaces are found, // the entire string is taken to be 1 term. // // TODO: add support for when there are no spaces const matches = [...query.substring(0, currentPosition).matchAll(/\s|^/g)]; const match = matches[matches.length - 1]; const startOfTerm = match[0] === '' ? 0 : (match.index || 0) + 1; const cursorOffset = query.slice(currentPosition).search(/\s|$/); const endOfTerm = currentPosition + (cursorOffset === -1 ? 0 : cursorOffset); return { startOfTerm, endOfTerm, prefix: query.substring(0, startOfTerm), term: query.substring(startOfTerm, endOfTerm), suffix: query.substring(endOfTerm), }; } handleChange = (event: React.ChangeEvent) => { const query = event.target.value.replace('\n', ''); this.setState({query}, this.updateAutocompleteOptions); }; handleClick = () => { this.updateAutocompleteOptions(); }; handleFocus = () => { this.setState({dropdownVisible: true}); }; handleBlur = () => { this.props.onUpdate(this.state.query); this.setState({dropdownVisible: false}); }; getSelection(selection: number): DropdownOption | null { const {dropdownOptionGroups} = this.state; for (const group of dropdownOptionGroups) { if (selection >= group.options.length) { selection -= group.options.length; continue; } return group.options[selection]; } return null; } handleKeyDown = (event: React.KeyboardEvent) => { const {key} = event; const {options, hideFieldOptions} = this.props; const {activeSelection, partialTerm} = this.state; const startedSelection = activeSelection >= 0; // handle arrow navigation if (key === 'ArrowDown' || key === 'ArrowUp') { event.preventDefault(); const newOptionGroups = makeOptions(options, partialTerm, hideFieldOptions); const flattenedOptions = newOptionGroups.map(group => group.options).flat(); if (flattenedOptions.length === 0) { return; } let newSelection; if (!startedSelection) { newSelection = key === 'ArrowUp' ? flattenedOptions.length - 1 : 0; } else { newSelection = key === 'ArrowUp' ? (activeSelection - 1 + flattenedOptions.length) % flattenedOptions.length : (activeSelection + 1) % flattenedOptions.length; } // This is modifying the `active` value of the references so make sure to // use `newOptionGroups` at the end. flattenedOptions[newSelection].active = true; this.setState({ activeSelection: newSelection, dropdownOptionGroups: newOptionGroups, }); return; } // handle selection if (startedSelection && (key === 'Tab' || key === 'Enter')) { event.preventDefault(); const selection = this.getSelection(activeSelection); if (selection) { this.handleSelect(selection); } return; } if (key === 'Enter') { this.blur(); return; } if (key === 'ArrowLeft' || key === 'ArrowRight') { this.updateAutocompleteOptions(); } }; handleKeyUp = (event: React.KeyboardEvent) => { // Other keys are managed at handleKeyDown function if (event.key !== 'Escape') { return; } event.preventDefault(); const {activeSelection} = this.state; const startedSelection = activeSelection >= 0; if (!startedSelection) { this.blur(); return; } }; handleSelect = (option: DropdownOption) => { const {prefix, suffix} = this.splitQuery(); this.setState( { // make sure to insert a space after the autocompleted term query: `${prefix}${option.value} ${suffix}`, activeSelection: NONE_SELECTED, }, () => { // updating the query will cause the input to lose focus // and make sure to move the cursor behind the space after // the end of the autocompleted term this.focus(prefix.length + option.value.length + 1); this.updateAutocompleteOptions(); } ); }; updateAutocompleteOptions() { const {options, hideFieldOptions} = this.props; const {term} = this.splitQuery(); const partialTerm = term || null; this.setState({ dropdownOptionGroups: makeOptions(options, partialTerm, hideFieldOptions), partialTerm, }); } render() { const {onUpdate: _onUpdate, options: _options, ...props} = this.props; const {dropdownVisible, dropdownOptionGroups} = this.state; return ( ); } } const Container = styled('div')<{isOpen: boolean}>` background: ${p => p.theme.background}; position: relative; border-radius: ${p => p.isOpen ? `${p.theme.borderRadius} ${p.theme.borderRadius} 0 0` : p.theme.borderRadius}; .show-sidebar & { background: ${p => p.theme.backgroundSecondary}; } `; type TermDropdownProps = { handleSelect: (option: DropdownOption) => void; isOpen: boolean; optionGroups: DropdownOptionGroup[]; }; function TermDropdown({isOpen, optionGroups, handleSelect}: TermDropdownProps) { return ( {isOpen && ( {optionGroups.map(group => { const {title, options} = group; return ( {title} {options.map(option => { return ( handleSelect(option)} // prevent the blur event on the input from firing onMouseDown={event => event.preventDefault()} // scroll into view if it is the active element ref={element => option.active && element?.scrollIntoView?.({block: 'nearest'}) } aria-label={option.value} > {option.value} ); })} {options.length === 0 && {t('No items found')}} ); })} )} ); } function makeFieldOptions( columns: Column[], partialTerm: string | null ): DropdownOptionGroup { const fieldValues = new Set(); const options = columns .filter(({kind}) => kind !== 'equation') .filter(isLegalEquationColumn) .map(option => ({ kind: 'field' as const, active: false, value: generateFieldAsString(option), })) .filter(({value}) => { if (fieldValues.has(value)) { return false; } fieldValues.add(value); return true; }) .filter(({value}) => (partialTerm ? value.includes(partialTerm) : true)); return { title: 'Fields', options, }; } function makeOperatorOptions(partialTerm: string | null): DropdownOptionGroup { const options = ['+', '-', '*', '/', '(', ')'] .filter(operator => (partialTerm ? operator.includes(partialTerm) : true)) .map(operator => ({ kind: 'operator' as const, active: false, value: operator, })); return { title: 'Operators', options, }; } function makeOptions( columns: Column[], partialTerm: string | null, hideFieldOptions?: boolean ): DropdownOptionGroup[] { if (hideFieldOptions) { return [makeOperatorOptions(partialTerm)]; } return [makeFieldOptions(columns, partialTerm), makeOperatorOptions(partialTerm)]; } const DropdownContainer = styled('div')<{isOpen: boolean}>` /* Container has a border that we need to account for */ display: ${p => (p.isOpen ? 'block' : 'none')}; position: absolute; top: 100%; left: -1px; right: -1px; z-index: ${p => p.theme.zIndex.dropdown}; background: ${p => p.theme.backgroundElevated}; box-shadow: ${p => p.theme.dropShadowHeavy}; border: 1px solid ${p => p.theme.border}; border-radius: ${p => p.theme.borderRadius}; margin-top: ${space(1)}; max-height: 300px; overflow-y: auto; `; const DropdownItemsList = styled('ul')` padding-left: 0; list-style: none; margin-bottom: 0; `; const ListItem = styled('li')` &:not(:last-child) { border-bottom: 1px solid ${p => p.theme.innerBorder}; } `; const DropdownTitle = styled('header')` display: flex; align-items: center; background-color: ${p => p.theme.backgroundSecondary}; color: ${p => p.theme.gray300}; font-weight: normal; font-size: ${p => p.theme.fontSizeMedium}; margin: 0; padding: ${space(1)} ${space(2)}; & > svg { margin-right: ${space(1)}; } `; const DropdownListItem = styled(ListItem)` scroll-margin: 40px 0; font-size: ${p => p.theme.fontSizeLarge}; padding: ${space(1)} ${space(2)}; cursor: pointer; &:hover, &.active { background: ${p => p.theme.hover}; } `; const DropdownItemTitleWrapper = styled('div')` color: ${p => p.theme.textColor}; font-weight: normal; font-size: ${p => p.theme.fontSizeMedium}; margin: 0; line-height: ${p => p.theme.text.lineHeightHeading}; ${p => p.theme.overflowEllipsis}; `; const Info = styled('div')` display: flex; padding: ${space(1)} ${space(2)}; font-size: ${p => p.theme.fontSizeLarge}; color: ${p => p.theme.gray300}; &:not(:last-child) { border-bottom: 1px solid ${p => p.theme.innerBorder}; } `;