mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
482 lines
12 KiB
TypeScript
482 lines
12 KiB
TypeScript
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<Props, State> {
|
|
static defaultProps: DefaultProps = {
|
|
options: [],
|
|
};
|
|
|
|
static getDerivedStateFromProps(props: Readonly<Props>, 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<HTMLInputElement>();
|
|
|
|
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<HTMLInputElement>) => {
|
|
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<HTMLInputElement>) => {
|
|
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.flatMap(group => group.options);
|
|
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<HTMLInputElement>) => {
|
|
// 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 (
|
|
<Container isOpen={dropdownVisible}>
|
|
<Input
|
|
{...props}
|
|
ref={this.input}
|
|
autoComplete="off"
|
|
className="form-control"
|
|
value={this.state.query}
|
|
onClick={this.handleClick}
|
|
onChange={this.handleChange}
|
|
onBlur={this.handleBlur}
|
|
onFocus={this.handleFocus}
|
|
onKeyDown={this.handleKeyDown}
|
|
spellCheck={false}
|
|
/>
|
|
<TermDropdown
|
|
isOpen={dropdownVisible}
|
|
optionGroups={dropdownOptionGroups}
|
|
handleSelect={this.handleSelect}
|
|
/>
|
|
</Container>
|
|
);
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<DropdownContainer isOpen={isOpen}>
|
|
{isOpen && (
|
|
<DropdownItemsList>
|
|
{optionGroups.map(group => {
|
|
const {title, options} = group;
|
|
return (
|
|
<Fragment key={title}>
|
|
<ListItem>
|
|
<DropdownTitle>{title}</DropdownTitle>
|
|
</ListItem>
|
|
{options.map(option => {
|
|
return (
|
|
<DropdownListItem
|
|
key={option.value}
|
|
className={option.active ? 'active' : undefined}
|
|
onClick={() => 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}
|
|
>
|
|
<DropdownItemTitleWrapper>{option.value}</DropdownItemTitleWrapper>
|
|
</DropdownListItem>
|
|
);
|
|
})}
|
|
{options.length === 0 && <Info>{t('No items found')}</Info>}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</DropdownItemsList>
|
|
)}
|
|
</DropdownContainer>
|
|
);
|
|
}
|
|
|
|
function makeFieldOptions(
|
|
columns: Column[],
|
|
partialTerm: string | null
|
|
): DropdownOptionGroup {
|
|
const fieldValues = new Set<string>();
|
|
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};
|
|
}
|
|
`;
|