import classNames from 'classnames'; import React from 'react'; import type { Filter } from './Filter'; import type { IFiltersLayout } from './Filters'; import { Filters } from './Filters'; import type { IFilterType } from './SearchFilterTypeRegistry'; import { SearchFilterTypeRegistry } from './SearchFilterTypeRegistry'; import { FirewallLabels } from '../../securityGroup'; import type { ITag } from '../../widgets'; import { Key, TagList } from '../../widgets'; import './search.less'; export interface ISearchProps { params: { [key: string]: any }; onChange: (tags: ITag[]) => void; } export interface ISearchState { activeFilter: IFilterType; isFocused: boolean; isOpen: boolean; layouts: IFiltersLayout[]; tags: ITag[]; } export class Search extends React.Component { private filterTypes: IFilterType[] = []; private keys: Set; private tagElements: HTMLElement[] = []; private inputElement: HTMLInputElement; private mouseDownFired = false; constructor(props: ISearchProps) { super(props); const layouts: IFiltersLayout[] = []; layouts.push({ header: 'SEARCH', filterTypes: [SearchFilterTypeRegistry.KEYWORD_FILTER], }); this.filterTypes = this.reorderFilterTypesForSearch(SearchFilterTypeRegistry.getValues()); layouts.push({ header: 'FILTER ON', filterTypes: this.filterTypes.filter((type) => type.key !== SearchFilterTypeRegistry.KEYWORD_FILTER.key), }); this.keys = new Set(SearchFilterTypeRegistry.getValues().map((type: IFilterType) => type.key)); this.state = { activeFilter: SearchFilterTypeRegistry.KEYWORD_FILTER, isFocused: true, isOpen: false, layouts, tags: this.buildTagsFromParams(props.params, []), }; } public componentWillReceiveProps(props: ISearchProps) { const tags = this.buildTagsFromParams(props.params, this.state.tags); this.setState({ tags }); } // TODO: ANG to clean this up by adding sort weights to filters to control order so this hackery isn't needed private reorderFilterTypesForSearch(filterTypes: IFilterType[]): IFilterType[] { const mods: Set = new Set([ SearchFilterTypeRegistry.KEYWORD_FILTER.key, SearchFilterTypeRegistry.NAME_FILTER.key, ]); const result: IFilterType[] = filterTypes.filter((type: IFilterType) => !mods.has(type.key)); result.unshift(SearchFilterTypeRegistry.NAME_FILTER); result.unshift(SearchFilterTypeRegistry.KEYWORD_FILTER); return result; } // Merge param changes with existing tags, maintaining the current order (new tags should go at the end) private buildTagsFromParams(params: { [key: string]: any }, currentTags: ITag[]): ITag[] { const tagsToKeep: ITag[] = currentTags .filter((tag) => params[tag.key]) .map((tag) => ({ key: tag.key, text: params[tag.key] })); const tagsToAdd: ITag[] = Object.keys(params) .filter((key) => !!params[key] && !tagsToKeep.some((tag) => tag.key === key)) .map((key) => ({ key, text: params[key] })); return tagsToKeep.concat(tagsToAdd); } private isLongEnoughIfKeyword(tag: ITag): boolean { return ( tag.key !== SearchFilterTypeRegistry.KEYWORD_FILTER.key || (tag.key === SearchFilterTypeRegistry.KEYWORD_FILTER.key && tag.text.length > 2) ); } private hasModifier(input: string): boolean { const index = input.indexOf(':'); return this.keys.has(input.substring(0, index).toLocaleLowerCase()) && index !== input.length; } private getModifier(input: string): string { return input.substring(0, input.indexOf(':')).toLocaleLowerCase(); } private getSearchText(input: string): string { const trimmed = input.trim(); const searchText = trimmed.split(':')[1] || ''; return this.hasModifier(trimmed) ? searchText.trim() : trimmed; } private buildTagFromInputString(filter: IFilterType = this.state.activeFilter): ITag { const value = this.inputElement.value.trim(); const text = this.getSearchText(value).trim(); if (!value || !text) { return null; } const key = this.hasModifier(value) ? this.getModifier(value) : filter.key; return { key, text }; } private getActiveFilterIndex(): number { return this.filterTypes.findIndex((type: IFilterType) => type === this.state.activeFilter); } private getActiveFilterText(filterType: IFilterType): string { let result: string; const text = this.inputElement.value.trim(); if (this.hasModifier(text)) { const key = this.getModifier(text); const regex = new RegExp(key, 'i'); result = `${text.replace(regex, filterType.key)}`; } else { result = `${filterType.key}:${text}`; } return result; } private handleTagListUpdate = (elements: HTMLElement[]): void => { this.tagElements = elements; }; private refCallback = (element: HTMLInputElement): void => { this.inputElement = element; }; private handleBlur = (): void => { if (!this.mouseDownFired) { this.setState({ isFocused: false, isOpen: false, }); } }; private handleChange = (): void => { let newState: Partial; const value = this.inputElement.value.trim(); if (this.hasModifier(value)) { const key = this.getModifier(value); newState = { activeFilter: this.filterTypes.find((type: IFilterType) => type.key === key), isOpen: true, }; } else { newState = { isOpen: !!value }; } this.setState(newState as ISearchState); }; private handleDelete = (tag: ITag, focus: boolean): void => { const tags = this.state.tags.filter((t: ITag) => tag !== t); this.setState({ isFocused: true, tags, }); this.props.onChange(tags); if (focus) { this.inputElement.focus(); } }; private handleFocus = (): void => { this.setState({ isFocused: true, }); }; private navigateUp(): void { const { filterTypes } = this; const activeIndex = this.getActiveFilterIndex(); const activeFilter = activeIndex === 0 ? filterTypes[filterTypes.length - 1] : filterTypes[activeIndex - 1]; this.navigate(activeFilter); } private navigateDown(): void { const { filterTypes } = this; const activeIndex = this.getActiveFilterIndex(); const activeFilter = activeIndex === filterTypes.length - 1 ? filterTypes[0] : filterTypes[activeIndex + 1]; this.navigate(activeFilter); } private navigate(active: IFilterType): void { const activeFilter: IFilterType = this.state.isOpen ? active : SearchFilterTypeRegistry.KEYWORD_FILTER; const newState: Partial = { activeFilter }; this.inputElement.value = this.getActiveFilterText(activeFilter); this.setState({ ...newState, isOpen: true } as ISearchState); } private handleFilterSelection(filter?: IFilterType): void { const tag = this.buildTagFromInputString(filter); if (tag && this.isLongEnoughIfKeyword(tag)) { const tags: ITag[] = this.state.tags.filter((x) => x.key !== tag.key).concat(tag); this.setState({ activeFilter: SearchFilterTypeRegistry.KEYWORD_FILTER, isOpen: false, tags, }); this.inputElement.value = ''; this.props.onChange(tags); } } private handleKeyUpFromInput = (event: React.KeyboardEvent): void => { const length = this.tagElements.length; switch (event.key) { case Key.LEFT_ARROW: if (!this.inputElement.value && length >= 1) { this.tagElements[length - 1].focus(); } break; case Key.RIGHT_ARROW: if (!this.inputElement.value && length !== 0) { this.tagElements[0].focus(); } break; case Key.UP_ARROW: this.navigateUp(); break; case Key.DOWN_ARROW: this.navigateDown(); break; case Key.ENTER: this.handleFilterSelection(); break; case Key.ESCAPE: { const text = this.inputElement.value; if (this.hasModifier(text) && !this.getSearchText(text)) { this.inputElement.value = ''; } this.setState({ activeFilter: SearchFilterTypeRegistry.KEYWORD_FILTER, isOpen: false, }); break; } } }; private handleKeyUpFromTag = (): void => { this.inputElement.focus(); }; private filterClicked = (filter: Filter): void => { this.handleFilterSelection(filter.props.filterType); this.mouseDownFired = false; }; private handleKeyUp = (key: Key): void => { switch (key) { case Key.UP_ARROW: this.navigateUp(); break; case Key.DOWN_ARROW: this.navigateDown(); break; } }; private handleMouseDown = (): void => { this.mouseDownFired = true; }; private handleClearClick = (): void => { this.setState({ isOpen: false, tags: [], }); this.inputElement.value = ''; this.inputElement.focus(); this.props.onChange([]); }; public render(): React.ReactElement { const { activeFilter, isFocused, isOpen, layouts, tags } = this.state; // prettier-ignore const className = classNames({ 'search__input': true, 'search__input--focus': isFocused, 'search__input--blur': !isFocused, }); return (
Search
); } }