diff --git a/changelog/unreleased/pr-18067.toml b/changelog/unreleased/pr-18067.toml new file mode 100644 index 000000000000..2309d988ca4a --- /dev/null +++ b/changelog/unreleased/pr-18067.toml @@ -0,0 +1,15 @@ +type = "a" +message = "Extend search query input with option to view search query history." + +issues = ["Graylog2/graylog-plugin-enterprise#4012"] +pulls = ["18067"] + +details.user = """ +The history is scoped by user and sorted chronologically. The history contains all queries executed on the search page, dashboards and dashboard widgets. +It can be opened by clicking on the history icon or by pressing the keyboard shortcut alt+shift+h. + +While it is open, it can be filtered using the query input. When a previous query has been selected, it will replace the current value of the query input. + +With this change we are also removing the keyboard shortcuts ctrl+space and ctrl+shift+space to manually display the suggestions. +It is still possible to display the suggestions by pressing alt+space. +""" diff --git a/graylog2-web-interface/src/components/common/EntityDataTable/EntityDataTable.tsx b/graylog2-web-interface/src/components/common/EntityDataTable/EntityDataTable.tsx index f5e7cfc856f9..474ece34d410 100644 --- a/graylog2-web-interface/src/components/common/EntityDataTable/EntityDataTable.tsx +++ b/graylog2-web-interface/src/components/common/EntityDataTable/EntityDataTable.tsx @@ -112,7 +112,7 @@ const useElementsWidths = ({ displayBulkSelectCol: boolean fixedActionsCellWidth: number | undefined }) => { - const tableRef = useRef(); + const tableRef = useRef(null); const actionsRef = useRef(); const { width: tableWidth } = useElementDimensions(tableRef); const columnsIds = useMemo(() => columns.map(({ id }) => id), [columns]); diff --git a/graylog2-web-interface/src/components/hotkeys/HotkeyCollectionSection.tsx b/graylog2-web-interface/src/components/hotkeys/HotkeyCollectionSection.tsx index 723b64daa8d3..4f85f991582f 100644 --- a/graylog2-web-interface/src/components/hotkeys/HotkeyCollectionSection.tsx +++ b/graylog2-web-interface/src/components/hotkeys/HotkeyCollectionSection.tsx @@ -42,7 +42,7 @@ const KeysList = styled.div` display: inline-flex; gap: 5px; justify-content: right; - flex-wrap: wrap; + height: fit-content; `; const KeySeparator = styled.div` diff --git a/graylog2-web-interface/src/contexts/HotkeysContext.tsx b/graylog2-web-interface/src/contexts/HotkeysContext.tsx index 603d415fcaa5..68dc6895eba4 100644 --- a/graylog2-web-interface/src/contexts/HotkeysContext.tsx +++ b/graylog2-web-interface/src/contexts/HotkeysContext.tsx @@ -20,7 +20,7 @@ import type Immutable from 'immutable'; import { singleton } from 'logic/singleton'; export type DefaultScopeName = '*'; -export type ScopeName = 'general' | 'search' | 'dashboard' | 'scratchpad'; +export type ScopeName = 'general' | 'search' | 'dashboard' | 'scratchpad' | 'query-input'; export type ScopeParam = Array | ScopeName export type KeyboardModifiers = { diff --git a/graylog2-web-interface/src/contexts/HotkeysProvider.tsx b/graylog2-web-interface/src/contexts/HotkeysProvider.tsx index 75aaac75a664..c883bda92b30 100644 --- a/graylog2-web-interface/src/contexts/HotkeysProvider.tsx +++ b/graylog2-web-interface/src/contexts/HotkeysProvider.tsx @@ -61,6 +61,18 @@ export const hotKeysCollections: HotkeyCollections = { 'save-as': { keys: 'mod+shift+s', description: 'Save dashboard as' }, }, }, + 'query-input': { + title: 'Query Input', + description: 'Keyboard shortcuts for query input in search bar, available when input is focussed.', + // Please note, any changes to keybindings also need to be made in the query input component. + actions: { + 'submit-search': { keys: 'return', description: 'Execute the search' }, + 'insert-newline': { keys: 'shift+return', description: 'Create a new line' }, + 'create-search-filter': { keys: 'alt+return', description: 'Create search filter based on current query' }, + 'show-suggestions': { keys: 'alt+space', description: 'Show suggestions, displays query history when input is empty' }, + 'show-history': { keys: 'alt+shift+h', description: 'View your search query history' }, + }, + }, scratchpad: { title: 'Scratchpad', description: 'Scratchpad shortcuts', diff --git a/graylog2-web-interface/src/hooks/useElementDimensions.ts b/graylog2-web-interface/src/hooks/useElementDimensions.ts index 09e5df7fc6ed..77b2c734cce2 100644 --- a/graylog2-web-interface/src/hooks/useElementDimensions.ts +++ b/graylog2-web-interface/src/hooks/useElementDimensions.ts @@ -20,12 +20,12 @@ import useResizeObserver from '@react-hook/resize-observer'; import debounce from 'lodash/debounce'; // Simple hook which provides the width and height of an element by using a ResizeObserver. -const useElementDimensions = (target: React.RefObject, debounceDelay = 200) => { +const useElementDimensions = (target: React.RefObject | HTMLElement, debounceDelay = 200) => { const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); const debouncedUpdate = debounce((newDimensions) => setDimensions(newDimensions), debounceDelay); useLayoutEffect(() => { - if (target?.current) { + if (target && 'current' in target && target.current) { const { width, height } = target.current.getBoundingClientRect(); setDimensions({ width, height }); } diff --git a/graylog2-web-interface/src/hooks/useHotkey.tsx b/graylog2-web-interface/src/hooks/useHotkey.tsx index c9808f392d99..80fd1eef0640 100644 --- a/graylog2-web-interface/src/hooks/useHotkey.tsx +++ b/graylog2-web-interface/src/hooks/useHotkey.tsx @@ -32,7 +32,7 @@ export const DEFAULT_COMBINATION_KEY = '+'; const defaultOptions: ReactHotKeysHookOptions & Options = { preventDefault: true, enabled: true, - enableOnFormTags: false, + enableOnFormTags: true, enableOnContentEditable: false, combinationKey: DEFAULT_COMBINATION_KEY, splitKey: DEFAULT_SPLIT_KEY, @@ -56,7 +56,7 @@ const catchErrors = (hotKeysCollections: HotkeyCollections, actionKey: string, s export type HotkeysProps = { actionKey: string, - callback: (event: KeyboardEvent, handler: HotkeysEvent) => unknown, + callback?: (event: KeyboardEvent, handler: HotkeysEvent) => unknown, scope: ScopeName, options?: Options, dependencies?: Array, diff --git a/graylog2-web-interface/src/routing/loadAsync.tsx b/graylog2-web-interface/src/routing/loadAsync.tsx index 7d4ef8d0ed1c..58073e6a96c5 100644 --- a/graylog2-web-interface/src/routing/loadAsync.tsx +++ b/graylog2-web-interface/src/routing/loadAsync.tsx @@ -36,16 +36,16 @@ type ComponentSupplier = () => Promise<{ default: React.ComponentType; -const loadAsync = (factory: ComponentSupplier): React.ComponentType => { - const Component = React.lazy(factory) as React.ComponentType; +const loadAsync = (factory: ComponentSupplier) => { + const Component = React.lazy(factory) as React.ForwardRefExoticComponent; - return (props: TProps) => ( + return React.forwardRef((props: TProps, ref) => ( - + - ); + )); }; export default loadAsync; diff --git a/graylog2-web-interface/src/theme/GlobalThemeStyles.ts b/graylog2-web-interface/src/theme/GlobalThemeStyles.ts index 276ffae26e78..81a51285a615 100644 --- a/graylog2-web-interface/src/theme/GlobalThemeStyles.ts +++ b/graylog2-web-interface/src/theme/GlobalThemeStyles.ts @@ -646,14 +646,13 @@ const GlobalThemeStyles = createGlobalStyle(({ theme }) => css` /* additional styles for 'StyledAceEditor' */ .ace_editor.ace_autocomplete { - width: 600px !important; margin-top: 6px; background-color: ${theme.colors.input.background}; color: ${theme.colors.input.color}; } .ace_editor.ace_autocomplete .ace_marker-layer .ace_active-line { - background-color: ${theme.utils.opacify(theme.colors.variant.info, 0.7)}; + background-color: ${theme.colors.variant.lighter.info}; color: ${theme.colors.input.colorDisabled}; } diff --git a/graylog2-web-interface/src/views/components/DashboardSearchBar.test.tsx b/graylog2-web-interface/src/views/components/DashboardSearchBar.test.tsx index df0461eaa75b..9861195555fa 100644 --- a/graylog2-web-interface/src/views/components/DashboardSearchBar.test.tsx +++ b/graylog2-web-interface/src/views/components/DashboardSearchBar.test.tsx @@ -29,7 +29,7 @@ import { execute, setGlobalOverride } from 'views/logic/slices/searchExecutionSl import OriginalDashboardSearchBar from './DashboardSearchBar'; -jest.mock('views/components/searchbar/queryinput/QueryInput', () => ({ value = '' }: { value: string }) => {value}); +jest.mock('views/components/searchbar/queryinput/QueryInput'); jest.mock('views/components/DashboardActionsMenu', () => () => View Actions); jest.mock('views/hooks/useAutoRefresh', () => () => ({ diff --git a/graylog2-web-interface/src/views/components/DashboardSearchBar.tsx b/graylog2-web-interface/src/views/components/DashboardSearchBar.tsx index 9cca6e9f28cb..c501c4247010 100644 --- a/graylog2-web-interface/src/views/components/DashboardSearchBar.tsx +++ b/graylog2-web-interface/src/views/components/DashboardSearchBar.tsx @@ -15,7 +15,7 @@ * . */ import * as React from 'react'; -import { useMemo, useCallback } from 'react'; +import { useMemo, useCallback, useRef } from 'react'; import { Field } from 'formik'; import moment from 'moment'; import styled, { css } from 'styled-components'; @@ -58,6 +58,8 @@ import { setGlobalOverride, execute } from 'views/logic/slices/searchExecutionSl import useGlobalOverride from 'views/hooks/useGlobalOverride'; import useHandlerContext from 'views/components/useHandlerContext'; import type { TimeRange } from 'views/logic/queries/Query'; +import QueryHistoryButton from 'views/components/searchbar/QueryHistoryButton'; +import type { Editor } from 'views/components/searchbar/queryinput/ace-types'; import useView from 'views/hooks/useView'; import TimeRangeFilter from './searchbar/time-range-filter'; @@ -104,6 +106,7 @@ const useInitialFormValues = (timerange: TimeRange, queryString: string) => { }; const DashboardSearchBar = () => { + const editorRef = useRef(null); const view = useView(); const { userTimezone } = useUserDateTime(); const { searchesClusterConfig: config } = useStore(SearchConfigStore); @@ -177,6 +180,7 @@ const DashboardSearchBar = () => { isValidating={isValidating} validate={validateForm} warning={warnings.queryString} + ref={editorRef} onExecute={handleSubmit as () => void} commands={customCommands} /> )} @@ -187,6 +191,7 @@ const DashboardSearchBar = () => { + diff --git a/graylog2-web-interface/src/views/components/SearchBar.tsx b/graylog2-web-interface/src/views/components/SearchBar.tsx index 0e1d6e7d1645..680f20f0f573 100644 --- a/graylog2-web-interface/src/views/components/SearchBar.tsx +++ b/graylog2-web-interface/src/views/components/SearchBar.tsx @@ -15,7 +15,7 @@ * . */ import * as React from 'react'; -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import PropTypes from 'prop-types'; import * as Immutable from 'immutable'; import { Field } from 'formik'; @@ -66,6 +66,8 @@ import useAppDispatch from 'stores/useAppDispatch'; import { execute } from 'views/logic/slices/searchExecutionSlice'; import { updateQuery } from 'views/logic/slices/viewSlice'; import useHandlerContext from 'views/components/useHandlerContext'; +import QueryHistoryButton from 'views/components/searchbar/QueryHistoryButton'; +import type { Editor } from 'views/components/searchbar/queryinput/ace-types'; import SearchBarForm from './searchbar/SearchBarForm'; @@ -133,6 +135,7 @@ type Props = { }; const SearchBar = ({ onSubmit = defaultProps.onSubmit }: Props) => { + const editorRef = useRef(null); const view = useView(); const availableStreams = useStore(StreamsStore, ({ streams }) => streams.map((stream) => ({ key: stream.title, @@ -218,6 +221,7 @@ const SearchBar = ({ onSubmit = defaultProps.onSubmit }: Props) => { {(customCommands) => ( { + {!editing && } diff --git a/graylog2-web-interface/src/views/components/WidgetQueryControls.pluggableControls.test.tsx b/graylog2-web-interface/src/views/components/WidgetQueryControls.pluggableControls.test.tsx index c3636f50b17c..1576270c6500 100644 --- a/graylog2-web-interface/src/views/components/WidgetQueryControls.pluggableControls.test.tsx +++ b/graylog2-web-interface/src/views/components/WidgetQueryControls.pluggableControls.test.tsx @@ -39,11 +39,12 @@ import FormikInput from '../../components/common/FormikInput'; const testTimeout = applyTimeoutMultiplier(30000); jest.mock('views/components/searchbar/queryvalidation/QueryValidation', () => mockComponent('QueryValidation')); -jest.mock('views/components/searchbar/queryinput/QueryInput', () => ({ value = '' }: { value: string }) => {value}); + jest.mock('hooks/useFeature', () => (key: string) => key === 'search_filter'); -jest.mock('views/components/searchbar/queryvalidation/QueryValidation', () => mockComponent('QueryValidation')); -jest.mock('views/components/searchbar/queryinput/BasicQueryInput', () => ({ value = '' }: { value: string }) => {value}); -jest.mock('views/components/searchbar/queryinput/QueryInput', () => ({ value = '' }: { value: string }) => {value}); + +jest.mock('views/components/searchbar/queryinput/QueryInput'); +jest.mock('views/components/searchbar/queryinput/BasicQueryInput'); + jest.mock('views/logic/debounceWithPromise', () => (fn: any) => fn); jest.mock('views/stores/SearchConfigStore', () => ({ diff --git a/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx b/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx index 822266beb6f9..5fd3e9f8da23 100644 --- a/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx +++ b/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx @@ -31,10 +31,10 @@ import WidgetQueryControls from './WidgetQueryControls'; import WidgetContext from './contexts/WidgetContext'; jest.mock('views/components/searchbar/queryvalidation/QueryValidation', () => mockComponent('QueryValidation')); -jest.mock('views/components/searchbar/queryinput/QueryInput', () => ({ value = '' }: { value: string }) => {value}); jest.mock('views/components/searchbar/queryvalidation/QueryValidation', () => mockComponent('QueryValidation')); -jest.mock('views/components/searchbar/queryinput/BasicQueryInput', () => ({ value = '' }: { value: string }) => {value}); -jest.mock('views/components/searchbar/queryinput/QueryInput', () => ({ value = '' }: { value: string }) => {value}); + +jest.mock('views/components/searchbar/queryinput/QueryInput'); +jest.mock('views/components/searchbar/queryinput/BasicQueryInput'); jest.mock('views/stores/SearchConfigStore', () => ({ SearchConfigActions: { diff --git a/graylog2-web-interface/src/views/components/WidgetQueryControls.tsx b/graylog2-web-interface/src/views/components/WidgetQueryControls.tsx index 69daa7f69ab3..dfa7ee4b8d08 100644 --- a/graylog2-web-interface/src/views/components/WidgetQueryControls.tsx +++ b/graylog2-web-interface/src/views/components/WidgetQueryControls.tsx @@ -63,6 +63,8 @@ import useHandlerContext from 'views/components/useHandlerContext'; import useView from 'views/hooks/useView'; import { isNoTimeRangeOverride } from 'views/typeGuards/timeRange'; import { normalizeFromSearchBarForBackend } from 'views/logic/queries/NormalizeTimeRange'; +import QueryHistoryButton from 'views/components/searchbar/QueryHistoryButton'; +import type { Editor } from 'views/components/searchbar/queryinput/ace-types'; import TimeRangeOverrideInfo from './searchbar/WidgetTimeRangeOverride'; import TimeRangeFilter from './searchbar/time-range-filter'; @@ -159,6 +161,7 @@ const _validateQueryString = (values: SearchBarFormValues, globalOverride: Globa }; const WidgetQueryControls = ({ availableStreams }: Props) => { + const editorRef = useRef(null); const view = useView(); const globalOverride = useGlobalOverride(); const widget = useContext(WidgetContext); @@ -236,6 +239,7 @@ const WidgetQueryControls = ({ availableStreams }: Props) => { streams={values?.streams} placeholder={'Type your search query here and press enter. E.g.: ("not found" AND http) OR http_response_code:[400 TO 404]'} error={error} + ref={editorRef} disableExecution={disableSearchSubmit} isValidating={isValidatingQuery} warning={warnings.queryString} @@ -252,6 +256,7 @@ const WidgetQueryControls = ({ availableStreams }: Props) => { + {hasQueryOverride && ( diff --git a/graylog2-web-interface/src/views/components/searchbar/QueryHistoryButton.tsx b/graylog2-web-interface/src/views/components/searchbar/QueryHistoryButton.tsx new file mode 100644 index 000000000000..5a2a76606613 --- /dev/null +++ b/graylog2-web-interface/src/views/components/searchbar/QueryHistoryButton.tsx @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; +import styled from 'styled-components'; + +import { SearchSuggestions } from '@graylog/server-api'; +import IconButton from 'components/common/IconButton'; +import type { Editor } from 'views/components/searchbar/queryinput/ace-types'; +import { startAutocomplete } from 'views/components/searchbar/queryinput/commands'; + +const QUERY_HISTORY_LIMIT = 100; + +const ButtonContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + margin-left: 6px; +`; + +const fetchQueryHistoryCompletions = () => SearchSuggestions.suggestQueryStrings(QUERY_HISTORY_LIMIT).then((response) => ( + response.sort(( + { last_used: lastUsedA }, { last_used: lastUsedB }) => new Date(lastUsedB).getTime() - new Date(lastUsedA).getTime(), + ).map((entry, index) => ({ + value: entry.query, + meta: 'history', + score: index, + completer: { + insertMatch: ( + editor: { setValue: (value: string, cursorPosition?: number) => void }, + data: { value: string }, + ) => { + editor.setValue(data.value, 1); + }, + }, + })))); + +export const displayHistoryCompletions = async (editor: Editor) => { + const historyCompletions = await fetchQueryHistoryCompletions(); + + if (historyCompletions?.length) { + startAutocomplete(editor, { matches: historyCompletions }); + } +}; + +type Props = { + editorRef: React.MutableRefObject +} + +const QueryHistoryButton = ({ editorRef }: Props) => { + const showQueryHistory = async () => { + if (editorRef.current) { + editorRef.current.focus(); + + displayHistoryCompletions(editorRef.current); + } + }; + + return ( + + + + ); +}; + +export default QueryHistoryButton; diff --git a/graylog2-web-interface/src/views/components/searchbar/SearchBarAutocompletions.ts b/graylog2-web-interface/src/views/components/searchbar/SearchBarAutocompletions.ts index f80d2172ce00..8eeed16ff27a 100644 --- a/graylog2-web-interface/src/views/components/searchbar/SearchBarAutocompletions.ts +++ b/graylog2-web-interface/src/views/components/searchbar/SearchBarAutocompletions.ts @@ -36,6 +36,7 @@ export type FieldTypes = { all: FieldIndex, query: FieldIndex }; type FieldIndex = { [fieldName: string]: FieldTypeMapping }; export type CompleterContext = Readonly<{ + commandArgs?: unknown, currentToken: Token | undefined | null, prevToken: Token | undefined | null, prefix: string, @@ -137,6 +138,7 @@ export default class SearchBarAutoCompletions implements AutoCompleter { prefix, tokens, currentTokenIdx, + commandArgs: _session?.curOp?.args, timeRange: this.timeRange, streams: this.streams, fieldTypes: this.fieldTypes, diff --git a/graylog2-web-interface/src/views/components/searchbar/completions/token-helper.ts b/graylog2-web-interface/src/views/components/searchbar/completions/token-helper.ts index a64661e347ec..1d4cedc57761 100644 --- a/graylog2-web-interface/src/views/components/searchbar/completions/token-helper.ts +++ b/graylog2-web-interface/src/views/components/searchbar/completions/token-helper.ts @@ -33,7 +33,10 @@ export const isExistsOperator = (token: Token | undefined) => isTypeKeyword(toke export const isCompleteFieldName = (token: Token | undefined) => !!(isTypeKeyword(token) && token?.value.endsWith(':')); -export const isSpace = (currentToken: Token | undefined) => isTypeText(currentToken) && currentToken?.value === ' '; +export const isSpace = (token: Token | undefined) => isTypeText(token) && token?.value === ' '; + +export const isLeftParen = (token: Token | undefined) => token?.type === 'paren.lparen'; +export const isRightParen = (token: Token | undefined) => token?.type === 'paren.rparen'; export const getFieldNameForFieldValueInBrackets = (tokens: Array, currentTokenIndex: number) => { if (!tokens?.length) { @@ -43,7 +46,7 @@ export const getFieldNameForFieldValueInBrackets = (tokens: Array, curren const currentToken = tokens[currentTokenIndex]; const prevToken = tokens[currentTokenIndex - 1] ?? null; - if (prevToken?.type === 'keyword' && prevToken?.value.endsWith(':') && currentToken.type === 'paren.lparen') { + if (prevToken?.type === 'keyword' && prevToken?.value.endsWith(':') && isLeftParen(currentToken)) { return removeFinalColon(prevToken.value); } @@ -52,15 +55,15 @@ export const getFieldNameForFieldValueInBrackets = (tokens: Array, curren let closingBracketsCount = 0; for (let i = 0; i < currentTokenIndex; i += 1) { - if (tokens[i].type === 'keyword' && tokens[i].value.endsWith(':') && tokens[i + 1].type === 'paren.lparen') { + if (tokens[i].type === 'keyword' && tokens[i].value.endsWith(':') && isLeftParen(tokens[i + 1])) { fieldNameIndex = i; } - if (fieldNameIndex !== null && tokens[i].type === 'paren.lparen') { + if (fieldNameIndex !== null && isLeftParen(tokens[i])) { openingBracketsCount += 1; } - if (fieldNameIndex !== null && tokens[i].type === 'paren.rparen') { + if (fieldNameIndex !== null && isRightParen(tokens[i])) { closingBracketsCount += 1; } diff --git a/graylog2-web-interface/src/views/components/searchbar/queryinput/QueryInput.tsx b/graylog2-web-interface/src/views/components/searchbar/queryinput/QueryInput.tsx index 6499ad95ca25..49fef91a5e93 100644 --- a/graylog2-web-interface/src/views/components/searchbar/queryinput/QueryInput.tsx +++ b/graylog2-web-interface/src/views/components/searchbar/queryinput/QueryInput.tsx @@ -15,10 +15,11 @@ * . */ import * as React from 'react'; -import { useCallback, useMemo, useContext, useRef } from 'react'; +import { useCallback, useMemo, useContext, useRef, useImperativeHandle } from 'react'; import PropTypes from 'prop-types'; import isEmpty from 'lodash/isEmpty'; import type { FormikErrors } from 'formik'; +import { createGlobalStyle } from 'styled-components'; import UserPreferencesContext from 'contexts/UserPreferencesContext'; import type { TimeRange, NoTimeRangeOverride } from 'views/logic/queries/Query'; @@ -30,6 +31,10 @@ import { isNoTimeRangeOverride } from 'views/typeGuards/timeRange'; import usePluginEntities from 'hooks/usePluginEntities'; import useUserDateTime from 'hooks/useUserDateTime'; import type View from 'views/logic/views/View'; +import useElementDimensions from 'hooks/useElementDimensions'; +import { displayHistoryCompletions } from 'views/components/searchbar/QueryHistoryButton'; +import { startAutocomplete } from 'views/components/searchbar/queryinput/commands'; +import useHotkey from 'hooks/useHotkey'; import type { AutoCompleter, Editor, Command } from './ace-types'; import type { BaseProps } from './BasicQueryInput'; @@ -40,6 +45,13 @@ import type { Completer, FieldTypes } from '../SearchBarAutocompletions'; const defaultCompleterFactory = (...args: ConstructorParameters) => new SearchBarAutoCompletions(...args); +const GlobalEditorStyles = createGlobalStyle<{ $width?: number; $offsetLeft: number }>` + .ace_editor.ace_autocomplete { + width: ${(props) => (props.$width ?? 600) - 12}px !important; + left: ${(props) => (props.$offsetLeft ?? 143) + 7}px !important; + } +`; + const displayValidationErrors = () => { QueryValidationActions.displayValidationErrors(); }; @@ -99,8 +111,8 @@ const _onLoadEditor = (editor: Editor, isInitialTokenizerUpdate: React.MutableRe editor.commands.removeCommands(['find', 'indent', 'outdent']); editor.session.on('tokenizerUpdate', () => { - if (editor.isFocused() && !editor.completer?.activated && !isInitialTokenizerUpdate.current) { - editor.execCommand('startAutocomplete'); + if (editor.isFocused() && !editor.completer?.activated && editor.getValue() && !isInitialTokenizerUpdate.current) { + startAutocomplete(editor); } if (isInitialTokenizerUpdate.current) { @@ -115,8 +127,13 @@ const _onLoadEditor = (editor: Editor, isInitialTokenizerUpdate: React.MutableRe // This is necessary for configuration options which rely on external data. // Unfortunately it is not possible to configure for example the command once // with the `onLoad` or `commands` prop, because the reference for the related function will be outdated. -const _updateEditorConfiguration = (node: { editor: Editor; }, completer: AutoCompleter, commands: Array) => { - const editor = node && node.editor; +const _updateEditorConfiguration = (node: { editor: Editor; }, completer: AutoCompleter, commands: Array, ref: React.MutableRefObject) => { + const editor = node?.editor; + + if (ref && editor) { + // eslint-disable-next-line no-param-reassign + ref.current = editor; + } if (editor) { editor.commands.on('afterExec', () => { @@ -150,7 +167,8 @@ const _updateEditorConfiguration = (node: { editor: Editor; }, completer: AutoCo } }; -const useCompleter = ({ streams, timeRange, completerFactory, userTimezone, view }: Pick & { userTimezone: string }) => { +const useCompleter = ({ streams, timeRange, completerFactory, view }: Pick) => { + const { userTimezone } = useUserDateTime(); const completers = usePluginEntities('views.completers'); const { data: queryFields } = useFieldTypes(streams, isNoTimeRangeOverride(timeRange) ? DEFAULT_TIMERANGE : timeRange); const { data: allFields } = useFieldTypes([], DEFAULT_TIMERANGE); @@ -165,6 +183,33 @@ const useCompleter = ({ streams, timeRange, completerFactory, userTimezone, view [completerFactory, completers, timeRange, streams, fieldTypes, userTimezone, view]); }; +const useShowHotkeysInOverview = () => { + useHotkey({ + scope: 'query-input', + actionKey: 'submit-search', + }); + + useHotkey({ + scope: 'query-input', + actionKey: 'insert-newline', + }); + + useHotkey({ + scope: 'query-input', + actionKey: 'create-search-filter', + }); + + useHotkey({ + scope: 'query-input', + actionKey: 'show-suggestions', + }); + + useHotkey({ + scope: 'query-input', + actionKey: 'show-history', + }); +}; + type Props = BaseProps & { commands?: Array, completerFactory?: ( @@ -187,7 +232,7 @@ type Props = BaseProps & { view?: View }; -const QueryInput = ({ +const QueryInput = React.forwardRef(({ className, commands, completerFactory = defaultCompleterFactory, @@ -209,11 +254,13 @@ const QueryInput = ({ wrapEnabled, name, view, -}: Props) => { - const { userTimezone } = useUserDateTime(); +}, outerRef) => { + const innerRef = useRef(null); + const inputElement = innerRef.current?.container; + const { width: inputWidth } = useElementDimensions(inputElement); const isInitialTokenizerUpdate = useRef(true); const { enableSmartSearch } = useContext(UserPreferencesContext); - const completer = useCompleter({ streams, timeRange, completerFactory, userTimezone, view }); + const completer = useCompleter({ streams, timeRange, completerFactory, view }); const onLoadEditor = useCallback((editor: Editor) => _onLoadEditor(editor, isInitialTokenizerUpdate), []); const onExecute = useCallback((editor: Editor) => handleExecution({ editor, @@ -224,37 +271,75 @@ const QueryInput = ({ isValidating, validate, }), [onExecuteProp, value, error, disableExecution, isValidating, validate]); - const _commands = useMemo(() => [...commands, { - name: 'Execute', - bindKey: { win: 'Enter', mac: 'Enter' }, - exec: onExecute, - }], [commands, onExecute]); - const updateEditorConfiguration = useCallback((node: { editor: Editor }) => _updateEditorConfiguration(node, completer, _commands), [_commands, completer]); + const _commands = useMemo(() => [ + ...commands, + { + name: 'Execute', + bindKey: { win: 'Enter', mac: 'Enter' }, + exec: onExecute, + }, + { + name: 'Show completions', + bindKey: { win: 'Alt-Space', mac: 'Alt-Space' }, + exec: async (editor: Editor) => { + console.log('test', editor.getValue()); + + if (editor.getValue()) { + startAutocomplete(editor); + + return; + } + + await displayHistoryCompletions(editor); + }, + }, + { + name: 'Show query history', + bindKey: { win: 'Alt-Shift-H', mac: 'Alt-Shift-H' }, + exec: async (editor: Editor) => { + displayHistoryCompletions(editor); + }, + }, + // The following will disable the mentioned hotkeys. + { + name: 'Do nothing', + bindKey: { win: 'Ctrl-Space|Ctrl-Shift-Space', mac: 'Ctrl-Space|Ctrl-Shift-Space' }, + exec: () => {}, + }, + ], [commands, onExecute]); + const updateEditorConfiguration = useCallback((node: { editor: Editor }) => _updateEditorConfiguration(node, completer, _commands, innerRef), [_commands, completer]); const _onChange = useCallback((newQuery: string) => { onChange({ target: { value: newQuery, name } }); return Promise.resolve(newQuery); }, [name, onChange]); + useShowHotkeysInOverview(); + useImperativeHandle(outerRef, () => innerRef.current, []); + return ( - + <> + + + + ); -}; +}); QueryInput.propTypes = { className: PropTypes.string, diff --git a/graylog2-web-interface/src/views/components/searchbar/queryinput/__mocks__/BasicQueryInput.tsx b/graylog2-web-interface/src/views/components/searchbar/queryinput/__mocks__/BasicQueryInput.tsx new file mode 100644 index 000000000000..8085359c35e9 --- /dev/null +++ b/graylog2-web-interface/src/views/components/searchbar/queryinput/__mocks__/BasicQueryInput.tsx @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; + +const MockedBasicQueryInput = React.forwardRef(({ value }, ref) => ( + {value} +)); + +export default MockedBasicQueryInput; diff --git a/graylog2-web-interface/src/views/components/searchbar/queryinput/__mocks__/QueryInput.tsx b/graylog2-web-interface/src/views/components/searchbar/queryinput/__mocks__/QueryInput.tsx new file mode 100644 index 000000000000..d5c6008cce09 --- /dev/null +++ b/graylog2-web-interface/src/views/components/searchbar/queryinput/__mocks__/QueryInput.tsx @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; + +const MockedQueryInput = React.forwardRef(({ value }, ref) => ( + {value} +)); + +export default MockedQueryInput; diff --git a/graylog2-web-interface/src/views/components/searchbar/queryinput/ace-types.ts b/graylog2-web-interface/src/views/components/searchbar/queryinput/ace-types.ts index e4a5ed0ef0cb..c9843e7d688f 100644 --- a/graylog2-web-interface/src/views/components/searchbar/queryinput/ace-types.ts +++ b/graylog2-web-interface/src/views/components/searchbar/queryinput/ace-types.ts @@ -33,6 +33,7 @@ type EventCallback = { }; export type Session = { + curOp: { args: unknown }, getLength: () => number, getTokens: (no: number) => Array, getTokenAt: (no: number, idx: number) => Token | undefined | null, @@ -82,10 +83,12 @@ export type Completer = { }; export type Editor = { + container: HTMLElement | undefined, commands: Commands, completer: Completer, completers: Array, - execCommand: (command: string) => void, + execCommand: (command: string, args?: Record) => void, + focus: () => void, session: Session, renderer: Renderer, setFontSize: (newFontSize: number) => void, @@ -96,10 +99,10 @@ export type Editor = { }; export type CompletionResult = { - name: string, + name?: string, value: string, score: number, - meta: any, + meta?: any, caption?: string, }; diff --git a/graylog2-web-interface/src/views/components/searchbar/queryinput/commands.ts b/graylog2-web-interface/src/views/components/searchbar/queryinput/commands.ts new file mode 100644 index 000000000000..d73da845bb91 --- /dev/null +++ b/graylog2-web-interface/src/views/components/searchbar/queryinput/commands.ts @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ + +import type { Editor, CompletionResult } from 'views/components/searchbar/queryinput/ace-types'; + +// eslint-disable-next-line import/prefer-default-export +export const startAutocomplete = (editor: Editor, args?: { matches?: Array }) => editor.execCommand('startAutocomplete', args); diff --git a/graylog2-web-interface/src/views/components/widgets/Widget.test.tsx b/graylog2-web-interface/src/views/components/widgets/Widget.test.tsx index d3eccb3731c4..1f6b999b8bc4 100644 --- a/graylog2-web-interface/src/views/components/widgets/Widget.test.tsx +++ b/graylog2-web-interface/src/views/components/widgets/Widget.test.tsx @@ -19,7 +19,6 @@ import * as Immutable from 'immutable'; import { render, waitFor, fireEvent, screen } from 'wrappedTestingLibrary'; import type { PluginRegistration } from 'graylog-web-plugin/plugin'; -import mockComponent from 'helpers/mocking/MockComponent'; import asMock from 'helpers/mocking/AsMock'; import WidgetModel from 'views/logic/widgets/Widget'; import WidgetPosition from 'views/logic/widgets/WidgetPosition'; @@ -39,7 +38,7 @@ import type { WidgetFocusContextType } from '../contexts/WidgetFocusContext'; import WidgetFocusContext from '../contexts/WidgetFocusContext'; import FieldTypesContext from '../contexts/FieldTypesContext'; -jest.mock('../searchbar/queryinput/QueryInput', () => mockComponent('QueryInput')); +jest.mock('../searchbar/queryinput/QueryInput'); jest.mock('./WidgetHeader', () => 'widget-header'); jest.mock('./WidgetColorContext', () => ({ children }) => children); diff --git a/graylog2-web-interface/src/views/spec/CreateNewDashboard.it.tsx b/graylog2-web-interface/src/views/spec/CreateNewDashboard.it.tsx index 51fb7d8669e0..69c66765f18f 100644 --- a/graylog2-web-interface/src/views/spec/CreateNewDashboard.it.tsx +++ b/graylog2-web-interface/src/views/spec/CreateNewDashboard.it.tsx @@ -79,7 +79,7 @@ jest.mock('stores/sessions/SessionStore', () => ({ }, })); -jest.mock('views/components/searchbar/queryinput/QueryInput', () => () => Query Editor); +jest.mock('views/components/searchbar/queryinput/QueryInput'); jest.unmock('logic/rest/FetchProvider');