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) => ( <> {filter ? 'Edit' : 'Create'} {attribute.title.toLowerCase()} filter . */ -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,