Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/unreleased/pr-26151.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type = "f"
message = "Carry search filters into widget replay search so the replayed search matches what the widget displayed."
issues = ["Graylog2/graylog-plugin-enterprise#8869"]
pulls = ["26151"]
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,35 @@
*/
import * as React from 'react';
import { asElement, render, screen } from 'wrappedTestingLibrary';
import * as Immutable from 'immutable';
import userEvent from '@testing-library/user-event';
import URI from 'urijs';

import type { TimeRange } from 'views/logic/queries/Query';
import { createElasticsearchQueryString } from 'views/logic/queries/Query';
import type { QueryString } from 'views/logic/queries/types';
import type { FiltersType, SearchFilter } from 'views/types';
import Store from 'logic/local-storage/Store';

import ReplaySearchButton from './ReplaySearchButton';

type OptionalOverrides = {
streams?: Array<string>;
query?: QueryString;
timerange?: TimeRange;
filters?: FiltersType;
};

const filter = (queryString: string): SearchFilter => ({
type: 'inlineQueryString',
queryString,
disabled: false,
negation: false,
});

const findReplayButton = async () =>
asElement(await screen.findByRole('link', { name: /replay search/i }), HTMLAnchorElement);

describe('ReplaySearchButton', () => {
it('renders play button', async () => {
render(<ReplaySearchButton />);
Expand All @@ -37,10 +53,17 @@ describe('ReplaySearchButton', () => {
});

describe('generates link', () => {
const renderWithContext = async ({ query, timerange, streams }: OptionalOverrides = {}) => {
render(<ReplaySearchButton queryString={query?.query_string} timerange={timerange} streams={streams} />);
const renderWithContext = async ({ query, timerange, streams, filters }: OptionalOverrides = {}) => {
render(
<ReplaySearchButton
queryString={query?.query_string}
timerange={timerange}
streams={streams}
filters={filters}
/>,
);

return asElement(await screen.findByRole('link', { name: /replay search/i }), HTMLAnchorElement);
return findReplayButton();
};

it('including query string', async () => {
Expand Down Expand Up @@ -68,5 +91,43 @@ describe('ReplaySearchButton', () => {

expect(button.href).toContain('streams=stream1%2Cstream2%2Csomeotherstream');
});

it('adds a session-id when search filters are configured', async () => {
const button = await renderWithContext({ filters: Immutable.List([filter('foo:bar')]) });

expect(button.href).toMatch(/session-id=replay-search-/);
});

it('omits the session-id when neither parameters nor filters are configured', async () => {
const button = await renderWithContext();

expect(button.href).not.toContain('session-id=');
});

it('omits the session-id when filters are empty', async () => {
const button = await renderWithContext({ filters: Immutable.List() });

expect(button.href).not.toContain('session-id=');
});
});

describe('on click', () => {
it('persists search filters to local storage so the replayed search can consume them', async () => {
const filters = Immutable.List([filter('source:my-host'), filter('level:ERROR')]);
render(<ReplaySearchButton filters={filters} />);
const button = await findReplayButton();
const sessionId = new URI(button.href).search(true)['session-id'] as string | undefined;

expect(sessionId).toMatch(/^replay-search-/);

await userEvent.click(button);

const raw = Store.get<string>(sessionId);

expect(raw).toBeDefined();
expect(JSON.parse(raw!)).toEqual({
filters: [filter('source:my-host'), filter('level:ERROR')],
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { createElasticsearchQueryString } from 'views/logic/queries/Query';
import generateId from 'logic/generateId';
import type Parameter from 'views/logic/parameters/Parameter';
import type { ParameterBindings } from 'views/logic/search/SearchExecutionState';
import type { FiltersType } from 'views/types';

const NeutralLink = styled.a`
display: inline-flex;
Expand Down Expand Up @@ -57,6 +58,7 @@ const buildSearchLink = (
streams: Array<string>,
streamCategories: Array<string>,
parameters?: Immutable.Set<Parameter>,
filters?: FiltersType,
) => {
let searchLink = SearchLink.builder()
.query(createElasticsearchQueryString(queryString))
Expand All @@ -66,7 +68,7 @@ const buildSearchLink = (
.build()
.toURL();

if (parameters?.size) {
if (parameters?.size || filters?.size) {
searchLink = new URI(searchLink).setSearch('session-id', sessionId).toString();
}

Expand Down Expand Up @@ -117,6 +119,7 @@ type Props = {
streams?: string[] | undefined;
streamCategories?: string[] | undefined;
parameters?: Immutable.Set<Parameter>;
filters?: FiltersType;
children?: React.ReactNode;
parameterBindings?: ParameterBindings;
};
Expand All @@ -127,17 +130,33 @@ const ReplaySearchButton = ({
streams = undefined,
streamCategories = undefined,
parameters = undefined,
filters = undefined,
children = undefined,
parameterBindings = undefined,
}: Props) => {
const sessionId = useMemo(() => `replay-search-${generateId()}`, []);
const searchLink = buildSearchLink(sessionId, timerange, queryString, streams, streamCategories, parameters);
const searchLink = buildSearchLink(
sessionId,
timerange,
queryString,
streams,
streamCategories,
parameters,
filters,
);

const onReplaySearch = useCallback(() => {
if (parameters?.size) {
Store.set(sessionId, JSON.stringify({ parameters, parameterBindings }));
if (parameters?.size || filters?.size) {
Store.set(
sessionId,
JSON.stringify({
parameters,
parameterBindings,
filters: filters?.toArray() ?? [],
}),
);
}
}, [sessionId, parameters, parameterBindings]);
}, [sessionId, parameters, parameterBindings, filters]);

return (
<ReplaySearchButtonComponent searchLink={searchLink} onClick={onReplaySearch}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ const WidgetActionsMenu = ({ isFocused, onPositionsChange, position, title, togg
streamCategories={streamCategories}
parameterBindings={parameterBindings}
parameters={parameters}
filters={widget.filters}
/>
</IfDashboard>
<ExtraMenuWidgetActions widget={widget} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { DEFAULT_TIMERANGE } from 'views/Constants';
import type { TimeRange, QueryId, FilterType } from 'views/logic/queries/Query';
import Query, { createElasticsearchQueryString, newFiltersForQuery } from 'views/logic/queries/Query';
import generateId from 'logic/generateId';
import type { SearchFilter } from 'components/event-definitions/event-definitions-types';
import type { SearchFilter } from 'views/types';
import type { QueryString } from 'views/logic/queries/types';

export default (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,35 @@
import ViewGenerator from 'views/logic/views/ViewGenerator';
import type Parameter from 'views/logic/parameters/Parameter';
import type { QueryString } from 'views/logic/queries/types';
import type { SearchFilter } from 'views/types';

type Props = {
streamId?: string | string[];
streamCategory?: string | string[];
timeRange?: TimeRange;
queryString?: QueryString;
parameters?: Array<Parameter>;
searchFilters?: Array<SearchFilter>;
};

type Deps = Array<Props[keyof Props]> | [];
const useCreateSavedSearch = (
{ streamId, streamCategory, timeRange, queryString, parameters }: Props,
{ streamId, streamCategory, timeRange, queryString, parameters, searchFilters }: Props,
deps: Deps = [],
) =>
useMemo(
() => ViewGenerator({ type: View.Type.Search, streamId, streamCategory, timeRange, queryString, parameters }),
() =>
ViewGenerator({
type: View.Type.Search,
streamId,
streamCategory,
timeRange,
queryString,
parameters,
searchFilters,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
deps,

Check warning on line 52 in graylog2-web-interface/src/views/logic/views/UseCreateSavedSearch.ts

View workflow job for this annotation

GitHub Actions / Reviewbot

Error: Expected the dependency list for useMemo to be an array literal Expected the dependency list for useMemo to be an array literal. /home/runner/work/graylog2-server/graylog2-server/graylog2-web-interface/src/views/logic/views/UseCreateSavedSearch.ts:52:5 50 | }), 51 | // eslint-disable-next-line react-hooks/exhaustive-deps > 52 | deps, | ^^^^ Expected the dependency list for useMemo to be an array literal 53 | ); 54 | 55 | export default useCreateSavedSearch;

No further rule information available.
);

export default useCreateSavedSearch;
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { TimeRange } from 'views/logic/queries/Query';
import UpdateSearchForWidgets from 'views/logic/views/UpdateSearchForWidgets';
import type Parameter from 'views/logic/parameters/Parameter';
import type { QueryString } from 'views/logic/queries/types';
import type { SearchFilter } from 'views/types';

import View from './View';
import ViewStateGenerator from './ViewStateGenerator';
Expand All @@ -33,15 +34,17 @@ export default async ({
timeRange,
queryString,
parameters,
searchFilters,
}: {
type: ViewType;
streamId?: string | string[];
streamCategory?: string | string[];
timeRange?: TimeRange;
queryString?: QueryString;
parameters?: Array<Parameter>;
searchFilters?: Array<SearchFilter>;
}) => {
const query = QueryGenerator(streamId, streamCategory, undefined, timeRange, queryString);
const query = QueryGenerator(streamId, streamCategory, undefined, timeRange, queryString, searchFilters);
const search = Search.create().toBuilder().queries([query]).parameters(parameters).build();
const viewState = await ViewStateGenerator(type, streamId);

Expand Down
25 changes: 15 additions & 10 deletions graylog2-web-interface/src/views/pages/NewSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ import Parameter from 'views/logic/parameters/Parameter';
import type { ParameterBindingJsonRepresentation } from 'views/logic/parameters/ParameterBinding';
import ParameterBinding from 'views/logic/parameters/ParameterBinding';
import SearchExecutionState from 'views/logic/search/SearchExecutionState';
import type { SearchFilter } from 'views/types';

import SearchPage from './SearchPage';

const useParametersFromStore = () => {
const useReplayStateFromStore = () => {
const { 'session-id': sessionId } = useQuery();

return useMemo(() => {
Expand All @@ -40,31 +41,35 @@ const useParametersFromStore = () => {

const searchData = searchDataFromStore ? JSON.parse(searchDataFromStore) : undefined;

if (searchData?.parameters) {
if (searchData) {
return {
parameters: searchData.parameters.map((param) => Parameter.fromJSON(param)),
parameterBindings: Immutable.Map<string, ParameterBinding>(
Object.entries<ParameterBindingJsonRepresentation>(searchData.parameterBindings ?? {}).map(
([paramName, paramBinding]) => [paramName, ParameterBinding.fromJSON(paramBinding)],
),
),
parameters: searchData.parameters?.map((param) => Parameter.fromJSON(param)),
parameterBindings: searchData.parameterBindings
? Immutable.Map<string, ParameterBinding>(
Object.entries<ParameterBindingJsonRepresentation>(searchData.parameterBindings).map(
([paramName, paramBinding]) => [paramName, ParameterBinding.fromJSON(paramBinding)],
),
)
: undefined,
searchFilters: searchData.filters as SearchFilter[] | undefined,
};
}
}

return { parameters: undefined, parameterBindings: undefined };
return { parameters: undefined, parameterBindings: undefined, searchFilters: undefined };
}, [sessionId]);
};

const NewSearchPage = () => {
const { parameters, parameterBindings } = useParametersFromStore();
const { parameters, parameterBindings, searchFilters } = useReplayStateFromStore();
const { timeRange, queryString, streams, streamCategories } = useSearchURLQueryParams();
const viewPromise = useCreateSavedSearch({
streamId: streams,
streamCategory: streamCategories,
timeRange,
queryString,
parameters,
searchFilters,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL: toHaveBeenCalledWith (and other equality-checking assertions) do not consider {} to be different from { foo: undefined }. The idea is that a consumer of it would not experience a different behavior when accessing that subkey (i.e. argument.foo would be undefined for both {} and { foo: undefined }). So this comment from Copilot is not false, but not relevant/not an issue for the current change.

});
const view = useCreateSearch(viewPromise);

Expand Down
Loading