diff --git a/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.test.tsx b/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.test.tsx
index d185e96945e8..fd9c55ffac16 100644
--- a/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.test.tsx
+++ b/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.test.tsx
@@ -19,11 +19,13 @@ import { render, screen, waitFor, within } from 'wrappedTestingLibrary';
import userEvent from '@testing-library/user-event';
import { OrderedMap } from 'immutable';
import type { Optional } from 'utility-types';
+import { Formik, Form } from 'formik';
-import type { Attributes } from 'stores/PaginationTypes';
+import type { Attributes, FilterComponentProps } from 'stores/PaginationTypes';
import { asMock } from 'helpers/mocking';
import useFilterValueSuggestions from 'components/common/EntityFilters/hooks/useFilterValueSuggestions';
import useFiltersWithTitle from 'components/common/EntityFilters/hooks/useFiltersWithTitle';
+import { ModalSubmit, FormikInput } from 'components/common';
import OriginalEntityFilters from './EntityFilters';
@@ -36,10 +38,31 @@ jest.mock('logic/generateId', () => jest.fn(() => 'filter-id'));
jest.mock('components/common/EntityFilters/hooks/useFilterValueSuggestions');
jest.mock('components/common/EntityFilters/hooks/useFiltersWithTitle');
+const CustomFilterInput = ({ filter, onSubmit }: FilterComponentProps) => (
+
+ onSubmit({ title: value, value })}>
+ {({ isValid }) => (
+
+ )}
+
+
+);
+
describe('', () => {
const onChangeFiltersWithTitle = jest.fn();
const setUrlQueryFilters = jest.fn();
- const attributes = [
+ const attributes: Attributes = [
{ id: 'title', title: 'Title', sortable: true },
{ id: 'description', title: 'Description', sortable: true },
{
@@ -88,7 +111,14 @@ describe('', () => {
title: 'Generic Attribute',
type: 'STRING',
},
- ] as Attributes;
+ {
+ id: 'customComponent',
+ filterable: true,
+ title: 'Custom Component Attribute',
+ type: 'STRING',
+ filter_component: CustomFilterInput,
+ },
+ ];
const EntityFilters = (props: Optional, 'setUrlQueryFilters' | 'attributes'>) => (
@@ -424,6 +454,52 @@ describe('', () => {
});
});
+ describe('custom component attribute', () => {
+ it('provides text input to create filter', async () => {
+ render(
+ ,
+ );
+
+ userEvent.click(await screen.findByRole('button', { name: /create filter/i }));
+
+ userEvent.click(await screen.findByRole('menuitem', { name: /custom component/i }));
+
+ const filterInput = await screen.findByPlaceholderText('My custom input');
+ userEvent.type(filterInput, 'foo');
+
+ const form = await screen.findByTestId('custom-component-form');
+ userEvent.click(await within(form).findByRole('button', { name: /create filter/i }));
+
+ await waitFor(() => {
+ expect(setUrlQueryFilters).toHaveBeenCalledWith(OrderedMap({ customComponent: ['foo'] }));
+ });
+ });
+
+ it('allows changing filter', async () => {
+ asMock(useFiltersWithTitle).mockReturnValue({
+ data: OrderedMap({ customComponent: [{ title: 'foo', value: 'foo' }] }),
+ onChange: onChangeFiltersWithTitle,
+ isInitialLoading: false,
+ });
+
+ render(
+ ,
+ );
+
+ userEvent.click(await screen.findByText('foo'));
+
+ const filterInput = await screen.findByPlaceholderText('My custom input');
+ userEvent.type(filterInput, '{selectall}bar');
+
+ const form = await screen.findByTestId('custom-component-form');
+ userEvent.click(await within(form).findByRole('button', { name: /update filter/i }));
+
+ await waitFor(() => {
+ expect(setUrlQueryFilters).toHaveBeenCalledWith(OrderedMap({ customComponent: ['bar'] }));
+ });
+ });
+ });
+
it('should display active filters', async () => {
asMock(useFiltersWithTitle).mockReturnValue({
data: OrderedMap({
diff --git a/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/FilterConfiguration.tsx b/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/FilterConfiguration.tsx
index 7919cea70546..2a382c74954e 100644
--- a/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/FilterConfiguration.tsx
+++ b/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/FilterConfiguration.tsx
@@ -16,28 +16,31 @@
*/
import * as React from 'react';
-import type { Attribute } from 'stores/PaginationTypes';
-import type { Filters, Filter } from 'components/common/EntityFilters/types';
+import type { FilterComponentProps } from 'stores/PaginationTypes';
import { MenuItem } from 'components/bootstrap';
import {
isAttributeWithFilterOptions,
- isAttributeWithRelatedCollection, isDateAttribute,
+ isAttributeWithRelatedCollection, isDateAttribute, isCustomComponentFilter,
} from 'components/common/EntityFilters/helpers/AttributeIdentification';
-import GenericFilterInput from 'components/common/EntityFilters/FilterConfiguration/GenericFilterInput';
+import SuggestionsListFilter from './SuggestionsListFilter';
+import GenericFilterInput from './GenericFilterInput';
import StaticOptionsList from './StaticOptionsList';
-import SuggestionsList from './SuggestionsList';
import DateRangeForm from './DateRangeForm';
-type Props = {
- attribute: Attribute,
- filter?: Filter,
- filterValueRenderer: (value: Filter['value'], title: string) => React.ReactNode | undefined,
- onSubmit: (filter: { title: string, value: string }, closeDropdown?: boolean) => void,
- allActiveFilters: Filters | undefined,
-}
+const FilterComponent = ({ allActiveFilters, attribute, filter = undefined, filterValueRenderer, onSubmit }: FilterComponentProps) => {
+ if (isCustomComponentFilter(attribute)) {
+ const CustomFilterComponent = attribute.filter_component;
+
+ return (
+
+ );
+ }
-const FilterComponent = ({ allActiveFilters, attribute, filter, filterValueRenderer, onSubmit }: Pick) => {
if (isAttributeWithFilterOptions(attribute)) {
return (
+
);
}
@@ -73,7 +76,7 @@ export const FilterConfiguration = ({
filter = undefined,
filterValueRenderer,
onSubmit,
-}: Props) => (
+}: FilterComponentProps) => (
<>
.
*/
-import React, { useState, useCallback } from 'react';
+import React, { useCallback } from 'react';
import debounce from 'lodash/debounce';
import styled, { css } from 'styled-components';
@@ -23,15 +23,8 @@ import type { Attribute } from 'stores/PaginationTypes';
import type { Filters, Filter } from 'components/common/EntityFilters/types';
import { PaginatedList, NoSearchResult } from 'components/common';
import useIsKeyHeld from 'hooks/useIsKeyHeld';
-import useFilterValueSuggestions from 'components/common/EntityFilters/hooks/useFilterValueSuggestions';
import Spinner from 'components/common/Spinner';
-const DEFAULT_SEARCH_PARAMS = {
- query: '',
- pageSize: 10,
- page: 1,
-};
-
const Container = styled.div(({ theme }) => css`
color: ${theme.colors.global.textDefault};
padding: 3px 10px;
@@ -50,25 +43,40 @@ const Hint = styled.div(({ theme }) => css`
font-size: ${theme.fonts.size.small};
`);
+type SearchParams = {
+ query: string,
+ page: number,
+ pageSize: number,
+}
+
+type Suggestion = {
+ id: string,
+ value: string,
+}
+
type Props = {
allActiveFilters: Filters | undefined,
attribute: Attribute,
filter: Filter | undefined
filterValueRenderer: (value: unknown, title: string) => React.ReactNode | undefined,
onSubmit: (filter: { title: string, value: string }, closeDropdown: boolean) => void,
+ suggestions: Array,
+ isLoading: boolean,
+ total: number,
+ page: number,
+ pageSize: number,
+ setSearchParams: (updater: (current: SearchParams) => SearchParams) => void,
}
-const SuggestionsList = ({ attribute, filterValueRenderer, onSubmit, allActiveFilters, filter }: Props) => {
+const SuggestionsList = ({ attribute, filterValueRenderer, onSubmit, allActiveFilters, filter, isLoading, suggestions, total, setSearchParams, page, pageSize }: Props) => {
const isShiftHeld = useIsKeyHeld('Shift');
- const [searchParams, setSearchParams] = useState(DEFAULT_SEARCH_PARAMS);
- const { data: { pagination, suggestions }, isInitialLoading } = useFilterValueSuggestions(attribute.id, attribute.related_collection, searchParams, attribute.related_property);
const handleSearchChange = useCallback((newSearchQuery: string) => {
- setSearchParams((cur) => ({ ...cur, page: DEFAULT_SEARCH_PARAMS.page, query: newSearchQuery }));
+ setSearchParams((cur) => ({ ...cur, page: 1, query: newSearchQuery }));
}, [setSearchParams]);
- const handlePaginationChange = useCallback((page: number) => {
- setSearchParams((cur) => ({ ...cur, page }));
- }, []);
+ const handlePaginationChange = useCallback((newPage: number) => {
+ setSearchParams((cur) => ({ ...cur, page: newPage }));
+ }, [setSearchParams]);
const debounceOnSearch = debounce((value: string) => handleSearchChange(value), 1000);
@@ -79,15 +87,15 @@ const SuggestionsList = ({ attribute, filterValueRenderer, onSubmit, allActiveFi
formGroupClassName=""
placeholder={`Search for ${attribute.title.toLowerCase()}`}
onChange={({ target: { value } }) => debounceOnSearch(value)} />
- {isInitialLoading && }
+ {isLoading && }
{!!suggestions?.length && (
diff --git a/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/SuggestionsListFilter.tsx b/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/SuggestionsListFilter.tsx
new file mode 100644
index 000000000000..eb3e70961d6a
--- /dev/null
+++ b/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/SuggestionsListFilter.tsx
@@ -0,0 +1,59 @@
+/*
+ * 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 { useState } from 'react';
+
+import type { Filters, Filter } from 'components/common/EntityFilters/types';
+import type { Attribute } from 'stores/PaginationTypes';
+import useFilterValueSuggestions from 'components/common/EntityFilters/hooks/useFilterValueSuggestions';
+
+import SuggestionsList from './SuggestionsList';
+
+type Props = {
+ allActiveFilters: Filters | undefined,
+ attribute: Attribute,
+ filter: Filter | undefined
+ filterValueRenderer: (value: unknown, title: string) => React.ReactNode | undefined,
+ onSubmit: (filter: { title: string, value: string }, closeDropdown: boolean) => void,
+}
+
+const DEFAULT_SEARCH_PARAMS = {
+ query: '',
+ pageSize: 10,
+ page: 1,
+};
+
+const SuggestionsListFilter = ({ attribute, filterValueRenderer, onSubmit, allActiveFilters, filter }: Props) => {
+ const [searchParams, setSearchParams] = useState(DEFAULT_SEARCH_PARAMS);
+ const { data: { pagination, suggestions }, isInitialLoading } = useFilterValueSuggestions(attribute.id, attribute.related_collection, searchParams, attribute.related_property);
+
+ return (
+
+ );
+};
+
+export default SuggestionsListFilter;
diff --git a/graylog2-web-interface/src/components/common/EntityFilters/helpers/AttributeIdentification.ts b/graylog2-web-interface/src/components/common/EntityFilters/helpers/AttributeIdentification.ts
index 8c27e129df55..18564b17b799 100644
--- a/graylog2-web-interface/src/components/common/EntityFilters/helpers/AttributeIdentification.ts
+++ b/graylog2-web-interface/src/components/common/EntityFilters/helpers/AttributeIdentification.ts
@@ -19,3 +19,4 @@ import type { Attribute } from 'stores/PaginationTypes';
export const isDateAttribute = ({ type }: Attribute) => type === 'DATE';
export const isAttributeWithFilterOptions = ({ filter_options }: Attribute) => !!filter_options?.length;
export const isAttributeWithRelatedCollection = ({ related_collection }: Attribute) => !!related_collection;
+export const isCustomComponentFilter = ({ filter_component }: Attribute) => !!filter_component;
diff --git a/graylog2-web-interface/src/stores/PaginationTypes.ts b/graylog2-web-interface/src/stores/PaginationTypes.ts
index 4b9b508440c2..f1ff74af028d 100644
--- a/graylog2-web-interface/src/stores/PaginationTypes.ts
+++ b/graylog2-web-interface/src/stores/PaginationTypes.ts
@@ -15,10 +15,9 @@
* .
*/
import type * as Immutable from 'immutable';
-import type { $PropertyType } from 'utility-types';
import type { AdditionalQueries } from 'util/PaginationURL';
-import type { UrlQueryFilters } from 'components/common/EntityFilters/types';
+import type { UrlQueryFilters, Filter, Filters } from 'components/common/EntityFilters/types';
export type PaginatedResponseType = {
count: number,
@@ -29,9 +28,9 @@ export type PaginatedResponseType = {
};
export type PaginatedListJSON = {
- page: $PropertyType,
- per_page: $PropertyType,
- query: $PropertyType,
+ page: Pagination['page'],
+ per_page: Pagination['perPage'],
+ query: Pagination['query'],
total: number,
count: number,
};
@@ -72,6 +71,13 @@ export type SearchParams = {
filters?: UrlQueryFilters
}
+export type FilterComponentProps = {
+ attribute: Attribute,
+ filter?: Filter,
+ filterValueRenderer: (value: Filter['value'], title: string) => React.ReactNode | undefined,
+ onSubmit: (filter: { title: string, value: string }, closeDropdown?: boolean) => void,
+ allActiveFilters: Filters | undefined,
+}
export type Attribute = {
id: string,
title: string,
@@ -80,7 +86,8 @@ export type Attribute = {
hidden?: boolean,
searchable?: boolean,
filterable?: true,
- filter_options?: Array<{ value: string, title: string }>
+ filter_options?: Array<{ value: string, title: string }>,
+ filter_component?: React.ComponentType,
related_collection?: string,
related_property?: string,
permissions?: Array,