Skip to content

Commit

Permalink
Allow custom filter components for paginated data table. (#21216)
Browse files Browse the repository at this point in the history
* Allow custom filter components for paginated data table.

* Adding test cases for custom filter input component.

* Adding license headers.
  • Loading branch information
dennisoelkers authored Dec 19, 2024
1 parent 7664ddf commit 7bc444d
Show file tree
Hide file tree
Showing 6 changed files with 201 additions and 47 deletions.
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

0 comments on commit 7bc444d

Please sign in to comment.