Refactor Filters and Search (#119)

This commit is contained in:
Charles Bochet
2023-05-17 13:25:33 +02:00
committed by GitHub
parent 96e3f2c7ea
commit 499752ed6b
25 changed files with 466 additions and 804 deletions

View File

@ -1,34 +1,43 @@
import { ChangeEvent, useCallback, useState } from 'react';
import DropdownButton from './DropdownButton';
import { FilterOperandType, FilterType, SelectedFilterType } from './interface';
import {
FilterConfigType,
FilterOperandType,
SearchConfigType,
SearchableType,
SelectedFilterType,
} from './interface';
type OwnProps<FilterProperties> = {
type OwnProps = {
isFilterSelected: boolean;
availableFilters: FilterType<FilterProperties>[];
availableFilters: FilterConfigType[];
filterSearchResults?: {
results: { displayValue: string; value: any }[];
results: {
render: (value: SearchableType) => string;
value: SearchableType;
}[];
loading: boolean;
};
onFilterSelect: (filter: SelectedFilterType<FilterProperties>) => void;
onFilterSelect: (filter: SelectedFilterType) => void;
onFilterSearch: (
filter: FilterType<FilterProperties> | null,
filter: SearchConfigType<any> | null,
searchValue: string,
) => void;
};
export function FilterDropdownButton<FilterProperties>({
export function FilterDropdownButton({
availableFilters,
filterSearchResults,
onFilterSearch,
onFilterSelect,
isFilterSelected,
}: OwnProps<FilterProperties>) {
}: OwnProps) {
const [isUnfolded, setIsUnfolded] = useState(false);
const [isOptionUnfolded, setIsOptionUnfolded] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<
FilterType<FilterProperties> | undefined
FilterConfigType | undefined
>(undefined);
const [selectedFilterOperand, setSelectedFilterOperand] = useState<
@ -57,10 +66,8 @@ export function FilterDropdownButton<FilterProperties>({
);
const renderSearchResults = (
filterSearchResults: NonNullable<
OwnProps<FilterProperties>['filterSearchResults']
>,
selectedFilter: FilterType<FilterProperties>,
filterSearchResults: NonNullable<OwnProps['filterSearchResults']>,
selectedFilter: FilterConfigType,
selectedFilterOperand: FilterOperandType,
) => {
if (filterSearchResults.loading) {
@ -70,32 +77,24 @@ export function FilterDropdownButton<FilterProperties>({
</DropdownButton.StyledDropdownItem>
);
}
return filterSearchResults.results.map((value, index) => (
return filterSearchResults.results.map((result, index) => (
<DropdownButton.StyledDropdownItem
key={`fields-value-${index}`}
onClick={() => {
onFilterSelect({
...selectedFilter,
key: value.displayValue,
operand: selectedFilterOperand,
searchQuery: selectedFilter.searchQuery,
searchTemplate: selectedFilter.searchTemplate,
whereTemplate: selectedFilter.whereTemplate,
key: selectedFilter.key,
label: selectedFilter.label,
value: value.displayValue,
value: result.value,
displayValue: result.render(result.value),
icon: selectedFilter.icon,
where:
selectedFilter.whereTemplate(
selectedFilterOperand,
value.value,
) || ({} as FilterProperties),
searchResultMapper: selectedFilter.searchResultMapper,
operand: selectedFilterOperand,
});
setIsUnfolded(false);
setSelectedFilter(undefined);
}}
>
{value.displayValue}
{result.render(result.value)}
</DropdownButton.StyledDropdownItem>
));
};
@ -106,7 +105,7 @@ export function FilterDropdownButton<FilterProperties>({
onClick={() => {
setSelectedFilter(filter);
setSelectedFilterOperand(filter.operands[0]);
onFilterSearch(filter, '');
onFilterSearch(filter.searchConfig, '');
}}
>
<DropdownButton.StyledIcon>{filter.icon}</DropdownButton.StyledIcon>
@ -115,7 +114,7 @@ export function FilterDropdownButton<FilterProperties>({
));
function renderFilterDropdown(
selectedFilter: FilterType<FilterProperties>,
selectedFilter: FilterConfigType,
selectedFilterOperand: FilterOperandType,
) {
return (
@ -133,7 +132,7 @@ export function FilterDropdownButton<FilterProperties>({
type="text"
placeholder={selectedFilter.label}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
onFilterSearch(selectedFilter, event.target.value)
onFilterSearch(selectedFilter.searchConfig, event.target.value)
}
/>
</DropdownButton.StyledSearchField>

View File

@ -3,13 +3,11 @@ import SortOrFilterChip from './SortOrFilterChip';
import { FaArrowDown, FaArrowUp } from 'react-icons/fa';
import { SelectedFilterType, SelectedSortType } from './interface';
type OwnProps<SortField, FilterProperties> = {
type OwnProps<SortField> = {
sorts: Array<SelectedSortType<SortField>>;
onRemoveSort: (sortId: SelectedSortType<SortField>['key']) => void;
filters: Array<SelectedFilterType<FilterProperties>>;
onRemoveFilter: (
filterId: SelectedFilterType<FilterProperties>['key'],
) => void;
filters: Array<SelectedFilterType>;
onRemoveFilter: (filterId: SelectedFilterType['key']) => void;
onCancelClick: () => void;
};
@ -42,13 +40,13 @@ const StyledCancelButton = styled.button`
}
`;
function SortAndFilterBar<SortField, FilterProperties>({
function SortAndFilterBar<SortField>({
sorts,
onRemoveSort,
filters,
onRemoveFilter,
onCancelClick,
}: OwnProps<SortField, FilterProperties>) {
}: OwnProps<SortField>) {
return (
<StyledBar>
{sorts.map((sort) => {
@ -67,7 +65,7 @@ function SortAndFilterBar<SortField, FilterProperties>({
<SortOrFilterChip
key={filter.key}
labelKey={filter.label}
labelValue={`${filter.operand.label} ${filter.value}`}
labelValue={`${filter.operand.label} ${filter.displayValue}`}
id={filter.key}
icon={filter.icon}
onRemove={() => onRemoveFilter(filter.key)}

View File

@ -1,6 +1,8 @@
import styled from '@emotion/styled';
import {
FilterType,
FilterConfigType,
SearchConfigType,
SearchableType,
SelectedFilterType,
SelectedSortType,
SortType,
@ -10,21 +12,22 @@ import { SortDropdownButton } from './SortDropdownButton';
import { FilterDropdownButton } from './FilterDropdownButton';
import SortAndFilterBar from './SortAndFilterBar';
type OwnProps<SortField, FilterProperties> = {
type OwnProps<SortField> = {
viewName: string;
viewIcon?: ReactNode;
availableSorts?: Array<SortType<SortField>>;
availableFilters?: FilterType<FilterProperties>[];
availableFilters?: FilterConfigType[];
filterSearchResults?: {
results: { displayValue: string; value: any }[];
results: {
render: (value: SearchableType) => string;
value: SearchableType;
}[];
loading: boolean;
};
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onFiltersUpdate?: (
sorts: Array<SelectedFilterType<FilterProperties>>,
) => void;
onFiltersUpdate?: (sorts: Array<SelectedFilterType>) => void;
onFilterSearch?: (
filter: FilterType<FilterProperties> | null,
filter: SearchConfigType<any> | null,
searchValue: string,
) => void;
};
@ -65,7 +68,7 @@ const StyledFilters = styled.div`
margin-right: ${(props) => props.theme.spacing(2)};
`;
function TableHeader<SortField, FilterProperties>({
function TableHeader<SortField>({
viewName,
viewIcon,
availableSorts,
@ -74,13 +77,11 @@ function TableHeader<SortField, FilterProperties>({
onSortsUpdate,
onFiltersUpdate,
onFilterSearch,
}: OwnProps<SortField, FilterProperties>) {
}: OwnProps<SortField>) {
const [sorts, innerSetSorts] = useState<Array<SelectedSortType<SortField>>>(
[],
);
const [filters, innerSetFilters] = useState<
Array<SelectedFilterType<FilterProperties>>
>([]);
const [filters, innerSetFilters] = useState<Array<SelectedFilterType>>([]);
const sortSelect = useCallback(
(newSort: SelectedSortType<SortField>) => {
@ -101,7 +102,7 @@ function TableHeader<SortField, FilterProperties>({
);
const filterSelect = useCallback(
(filter: SelectedFilterType<FilterProperties>) => {
(filter: SelectedFilterType) => {
const newFilters = updateSortOrFilterByKey(filters, filter);
innerSetFilters(newFilters);
@ -111,7 +112,7 @@ function TableHeader<SortField, FilterProperties>({
);
const filterUnselect = useCallback(
(filterId: SelectedFilterType<FilterProperties>['key']) => {
(filterId: SelectedFilterType['key']) => {
const newFilters = filters.filter((filter) => filter.key !== filterId);
innerSetFilters(newFilters);
onFiltersUpdate && onFiltersUpdate(newFilters);
@ -120,7 +121,7 @@ function TableHeader<SortField, FilterProperties>({
);
const filterSearch = useCallback(
(filter: FilterType<FilterProperties> | null, searchValue: string) => {
(filter: SearchConfigType<any> | null, searchValue: string) => {
onFilterSearch && onFilterSearch(filter, searchValue);
},
[onFilterSearch],

View File

@ -2,16 +2,16 @@ import { ThemeProvider } from '@emotion/react';
import { lightTheme } from '../../../../layout/styles/themes';
import { FilterDropdownButton } from '../FilterDropdownButton';
import styled from '@emotion/styled';
import { FilterType, SelectedFilterType } from '../interface';
import { FilterConfigType, SelectedFilterType } from '../interface';
import { useCallback, useState } from 'react';
import { People_Bool_Exp } from '../../../../generated/graphql';
import { FaUsers } from 'react-icons/fa';
import {
SEARCH_PEOPLE_QUERY,
useSearch,
} from '../../../../services/search/search';
import { MockedProvider } from '@apollo/client/testing';
import { mockData } from '../../../../pages/people/__tests__/__data__/mock-data';
import { availableFilters } from '../../../../pages/people/people-table';
import { Person } from '../../../../interfaces/person.interface';
const component = {
title: 'FilterDropdownButton',
@ -78,35 +78,6 @@ const mocks = [
},
];
const availableFilters = [
{
key: 'fullname',
label: 'People',
icon: <FaUsers />,
searchQuery: SEARCH_PEOPLE_QUERY,
searchTemplate: (searchInput: string) => ({
_or: [
{ firstname: { _ilike: `%${searchInput}%` } },
{ lastname: { _ilike: `%${searchInput}%` } },
],
}),
whereTemplate: () => ({
_or: [
{ firstname: { _ilike: 'value' } },
{ lastname: { _ilike: 'value' } },
],
}),
searchResultMapper: (data) => ({
displayValue: data.firstname + ' ' + data.lastname,
value: data.firstname,
}),
operands: [
{ label: 'Equal', id: 'equal', keyWord: 'equal' },
{ label: 'Not equal', id: 'not-equal', keyWord: 'not_equal' },
],
},
] satisfies FilterType<People_Bool_Exp>[];
const StyleDiv = styled.div`
height: 200px;
width: 200px;
@ -114,12 +85,12 @@ const StyleDiv = styled.div`
const InnerRegularFilterDropdownButton = ({
setFilter: setFilters,
}: OwnProps<People_Bool_Exp>) => {
const [, innerSetFilters] = useState<SelectedFilterType<People_Bool_Exp>>();
}: OwnProps<Person>) => {
const [, innerSetFilters] = useState<SelectedFilterType<Person>>();
const [filterSearchResults, setSearhInput, setFilterSearch] = useSearch();
const outerSetFilters = useCallback(
(filter: SelectedFilterType<People_Bool_Exp>) => {
(filter: SelectedFilterType<Person>) => {
innerSetFilters(filter);
setFilters(filter);
},
@ -128,7 +99,7 @@ const InnerRegularFilterDropdownButton = ({
return (
<StyleDiv>
<FilterDropdownButton
availableFilters={availableFilters}
availableFilters={availableFilters as FilterConfigType[]}
isFilterSelected={true}
onFilterSelect={outerSetFilters}
filterSearchResults={filterSearchResults}
@ -143,7 +114,7 @@ const InnerRegularFilterDropdownButton = ({
export const RegularFilterDropdownButton = ({
setFilter: setFilters,
}: OwnProps<People_Bool_Exp>) => {
}: OwnProps<Person>) => {
return (
<MockedProvider mocks={mocks}>
<ThemeProvider theme={lightTheme}>

View File

@ -1,8 +1,8 @@
import SortAndFilterBar from '../SortAndFilterBar';
import { ThemeProvider } from '@emotion/react';
import { lightTheme } from '../../../../layout/styles/themes';
import { GET_PEOPLE } from '../../../../services/people';
import { FaArrowDown } from 'react-icons/fa';
import { SelectedFilterType } from '../interface';
const component = {
title: 'SortAndFilterBar',
@ -45,26 +45,28 @@ export const RegularSortAndFilterBar = ({
filters={[
{
label: 'People',
operand: { label: 'Include', id: 'include', keyWord: 'ilike' },
operand: {
label: 'Include',
id: 'include',
whereTemplate: (person) => {
return { email: { _eq: person.email } };
},
},
key: 'test_filter',
icon: <FaArrowDown />,
value: 'John Doe',
where: {
firstname: { _ilike: 'John Doe' },
displayValue: 'john@doedoe.com',
value: {
id: 'test',
email: 'john@doedoe.com',
firstname: 'John',
lastname: 'Doe',
phone: '123456789',
company: null,
creationDate: new Date(),
pipe: null,
city: 'Paris',
},
searchQuery: GET_PEOPLE,
searchTemplate: () => ({
firstname: { _ilike: 'John Doe' },
}),
whereTemplate: () => {
return { firstname: { _ilike: 'John Doe' } };
},
searchResultMapper: (data) => ({
displayValue: 'John Doe',
value: data.firstname,
}),
operands: [],
},
} satisfies SelectedFilterType,
]}
/>
</ThemeProvider>

View File

@ -1,6 +1,5 @@
import { fireEvent, render, waitFor } from '@testing-library/react';
import { RegularFilterDropdownButton } from '../__stories__/FilterDropdownButton.stories';
import { FaUsers } from 'react-icons/fa';
it('Checks the default top option is Include', async () => {
const setFilters = jest.fn();
@ -24,15 +23,9 @@ it('Checks the default top option is Include', async () => {
expect(setFilters).toHaveBeenCalledWith(
expect.objectContaining({
key: 'Alexandre Prot',
value: 'Alexandre Prot',
displayValue: 'Alexandre Prot',
key: 'fullname',
label: 'People',
operand: {
id: 'equal',
keyWord: 'equal',
label: 'Equal',
},
icon: <FaUsers />,
}),
);
});
@ -65,15 +58,9 @@ it('Checks the selection of top option for Not Equal', async () => {
expect(setFilters).toHaveBeenCalledWith(
expect.objectContaining({
key: 'Alexandre Prot',
value: 'Alexandre Prot',
key: 'fullname',
displayValue: 'Alexandre Prot',
label: 'People',
operand: {
id: 'not-equal',
keyWord: 'not_equal',
label: 'Not equal',
},
icon: <FaUsers />,
}),
);
const blueSortDropdownButton = getByText('Filter');
@ -118,15 +105,9 @@ it('Calls the filters when typing a new name', async () => {
expect(setFilters).toHaveBeenCalledWith(
expect.objectContaining({
key: 'Jane Doe',
value: 'Jane Doe',
key: 'fullname',
displayValue: 'Jane Doe',
label: 'People',
operand: {
id: 'equal',
keyWord: 'equal',
label: 'Equal',
},
icon: <FaUsers />,
}),
);
const blueSortDropdownButton = getByText('Filter');

View File

@ -1,13 +1,12 @@
import { Order_By } from '../../../generated/graphql';
import { SelectedFilterType, SelectedSortType } from './interface';
import { BoolExpType, SelectedFilterType, SelectedSortType } from './interface';
export const reduceFiltersToWhere = <T>(
filters: Array<SelectedFilterType<T>>,
): T => {
export const reduceFiltersToWhere = <ValueType, WhereTemplateType>(
filters: Array<SelectedFilterType<ValueType, WhereTemplateType>>,
): BoolExpType<WhereTemplateType> => {
const where = filters.reduce((acc, filter) => {
const { where } = filter;
return { ...acc, ...where };
}, {} as T);
return { ...acc, ...filter.operand.whereTemplate(filter.value) };
}, {} as BoolExpType<WhereTemplateType>);
return where;
};

View File

@ -6,6 +6,15 @@ import {
People_Bool_Exp,
Users_Bool_Exp,
} from '../../../generated/graphql';
import {
Company,
GraphqlQueryCompany,
} from '../../../interfaces/company.interface';
import {
GraphqlQueryPerson,
Person,
} from '../../../interfaces/person.interface';
import { GraphqlQueryUser, User } from '../../../interfaces/user.interface';
export type SortType<OrderByTemplate> =
| {
@ -26,33 +35,64 @@ export type SelectedSortType<OrderByTemplate> = SortType<OrderByTemplate> & {
order: 'asc' | 'desc';
};
export type FilterType<WhereTemplate, FilterValue = Record<string, any>> = {
operands: FilterOperandType[];
label: string;
export type FilterableFieldsType = Person | Company;
export type FilterWhereType = Person | Company | User;
type FilterConfigGqlType<WhereType> = WhereType extends Company
? GraphqlQueryCompany
: WhereType extends Person
? GraphqlQueryPerson
: WhereType extends User
? GraphqlQueryUser
: never;
export type BoolExpType<T> = T extends Company
? Companies_Bool_Exp
: T extends Person
? People_Bool_Exp
: never;
export type FilterConfigType<FilteredType = any, WhereType = any> = {
key: string;
label: string;
icon: ReactNode;
whereTemplate: (
operand: FilterOperandType,
value: FilterValue,
) => WhereTemplate | undefined;
searchQuery: DocumentNode;
searchTemplate: (
operands: FilterOperandType<FilteredType, WhereType>[];
searchConfig: WhereType extends SearchableType
? SearchConfigType<WhereType>
: null;
selectedValueRender: (selected: WhereType) => string;
};
export type SearchableType = Person | Company | User;
export type SearchConfigType<SearchType extends SearchableType> = {
query: DocumentNode;
template: (
searchInput: string,
) => People_Bool_Exp | Companies_Bool_Exp | Users_Bool_Exp;
searchResultMapper: (data: any) => {
displayValue: string;
value: FilterValue;
resultMapper: (data: FilterConfigGqlType<SearchType>) => {
value: SearchType;
render: (value: SearchType) => ReactNode;
};
};
export type FilterOperandType = {
export type FilterOperandType<
FilteredType = FilterableFieldsType,
WhereType = any,
> = {
label: string;
id: string;
keyWord: 'ilike' | 'not_ilike' | 'equal' | 'not_equal';
whereTemplate: (value: WhereType) => BoolExpType<FilteredType>;
};
export type SelectedFilterType<WhereTemplate> = FilterType<WhereTemplate> & {
value: string;
operand: FilterOperandType;
where: WhereTemplate;
export type SelectedFilterType<
FilteredType = FilterableFieldsType,
WhereType = any,
> = {
key: string;
value: WhereType;
displayValue: string;
label: string;
icon: ReactNode;
operand: FilterOperandType<FilteredType, WhereType>;
};