Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow custom filter components for paginated data table. #21216

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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) => (
<div data-testid="custom-component-form">
<Formik initialValues={{ value: filter?.value }} onSubmit={({ value }) => onSubmit({ title: value, value })}>
{({ isValid }) => (
<Form>
<FormikInput type="text"
id="custom-input"
name="value"
formGroupClassName=""
required
placeholder="My custom input" />
<ModalSubmit submitButtonText={`${filter ? 'Update' : 'Create'} filter`}
bsSize="small"
disabledSubmit={!isValid}
displayCancel={false} />
</Form>
)}
</Formik>
</div>
);

describe('<EntityFilters />', () => {
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 },
{
Expand Down Expand Up @@ -88,7 +111,14 @@ describe('<EntityFilters />', () => {
title: 'Generic Attribute',
type: 'STRING',
},
] as Attributes;
{
id: 'customComponent',
filterable: true,
title: 'Custom Component Attribute',
type: 'STRING',
filter_component: CustomFilterInput,
},
];

const EntityFilters = (props: Optional<React.ComponentProps<typeof OriginalEntityFilters>, 'setUrlQueryFilters' | 'attributes'>) => (
<OriginalEntityFilters setUrlQueryFilters={setUrlQueryFilters} attributes={attributes} {...props} />
Expand Down Expand Up @@ -424,6 +454,52 @@ describe('<EntityFilters />', () => {
});
});

describe('custom component attribute', () => {
it('provides text input to create filter', async () => {
render(
<EntityFilters urlQueryFilters={OrderedMap()} />,
);

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(
<EntityFilters urlQueryFilters={OrderedMap()} />,
);

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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<CustomFilterComponent attribute={attribute}
filterValueRenderer={filterValueRenderer}
onSubmit={onSubmit}
allActiveFilters={allActiveFilters}
filter={filter} />
);
}

const FilterComponent = ({ allActiveFilters, attribute, filter, filterValueRenderer, onSubmit }: Pick<Props, 'allActiveFilters' | 'attribute' | 'filter' | 'filterValueRenderer' | 'onSubmit'>) => {
if (isAttributeWithFilterOptions(attribute)) {
return (
<StaticOptionsList attribute={attribute}
Expand All @@ -49,11 +52,11 @@ const FilterComponent = ({ allActiveFilters, attribute, filter, filterValueRende

if (isAttributeWithRelatedCollection(attribute)) {
return (
<SuggestionsList attribute={attribute}
filterValueRenderer={filterValueRenderer}
onSubmit={onSubmit}
allActiveFilters={allActiveFilters}
filter={filter} />
<SuggestionsListFilter attribute={attribute}
filterValueRenderer={filterValueRenderer}
onSubmit={onSubmit}
allActiveFilters={allActiveFilters}
filter={filter} />
);
}

Expand All @@ -73,7 +76,7 @@ export const FilterConfiguration = ({
filter = undefined,
filterValueRenderer,
onSubmit,
}: Props) => (
}: FilterComponentProps) => (
<>
<MenuItem header>{filter ? 'Edit' : 'Create'} {attribute.title.toLowerCase()} filter</MenuItem>
<FilterComponent attribute={attribute}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import React, { useState, useCallback } from 'react';
import React, { useCallback } from 'react';
import debounce from 'lodash/debounce';
import styled, { css } from 'styled-components';

Expand All @@ -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;
Expand All @@ -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<Suggestion>,
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);

Expand All @@ -79,15 +87,15 @@ const SuggestionsList = ({ attribute, filterValueRenderer, onSubmit, allActiveFi
formGroupClassName=""
placeholder={`Search for ${attribute.title.toLowerCase()}`}
onChange={({ target: { value } }) => debounceOnSearch(value)} />
{isInitialLoading && <Spinner />}
{isLoading && <Spinner />}

{!!suggestions?.length && (
<PaginatedList showPageSizeSelect={false}
totalItems={pagination.total}
totalItems={total}
hidePreviousAndNextPageLinks
hideFirstAndLastPageLinks
activePage={searchParams.page}
pageSize={searchParams.pageSize}
activePage={page}
pageSize={pageSize}
onChange={handlePaginationChange}
useQueryParameter={false}>
<StyledListGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
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 (
<SuggestionsList isLoading={isInitialLoading}
total={pagination.total}
pageSize={searchParams.pageSize}
page={searchParams.page}
setSearchParams={setSearchParams}
allActiveFilters={allActiveFilters}
attribute={attribute}
filter={filter}
filterValueRenderer={filterValueRenderer}
onSubmit={onSubmit}
suggestions={suggestions} />
);
};

export default SuggestionsListFilter;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
19 changes: 13 additions & 6 deletions graylog2-web-interface/src/stores/PaginationTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
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,
Expand All @@ -29,9 +28,9 @@ export type PaginatedResponseType = {
};

export type PaginatedListJSON = {
page: $PropertyType<Pagination, 'page'>,
per_page: $PropertyType<Pagination, 'perPage'>,
query: $PropertyType<Pagination, 'query'>,
page: Pagination['page'],
per_page: Pagination['perPage'],
query: Pagination['query'],
total: number,
count: number,
};
Expand Down Expand Up @@ -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,
Expand All @@ -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<FilterComponentProps>,
related_collection?: string,
related_property?: string,
permissions?: Array<string>,
Expand Down
Loading