123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513 |
- import {Component, createRef, Fragment} from 'react';
- import styled from '@emotion/styled';
- import TextField from 'sentry/components/forms/fields/textField';
- import TextOverflow from 'sentry/components/textOverflow';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import {defined} from 'sentry/utils';
- import type {SourceSuggestion} from '../../types';
- import {SourceSuggestionType} from '../../types';
- import {binarySuggestions, unarySuggestions} from '../../utils';
- import SourceSuggestionExamples from './sourceSuggestionExamples';
- const defaultHelp = t(
- 'Where to look. In the simplest case this can be an attribute name.'
- );
- type Props = {
- isRegExMatchesSelected: boolean;
- onChange: (value: string) => void;
- suggestions: Array<SourceSuggestion>;
- value: string;
- error?: string;
- onBlur?: (value: string, event: React.FocusEvent<HTMLInputElement>) => void;
- };
- type State = {
- activeSuggestion: number;
- fieldValues: Array<SourceSuggestion | Array<SourceSuggestion>>;
- help: string;
- hideCaret: boolean;
- showSuggestions: boolean;
- suggestions: Array<SourceSuggestion>;
- };
- class SourceField extends Component<Props, State> {
- state: State = {
- suggestions: [],
- fieldValues: [],
- activeSuggestion: 0,
- showSuggestions: false,
- hideCaret: false,
- help: defaultHelp,
- };
- componentDidMount() {
- this.loadFieldValues(this.props.value);
- this.toggleSuggestions(false);
- }
- componentDidUpdate(prevProps: Props) {
- if (prevProps.suggestions !== this.props.suggestions) {
- this.loadFieldValues(this.props.value);
- this.toggleSuggestions(false);
- }
- if (
- prevProps.isRegExMatchesSelected !== this.props.isRegExMatchesSelected ||
- prevProps.value !== this.props.value
- ) {
- this.checkPossiblyRegExMatchExpression(this.props.value);
- }
- }
- selectorField = createRef<HTMLDivElement>();
- suggestionList = createRef<HTMLUListElement>();
- getAllSuggestions() {
- return [...this.getValueSuggestions(), ...unarySuggestions, ...binarySuggestions];
- }
- getValueSuggestions() {
- return this.props.suggestions || [];
- }
- getFilteredSuggestions(value: string, type: SourceSuggestionType) {
- let valuesToBeFiltered: Array<SourceSuggestion> = [];
- switch (type) {
- case SourceSuggestionType.BINARY: {
- valuesToBeFiltered = binarySuggestions;
- break;
- }
- case SourceSuggestionType.VALUE: {
- valuesToBeFiltered = this.getValueSuggestions();
- break;
- }
- case SourceSuggestionType.UNARY: {
- valuesToBeFiltered = unarySuggestions;
- break;
- }
- default: {
- valuesToBeFiltered = [...this.getValueSuggestions(), ...unarySuggestions];
- }
- }
- const filteredSuggestions = valuesToBeFiltered.filter(s =>
- s.value.toLowerCase().includes(value.toLowerCase())
- );
- return filteredSuggestions;
- }
- getNewSuggestions(fieldValues: Array<SourceSuggestion | Array<SourceSuggestion>>) {
- const lastFieldValue = fieldValues[fieldValues.length - 1];
- const penultimateFieldValue = fieldValues[fieldValues.length - 2];
- if (Array.isArray(lastFieldValue)) {
- // recursion
- return this.getNewSuggestions(lastFieldValue);
- }
- if (Array.isArray(penultimateFieldValue)) {
- if (lastFieldValue?.type === 'binary') {
- // returns filtered values
- return this.getFilteredSuggestions(
- lastFieldValue?.value,
- SourceSuggestionType.VALUE
- );
- }
- // returns all binaries without any filter
- return this.getFilteredSuggestions('', SourceSuggestionType.BINARY);
- }
- if (lastFieldValue?.type === 'value' && penultimateFieldValue?.type === 'unary') {
- // returns filtered values
- return this.getFilteredSuggestions(
- lastFieldValue?.value,
- SourceSuggestionType.VALUE
- );
- }
- if (lastFieldValue?.type === 'unary') {
- // returns all values without any filter
- return this.getFilteredSuggestions('', SourceSuggestionType.VALUE);
- }
- if (lastFieldValue?.type === 'string' && penultimateFieldValue?.type === 'value') {
- // returns all binaries without any filter
- return this.getFilteredSuggestions('', SourceSuggestionType.BINARY);
- }
- if (
- lastFieldValue?.type === 'string' &&
- penultimateFieldValue?.type === 'string' &&
- !penultimateFieldValue?.value
- ) {
- // returns all values without any filter
- return this.getFilteredSuggestions('', SourceSuggestionType.STRING);
- }
- if (
- (penultimateFieldValue?.type === 'string' && !lastFieldValue?.value) ||
- (penultimateFieldValue?.type === 'value' && !lastFieldValue?.value) ||
- lastFieldValue?.type === 'binary'
- ) {
- // returns filtered binaries
- return this.getFilteredSuggestions(
- lastFieldValue?.value,
- SourceSuggestionType.BINARY
- );
- }
- return this.getFilteredSuggestions(lastFieldValue?.value, lastFieldValue?.type);
- }
- loadFieldValues(newValue: string) {
- const fieldValues: Array<SourceSuggestion | Array<SourceSuggestion>> = [];
- const splittedValue = newValue.split(' ');
- for (const splittedValueIndex in splittedValue) {
- const value = splittedValue[splittedValueIndex];
- const lastFieldValue = fieldValues[fieldValues.length - 1];
- if (
- lastFieldValue &&
- !Array.isArray(lastFieldValue) &&
- !lastFieldValue.value &&
- !value
- ) {
- continue;
- }
- if (value.includes('!') && !!value.split('!')[1]) {
- const valueAfterUnaryOperator = value.split('!')[1];
- const selector = this.getAllSuggestions().find(
- s => s.value === valueAfterUnaryOperator
- );
- if (!selector) {
- fieldValues.push([
- unarySuggestions[0],
- {type: SourceSuggestionType.STRING, value: valueAfterUnaryOperator},
- ]);
- continue;
- }
- fieldValues.push([unarySuggestions[0], selector]);
- continue;
- }
- const selector = this.getAllSuggestions().find(s => s.value === value);
- if (selector) {
- fieldValues.push(selector);
- continue;
- }
- fieldValues.push({type: SourceSuggestionType.STRING, value});
- }
- const filteredSuggestions = this.getNewSuggestions(fieldValues);
- this.setState({
- fieldValues,
- activeSuggestion: 0,
- suggestions: filteredSuggestions,
- });
- }
- scrollToSuggestion() {
- const {activeSuggestion, hideCaret} = this.state;
- this.suggestionList?.current?.children[activeSuggestion].scrollIntoView({
- behavior: 'smooth',
- block: 'nearest',
- inline: 'start',
- });
- if (!hideCaret) {
- this.setState({
- hideCaret: true,
- });
- }
- }
- changeParentValue() {
- const {onChange} = this.props;
- const {fieldValues} = this.state;
- const newValue: Array<string> = [];
- for (const index in fieldValues) {
- const fieldValue = fieldValues[index];
- if (Array.isArray(fieldValue)) {
- if (fieldValue[0]?.value || fieldValue[1]?.value) {
- newValue.push(`${fieldValue[0]?.value ?? ''}${fieldValue[1]?.value ?? ''}`);
- }
- continue;
- }
- newValue.push(fieldValue.value);
- }
- onChange(newValue.join(' '));
- }
- getNewFieldValues(
- suggestion: SourceSuggestion
- ): Array<SourceSuggestion | Array<SourceSuggestion>> {
- const fieldValues = [...this.state.fieldValues];
- const lastFieldValue = fieldValues[fieldValues.length - 1];
- if (!defined(lastFieldValue)) {
- return [suggestion];
- }
- if (Array.isArray(lastFieldValue)) {
- fieldValues[fieldValues.length - 1] = [lastFieldValue[0], suggestion];
- return fieldValues;
- }
- if (lastFieldValue?.type === 'unary') {
- fieldValues[fieldValues.length - 1] = [lastFieldValue, suggestion];
- }
- if (lastFieldValue?.type === 'string' && !lastFieldValue?.value) {
- fieldValues[fieldValues.length - 1] = suggestion;
- return fieldValues;
- }
- if (suggestion.type === 'value' && lastFieldValue?.value !== suggestion.value) {
- return [suggestion];
- }
- return fieldValues;
- }
- checkPossiblyRegExMatchExpression(value: string) {
- const {isRegExMatchesSelected} = this.props;
- const {help} = this.state;
- if (isRegExMatchesSelected) {
- if (help) {
- this.setState({help: ''});
- }
- return;
- }
- const isMaybeRegExp = RegExp('^/.*/g?$').test(value);
- if (help) {
- if (!isMaybeRegExp) {
- this.setState({
- help: defaultHelp,
- });
- }
- return;
- }
- if (isMaybeRegExp) {
- this.setState({
- help: t("You might want to change Data Type's value to 'Regex matches'"),
- });
- }
- }
- toggleSuggestions(showSuggestions: boolean) {
- this.setState({showSuggestions});
- }
- handleChange = (value: string) => {
- this.loadFieldValues(value);
- this.props.onChange(value);
- };
- handleClickOutside = () => {
- this.setState({
- showSuggestions: false,
- hideCaret: false,
- });
- };
- handleClickSuggestionItem = (suggestion: SourceSuggestion) => {
- const fieldValues = this.getNewFieldValues(suggestion);
- this.setState(
- {
- fieldValues,
- activeSuggestion: 0,
- showSuggestions: false,
- hideCaret: false,
- },
- this.changeParentValue
- );
- };
- handleKeyDown = (_value: string, event: React.KeyboardEvent<HTMLInputElement>) => {
- event.persist();
- const {key} = event;
- const {activeSuggestion, suggestions} = this.state;
- if (key === 'Backspace' || key === ' ') {
- this.toggleSuggestions(true);
- return;
- }
- if (key === 'Enter') {
- this.handleClickSuggestionItem(suggestions[activeSuggestion]);
- return;
- }
- if (key === 'ArrowUp') {
- if (activeSuggestion === 0) {
- return;
- }
- this.setState({activeSuggestion: activeSuggestion - 1}, () => {
- this.scrollToSuggestion();
- });
- return;
- }
- if (key === 'ArrowDown') {
- if (activeSuggestion === suggestions.length - 1) {
- return;
- }
- this.setState({activeSuggestion: activeSuggestion + 1}, () => {
- this.scrollToSuggestion();
- });
- return;
- }
- };
- handleFocus = () => {
- this.toggleSuggestions(true);
- };
- render() {
- const {error, value, onBlur} = this.props;
- const {showSuggestions, suggestions, activeSuggestion, hideCaret, help} = this.state;
- return (
- <Wrapper ref={this.selectorField} hideCaret={hideCaret}>
- <StyledTextField
- data-test-id="source-field"
- label={t('Source')}
- name="source"
- placeholder={t('Enter a custom attribute, variable or header name')}
- onChange={this.handleChange}
- autoComplete="off"
- value={value}
- error={error}
- help={help}
- onKeyDown={this.handleKeyDown}
- onBlur={onBlur}
- onFocus={this.handleFocus}
- inline={false}
- flexibleControlStateSize
- stacked
- required
- showHelpInTooltip
- />
- {showSuggestions && suggestions.length > 0 && (
- <Fragment>
- <Suggestions
- ref={this.suggestionList}
- error={error}
- data-test-id="source-suggestions"
- >
- {suggestions.slice(0, 50).map((suggestion, index) => (
- <Suggestion
- key={suggestion.value}
- onClick={event => {
- event.preventDefault();
- this.handleClickSuggestionItem(suggestion);
- }}
- active={index === activeSuggestion}
- tabIndex={-1}
- >
- <TextOverflow>{suggestion.value}</TextOverflow>
- {suggestion.description && (
- <SuggestionDescription>
- (<TextOverflow>{suggestion.description}</TextOverflow>)
- </SuggestionDescription>
- )}
- {suggestion.examples && suggestion.examples.length > 0 && (
- <SourceSuggestionExamples
- examples={suggestion.examples}
- sourceName={suggestion.value}
- />
- )}
- </Suggestion>
- ))}
- </Suggestions>
- <SuggestionsOverlay onClick={this.handleClickOutside} />
- </Fragment>
- )}
- </Wrapper>
- );
- }
- }
- export default SourceField;
- const Wrapper = styled('div')<{hideCaret?: boolean}>`
- position: relative;
- width: 100%;
- ${p => p.hideCaret && `caret-color: transparent;`}
- `;
- const StyledTextField = styled(TextField)`
- z-index: 1002;
- :focus {
- outline: none;
- }
- `;
- const Suggestions = styled('ul')<{error?: string}>`
- position: absolute;
- width: ${p => (p.error ? 'calc(100% - 34px)' : '100%')};
- padding-left: 0;
- list-style: none;
- margin-bottom: 0;
- box-shadow: 0 2px 0 rgba(37, 11, 54, 0.04);
- border: 1px solid ${p => p.theme.border};
- border-radius: 0 0 ${space(0.5)} ${space(0.5)};
- background: ${p => p.theme.background};
- top: 63px;
- left: 0;
- z-index: 1002;
- overflow: hidden;
- max-height: 200px;
- overflow-y: auto;
- `;
- const Suggestion = styled('li')<{active: boolean}>`
- display: grid;
- grid-template-columns: auto 1fr max-content;
- gap: ${space(1)};
- border-bottom: 1px solid ${p => p.theme.border};
- padding: ${space(1)} ${space(2)};
- font-size: ${p => p.theme.fontSizeMedium};
- cursor: pointer;
- background: ${p => (p.active ? p.theme.backgroundSecondary : p.theme.background)};
- :hover {
- background: ${p =>
- p.active ? p.theme.backgroundSecondary : p.theme.backgroundSecondary};
- }
- `;
- const SuggestionDescription = styled('div')`
- display: flex;
- overflow: hidden;
- color: ${p => p.theme.gray300};
- line-height: 1.2;
- `;
- const SuggestionsOverlay = styled('div')`
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 1001;
- `;
|