Skip to content

Commit

Permalink
Extend search query input with option to view search query history (#…
Browse files Browse the repository at this point in the history
…18067)

* Add search query input completer for query history.

* Add button to view query history.

* Improve token helper naming.

* Replace completer with solution where we manually set completions when triggering action to show history.

* Set fixed width and position for editor dropdown.

* Fix background color of focused autocompletion.

* Fixing tests

* Fix order of query history.

* Cleanup code.

* Add changelog.

* Extend query history limit.

* Fix changelog.

* Show query history when showing suggestions in empty input.

* Remove `Ctrl-Space` and `Ctrl-Shift-Space` keybindings for query input.

* Display further query input keyboard shortcuts in keyboard shortcuts modal.

* Do not select query input value, when inserting history suggestion.

* Update changelog.

* Cleanup code.

* Do not show empty dropdown when triggering action to show query history while there is no history.
  • Loading branch information
linuspahl authored Feb 2, 2024
1 parent 014f732 commit 18ac529
Show file tree
Hide file tree
Showing 25 changed files with 348 additions and 68 deletions.
15 changes: 15 additions & 0 deletions changelog/unreleased/pr-18067.toml
Original file line number Diff line number Diff line change
@@ -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.
"""
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ const useElementsWidths = <Entity extends EntityBase>({
displayBulkSelectCol: boolean
fixedActionsCellWidth: number | undefined
}) => {
const tableRef = useRef<HTMLTableElement>();
const tableRef = useRef<HTMLTableElement>(null);
const actionsRef = useRef<HTMLDivElement>();
const { width: tableWidth } = useElementDimensions(tableRef);
const columnsIds = useMemo(() => columns.map(({ id }) => id), [columns]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion graylog2-web-interface/src/contexts/HotkeysContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> | ScopeName

export type KeyboardModifiers = {
Expand Down
12 changes: 12 additions & 0 deletions graylog2-web-interface/src/contexts/HotkeysProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions graylog2-web-interface/src/hooks/useElementDimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>, debounceDelay = 200) => {
const useElementDimensions = (target: React.RefObject<HTMLElement> | 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 });
}
Expand Down
4 changes: 2 additions & 2 deletions graylog2-web-interface/src/hooks/useHotkey.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<unknown>,
Expand Down
10 changes: 5 additions & 5 deletions graylog2-web-interface/src/routing/loadAsync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,16 @@ type ComponentSupplier<TProps> = () => Promise<{ default: React.ComponentType<TP
// eslint-disable-next-line react/jsx-no-useless-fragment
const emptyPlaceholder = <></>;

const loadAsync = <TProps, >(factory: ComponentSupplier<TProps>): React.ComponentType<TProps> => {
const Component = React.lazy(factory) as React.ComponentType<TProps>;
const loadAsync = <TProps, >(factory: ComponentSupplier<TProps>) => {
const Component = React.lazy(factory) as React.ForwardRefExoticComponent<TProps>;

return (props: TProps) => (
return React.forwardRef((props: TProps, ref) => (
<ErrorBoundary FallbackComponent={ErrorComponent}>
<React.Suspense fallback={emptyPlaceholder}>
<Component {...props} />
<Component {...props} ref={ref} />
</React.Suspense>
</ErrorBoundary>
);
));
};

export default loadAsync;
3 changes: 1 addition & 2 deletions graylog2-web-interface/src/theme/GlobalThemeStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => <span>{value}</span>);
jest.mock('views/components/searchbar/queryinput/QueryInput');
jest.mock('views/components/DashboardActionsMenu', () => () => <span>View Actions</span>);

jest.mock('views/hooks/useAutoRefresh', () => () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -104,6 +106,7 @@ const useInitialFormValues = (timerange: TimeRange, queryString: string) => {
};

const DashboardSearchBar = () => {
const editorRef = useRef<Editor>(null);
const view = useView();
const { userTimezone } = useUserDateTime();
const { searchesClusterConfig: config } = useStore(SearchConfigStore);
Expand Down Expand Up @@ -177,6 +180,7 @@ const DashboardSearchBar = () => {
isValidating={isValidating}
validate={validateForm}
warning={warnings.queryString}
ref={editorRef}
onExecute={handleSubmit as () => void}
commands={customCommands} />
)}
Expand All @@ -187,6 +191,7 @@ const DashboardSearchBar = () => {
</Field>

<QueryValidation />
<QueryHistoryButton editorRef={editorRef} />
</SearchInputAndValidationContainer>
</SearchButtonAndQuery>

Expand Down
7 changes: 6 additions & 1 deletion graylog2-web-interface/src/views/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
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';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -133,6 +135,7 @@ type Props = {
};

const SearchBar = ({ onSubmit = defaultProps.onSubmit }: Props) => {
const editorRef = useRef<Editor>(null);
const view = useView();
const availableStreams = useStore(StreamsStore, ({ streams }) => streams.map((stream) => ({
key: stream.title,
Expand Down Expand Up @@ -218,6 +221,7 @@ const SearchBar = ({ onSubmit = defaultProps.onSubmit }: Props) => {
<PluggableCommands usage="search_query">
{(customCommands) => (
<QueryInput value={value}
ref={editorRef}
view={view}
timeRange={values.timerange}
streams={values.streams}
Expand All @@ -239,6 +243,7 @@ const SearchBar = ({ onSubmit = defaultProps.onSubmit }: Props) => {
</Field>

<QueryValidation />
<QueryHistoryButton editorRef={editorRef} />
</SearchInputAndValidationContainer>
</SearchButtonAndQuery>
{!editing && <SearchActionsMenu />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => <span>{value}</span>);

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 }) => <span>{value}</span>);
jest.mock('views/components/searchbar/queryinput/QueryInput', () => ({ value = '' }: { value: string }) => <span>{value}</span>);

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', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => <span>{value}</span>);
jest.mock('views/components/searchbar/queryvalidation/QueryValidation', () => mockComponent('QueryValidation'));
jest.mock('views/components/searchbar/queryinput/BasicQueryInput', () => ({ value = '' }: { value: string }) => <span>{value}</span>);
jest.mock('views/components/searchbar/queryinput/QueryInput', () => ({ value = '' }: { value: string }) => <span>{value}</span>);

jest.mock('views/components/searchbar/queryinput/QueryInput');
jest.mock('views/components/searchbar/queryinput/BasicQueryInput');

jest.mock('views/stores/SearchConfigStore', () => ({
SearchConfigActions: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -159,6 +161,7 @@ const _validateQueryString = (values: SearchBarFormValues, globalOverride: Globa
};

const WidgetQueryControls = ({ availableStreams }: Props) => {
const editorRef = useRef<Editor>(null);
const view = useView();
const globalOverride = useGlobalOverride();
const widget = useContext(WidgetContext);
Expand Down Expand Up @@ -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}
Expand All @@ -252,6 +256,7 @@ const WidgetQueryControls = ({ availableStreams }: Props) => {
</Field>

<QueryValidation />
<QueryHistoryButton editorRef={editorRef} />
</SearchInputAndValidation>

{hasQueryOverride && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
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<Editor>
}

const QueryHistoryButton = ({ editorRef }: Props) => {
const showQueryHistory = async () => {
if (editorRef.current) {
editorRef.current.focus();

displayHistoryCompletions(editorRef.current);
}
};

return (
<ButtonContainer>
<IconButton name="clock-rotate-left" onClick={showQueryHistory} />
</ButtonContainer>
);
};

export default QueryHistoryButton;
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 18ac529

Please sign in to comment.