Add Filters on Table views (#95)
* Add filter search logic WIP Filter search Implement filters test: fix sorts tests test: fix filter test feature: search person and display firstname in results feature: fix test for filter component test: mock search filters refactor: create a useSearch hook refactor: move debounce in useSearch and reset status of filter selection feature: debounce set filters refactor: remove useless setSorts feature: add where variable to people query feature: strongly type Filters feature: update WhereTemplate method feature: implement filtering on full name feature: type the useSearch hook feature: use where reducer refactor: create a type for readability feature: use query and mapper from filters feature: implement filter by company feature: search filter results on filter select feature: add loading and results to search results in filters refactor: move render search results in a function feature: display a LOADING when it loads feature: split search input and search filter for different debounce refactor: remove some warnings refactor: remove some warnings * Write test 1 * Write test 2 * test: useSearch is tested * test: update names of default people data * test: add a filter search * Test 3 * Fix tests --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -1,9 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Opportunity } from '../../interfaces/company.interface';
|
||||
|
||||
type OwnProps = {
|
||||
name: string;
|
||||
picture?: string;
|
||||
opportunity: Opportunity;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.span`
|
||||
@ -20,11 +20,11 @@ const StyledContainer = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
function PipeChip({ name, picture }: OwnProps) {
|
||||
function PipeChip({ opportunity }: OwnProps) {
|
||||
return (
|
||||
<StyledContainer data-testid="company-chip">
|
||||
{picture && <span>{picture}</span>}
|
||||
<span>{name}</span>
|
||||
<StyledContainer data-testid="company-chip" key={opportunity.id}>
|
||||
{opportunity.icon && <span>{opportunity.icon}</span>}
|
||||
<span>{opportunity.name}</span>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,18 +10,30 @@ import TableHeader from './table-header/TableHeader';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
FilterType,
|
||||
SelectedFilterType,
|
||||
SelectedSortType,
|
||||
SortType,
|
||||
} from './table-header/interface';
|
||||
|
||||
type OwnProps<TData, SortField> = {
|
||||
type OwnProps<TData, SortField, FilterProperties> = {
|
||||
data: Array<TData>;
|
||||
columns: Array<ColumnDef<TData, any>>;
|
||||
viewName: string;
|
||||
viewIcon?: React.ReactNode;
|
||||
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
|
||||
availableSorts?: Array<SortType<SortField>>;
|
||||
availableFilters?: FilterType[];
|
||||
availableFilters?: FilterType<FilterProperties>[];
|
||||
filterSearchResults?: {
|
||||
results: { displayValue: string; value: any }[];
|
||||
loading: boolean;
|
||||
};
|
||||
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
|
||||
onFiltersUpdate?: (
|
||||
sorts: Array<SelectedFilterType<FilterProperties>>,
|
||||
) => void;
|
||||
onFilterSearch?: (
|
||||
filter: FilterType<FilterProperties> | null,
|
||||
searchValue: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
const StyledTable = styled.table`
|
||||
@ -75,15 +87,18 @@ const StyledTableScrollableContainer = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
function Table<TData, SortField extends string>({
|
||||
function Table<TData, SortField extends string, FilterProperies>({
|
||||
data,
|
||||
columns,
|
||||
viewName,
|
||||
viewIcon,
|
||||
onSortsUpdate,
|
||||
availableSorts,
|
||||
availableFilters,
|
||||
}: OwnProps<TData, SortField>) {
|
||||
filterSearchResults,
|
||||
onSortsUpdate,
|
||||
onFiltersUpdate,
|
||||
onFilterSearch,
|
||||
}: OwnProps<TData, SortField, FilterProperies>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
@ -95,9 +110,12 @@ function Table<TData, SortField extends string>({
|
||||
<TableHeader
|
||||
viewName={viewName}
|
||||
viewIcon={viewIcon}
|
||||
onSortsUpdate={onSortsUpdate}
|
||||
availableSorts={availableSorts}
|
||||
availableFilters={availableFilters}
|
||||
filterSearchResults={filterSearchResults}
|
||||
onSortsUpdate={onSortsUpdate}
|
||||
onFiltersUpdate={onFiltersUpdate}
|
||||
onFilterSearch={onFilterSearch}
|
||||
/>
|
||||
<StyledTableScrollableContainer>
|
||||
<StyledTable>
|
||||
|
||||
@ -1,24 +1,31 @@
|
||||
import EditableCell from '../EditableCell';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { lightTheme } from '../../../layout/styles/themes';
|
||||
import { StoryFn } from '@storybook/react';
|
||||
|
||||
const component = {
|
||||
title: 'EditableCell',
|
||||
component: EditableCell,
|
||||
};
|
||||
|
||||
export default component;
|
||||
|
||||
type OwnProps = {
|
||||
changeHandler: () => void;
|
||||
content: string;
|
||||
changeHandler: (updated: string) => void;
|
||||
};
|
||||
|
||||
export const RegularEditableCell = ({ changeHandler }: OwnProps) => {
|
||||
export default component;
|
||||
|
||||
const Template: StoryFn<typeof EditableCell> = (args: OwnProps) => {
|
||||
return (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<div data-testid="content-editable-parent">
|
||||
<EditableCell content={''} changeHandler={changeHandler} />,
|
||||
<EditableCell {...args} />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditableCellStory = Template.bind({});
|
||||
EditableCellStory.args = {
|
||||
content: 'Test string',
|
||||
};
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
|
||||
import { RegularEditableCell } from '../__stories__/EditableCell.stories';
|
||||
import { EditableCellStory } from '../__stories__/EditableCell.stories';
|
||||
|
||||
it('Checks the EditableCell editing event bubbles up', async () => {
|
||||
const func = jest.fn(() => null);
|
||||
const { getByTestId } = render(<RegularEditableCell changeHandler={func} />);
|
||||
const { getByTestId } = render(
|
||||
<EditableCellStory content="test" changeHandler={func} />,
|
||||
);
|
||||
|
||||
const parent = getByTestId('content-editable-parent');
|
||||
const editableInput = parent.querySelector('input');
|
||||
|
||||
@ -1,47 +1,40 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ChangeEvent, useCallback, useState } from 'react';
|
||||
import DropdownButton from './DropdownButton';
|
||||
import { FilterType, SelectedFilterType } from './interface';
|
||||
import { FilterOperandType, FilterType, SelectedFilterType } from './interface';
|
||||
|
||||
type OwnProps = {
|
||||
filters: SelectedFilterType[];
|
||||
setFilters: (sorts: SelectedFilterType[]) => void;
|
||||
availableFilters: FilterType[];
|
||||
type OwnProps<FilterProperties> = {
|
||||
isFilterSelected: boolean;
|
||||
availableFilters: FilterType<FilterProperties>[];
|
||||
filterSearchResults?: {
|
||||
results: { displayValue: string; value: any }[];
|
||||
loading: boolean;
|
||||
};
|
||||
onFilterSelect: (filter: SelectedFilterType<FilterProperties>) => void;
|
||||
onFilterSearch: (
|
||||
filter: FilterType<FilterProperties> | null,
|
||||
searchValue: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type FilterOperandType = { label: string; id: string };
|
||||
|
||||
const filterOperands: FilterOperandType[] = [
|
||||
{ label: 'Include', id: 'include' },
|
||||
{ label: "Doesn't include", id: 'not-include' },
|
||||
{ label: 'Include', id: 'include', keyWord: 'ilike' },
|
||||
{ label: "Doesn't include", id: 'not-include', keyWord: 'not_ilike' },
|
||||
];
|
||||
|
||||
const someFieldRandomValue = [
|
||||
'John Doe',
|
||||
'Jane Doe',
|
||||
'John Smith',
|
||||
'Jane Smith',
|
||||
'John Johnson',
|
||||
'Jane Johnson',
|
||||
'John Williams',
|
||||
'Jane Williams',
|
||||
'John Brown',
|
||||
'Jane Brown',
|
||||
'John Jones',
|
||||
'Jane Jones',
|
||||
];
|
||||
|
||||
export function FilterDropdownButton({
|
||||
export function FilterDropdownButton<FilterProperties>({
|
||||
availableFilters,
|
||||
setFilters,
|
||||
filters,
|
||||
}: OwnProps) {
|
||||
filterSearchResults,
|
||||
onFilterSearch,
|
||||
onFilterSelect,
|
||||
isFilterSelected,
|
||||
}: OwnProps<FilterProperties>) {
|
||||
const [isUnfolded, setIsUnfolded] = useState(false);
|
||||
|
||||
const [isOptionUnfolded, setIsOptionUnfolded] = useState(false);
|
||||
|
||||
const [selectedFilter, setSelectedFilter] = useState<FilterType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [selectedFilter, setSelectedFilter] = useState<
|
||||
FilterType<FilterProperties> | undefined
|
||||
>(undefined);
|
||||
|
||||
const [selectedFilterOperand, setSelectedFilterOperand] =
|
||||
useState<FilterOperandType>(filterOperands[0]);
|
||||
@ -50,7 +43,8 @@ export function FilterDropdownButton({
|
||||
setIsOptionUnfolded(false);
|
||||
setSelectedFilter(undefined);
|
||||
setSelectedFilterOperand(filterOperands[0]);
|
||||
}, []);
|
||||
onFilterSearch(null, '');
|
||||
}, [onFilterSearch]);
|
||||
|
||||
const renderSelectOptionItems = filterOperands.map((filterOperand, index) => (
|
||||
<DropdownButton.StyledDropdownItem
|
||||
@ -64,11 +58,49 @@ export function FilterDropdownButton({
|
||||
</DropdownButton.StyledDropdownItem>
|
||||
));
|
||||
|
||||
const renderSearchResults = (
|
||||
filterSearchResults: NonNullable<
|
||||
OwnProps<FilterProperties>['filterSearchResults']
|
||||
>,
|
||||
selectedFilter: FilterType<FilterProperties>,
|
||||
) => {
|
||||
if (filterSearchResults.loading) {
|
||||
return 'LOADING';
|
||||
}
|
||||
return filterSearchResults.results.map((value, index) => (
|
||||
<DropdownButton.StyledDropdownItem
|
||||
key={`fields-value-${index}`}
|
||||
onClick={() => {
|
||||
onFilterSelect({
|
||||
key: value.displayValue,
|
||||
operand: selectedFilterOperand,
|
||||
searchQuery: selectedFilter.searchQuery,
|
||||
searchTemplate: selectedFilter.searchTemplate,
|
||||
whereTemplate: selectedFilter.whereTemplate,
|
||||
label: selectedFilter.label,
|
||||
value: value.displayValue,
|
||||
icon: selectedFilter.icon,
|
||||
where: selectedFilter.whereTemplate(
|
||||
selectedFilterOperand,
|
||||
value.value,
|
||||
),
|
||||
searchResultMapper: selectedFilter.searchResultMapper,
|
||||
});
|
||||
setIsUnfolded(false);
|
||||
setSelectedFilter(undefined);
|
||||
}}
|
||||
>
|
||||
{value.displayValue}
|
||||
</DropdownButton.StyledDropdownItem>
|
||||
));
|
||||
};
|
||||
|
||||
const renderSelectFilterITems = availableFilters.map((filter, index) => (
|
||||
<DropdownButton.StyledDropdownItem
|
||||
key={`select-filter-${index}`}
|
||||
onClick={() => {
|
||||
setSelectedFilter(filter);
|
||||
onFilterSearch(filter, '');
|
||||
}}
|
||||
>
|
||||
<DropdownButton.StyledIcon>{filter.icon}</DropdownButton.StyledIcon>
|
||||
@ -76,46 +108,36 @@ export function FilterDropdownButton({
|
||||
</DropdownButton.StyledDropdownItem>
|
||||
));
|
||||
|
||||
function renderFilterDropdown(selectedFilter: FilterType) {
|
||||
return [
|
||||
<DropdownButton.StyledDropdownTopOption
|
||||
key={'selected-filter-operand'}
|
||||
onClick={() => setIsOptionUnfolded(true)}
|
||||
>
|
||||
{selectedFilterOperand.label}
|
||||
|
||||
<DropdownButton.StyledDropdownTopOptionAngleDown />
|
||||
</DropdownButton.StyledDropdownTopOption>,
|
||||
<DropdownButton.StyledSearchField key={'search-filter'}>
|
||||
<input type="text" placeholder={selectedFilter.label} />
|
||||
</DropdownButton.StyledSearchField>,
|
||||
someFieldRandomValue.map((value, index) => (
|
||||
<DropdownButton.StyledDropdownItem
|
||||
key={`fields-value-${index}`}
|
||||
onClick={() => {
|
||||
setFilters([
|
||||
{
|
||||
id: value,
|
||||
operand: selectedFilterOperand,
|
||||
label: selectedFilter.label,
|
||||
value: value,
|
||||
icon: selectedFilter.icon,
|
||||
},
|
||||
]);
|
||||
setIsUnfolded(false);
|
||||
setSelectedFilter(undefined);
|
||||
}}
|
||||
function renderFilterDropdown(selectedFilter: FilterType<FilterProperties>) {
|
||||
return (
|
||||
<>
|
||||
<DropdownButton.StyledDropdownTopOption
|
||||
key={'selected-filter-operand'}
|
||||
onClick={() => setIsOptionUnfolded(true)}
|
||||
>
|
||||
{value}
|
||||
</DropdownButton.StyledDropdownItem>
|
||||
)),
|
||||
];
|
||||
{selectedFilterOperand.label}
|
||||
|
||||
<DropdownButton.StyledDropdownTopOptionAngleDown />
|
||||
</DropdownButton.StyledDropdownTopOption>
|
||||
<DropdownButton.StyledSearchField key={'search-filter'}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={selectedFilter.label}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
onFilterSearch(selectedFilter, event.target.value)
|
||||
}
|
||||
/>
|
||||
</DropdownButton.StyledSearchField>
|
||||
{filterSearchResults &&
|
||||
renderSearchResults(filterSearchResults, selectedFilter)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownButton
|
||||
label="Filter"
|
||||
isActive={filters.length > 0}
|
||||
isActive={isFilterSelected}
|
||||
isUnfolded={isUnfolded}
|
||||
setIsUnfolded={setIsUnfolded}
|
||||
resetState={resetState}
|
||||
|
||||
@ -3,11 +3,13 @@ import SortOrFilterChip from './SortOrFilterChip';
|
||||
import { FaArrowDown, FaArrowUp } from 'react-icons/fa';
|
||||
import { SelectedFilterType, SelectedSortType } from './interface';
|
||||
|
||||
type OwnProps<SortField> = {
|
||||
type OwnProps<SortField, FilterProperties> = {
|
||||
sorts: Array<SelectedSortType<SortField>>;
|
||||
onRemoveSort: (sortId: SelectedSortType<SortField>['key']) => void;
|
||||
filters: Array<SelectedFilterType>;
|
||||
onRemoveFilter: (filterId: SelectedFilterType['id']) => void;
|
||||
filters: Array<SelectedFilterType<FilterProperties>>;
|
||||
onRemoveFilter: (
|
||||
filterId: SelectedFilterType<FilterProperties>['key'],
|
||||
) => void;
|
||||
};
|
||||
|
||||
const StyledBar = styled.div`
|
||||
@ -39,12 +41,12 @@ const StyledCancelButton = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
function SortAndFilterBar<SortField extends string>({
|
||||
function SortAndFilterBar<SortField extends string, FilterProperties>({
|
||||
sorts,
|
||||
onRemoveSort,
|
||||
filters,
|
||||
onRemoveFilter,
|
||||
}: OwnProps<SortField>) {
|
||||
}: OwnProps<SortField, FilterProperties>) {
|
||||
return (
|
||||
<StyledBar>
|
||||
{sorts.map((sort) => {
|
||||
@ -61,12 +63,12 @@ function SortAndFilterBar<SortField extends string>({
|
||||
{filters.map((filter) => {
|
||||
return (
|
||||
<SortOrFilterChip
|
||||
key={filter.id}
|
||||
key={filter.key}
|
||||
labelKey={filter.label}
|
||||
labelValue={`${filter.operand.label} ${filter.value}`}
|
||||
id={filter.id}
|
||||
id={filter.key}
|
||||
icon={filter.icon}
|
||||
onRemove={() => onRemoveFilter(filter.id)}
|
||||
onRemove={() => onRemoveFilter(filter.key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@ -75,7 +77,7 @@ function SortAndFilterBar<SortField extends string>({
|
||||
data-testid={'cancel-button'}
|
||||
onClick={() => {
|
||||
sorts.forEach((i) => onRemoveSort(i.key));
|
||||
filters.forEach((i) => onRemoveFilter(i.id));
|
||||
filters.forEach((i) => onRemoveFilter(i.key));
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@ -3,17 +3,17 @@ import DropdownButton from './DropdownButton';
|
||||
import { SelectedSortType, SortType } from './interface';
|
||||
|
||||
type OwnProps<SortField> = {
|
||||
sorts: SelectedSortType<SortField>[];
|
||||
setSorts: (sorts: SelectedSortType<SortField>[]) => void;
|
||||
isSortSelected: boolean;
|
||||
onSortSelect: (sort: SelectedSortType<SortField>) => void;
|
||||
availableSorts: SortType<SortField>[];
|
||||
};
|
||||
|
||||
const options: Array<SelectedSortType<string>['order']> = ['asc', 'desc'];
|
||||
|
||||
export function SortDropdownButton<SortField extends string>({
|
||||
isSortSelected,
|
||||
availableSorts,
|
||||
setSorts,
|
||||
sorts,
|
||||
onSortSelect,
|
||||
}: OwnProps<SortField>) {
|
||||
const [isUnfolded, setIsUnfolded] = useState(false);
|
||||
|
||||
@ -24,10 +24,9 @@ export function SortDropdownButton<SortField extends string>({
|
||||
|
||||
const onSortItemSelect = useCallback(
|
||||
(sort: SortType<SortField>) => {
|
||||
const newSorts = [{ ...sort, order: selectedSortDirection }];
|
||||
setSorts(newSorts);
|
||||
onSortSelect({ ...sort, order: selectedSortDirection });
|
||||
},
|
||||
[setSorts, selectedSortDirection],
|
||||
[onSortSelect, selectedSortDirection],
|
||||
);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
@ -38,7 +37,7 @@ export function SortDropdownButton<SortField extends string>({
|
||||
return (
|
||||
<DropdownButton
|
||||
label="Sort"
|
||||
isActive={sorts.length > 0}
|
||||
isActive={isSortSelected}
|
||||
isUnfolded={isUnfolded}
|
||||
setIsUnfolded={setIsUnfolded}
|
||||
resetState={resetState}
|
||||
|
||||
@ -11,13 +11,23 @@ import { SortDropdownButton } from './SortDropdownButton';
|
||||
import { FilterDropdownButton } from './FilterDropdownButton';
|
||||
import SortAndFilterBar from './SortAndFilterBar';
|
||||
|
||||
type OwnProps<SortField> = {
|
||||
type OwnProps<SortField, FilterProperties> = {
|
||||
viewName: string;
|
||||
viewIcon?: ReactNode;
|
||||
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
|
||||
onFiltersUpdate?: (sorts: Array<SelectedFilterType>) => void;
|
||||
availableSorts?: Array<SortType<SortField>>;
|
||||
availableFilters?: FilterType[];
|
||||
availableFilters?: FilterType<FilterProperties>[];
|
||||
filterSearchResults?: {
|
||||
results: { displayValue: string; value: any }[];
|
||||
loading: boolean;
|
||||
};
|
||||
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
|
||||
onFiltersUpdate?: (
|
||||
sorts: Array<SelectedFilterType<FilterProperties>>,
|
||||
) => void;
|
||||
onFilterSearch?: (
|
||||
filter: FilterType<FilterProperties> | null,
|
||||
searchValue: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@ -56,27 +66,32 @@ const StyledFilters = styled.div`
|
||||
margin-right: ${(props) => props.theme.spacing(2)};
|
||||
`;
|
||||
|
||||
function TableHeader<SortField extends string>({
|
||||
function TableHeader<SortField extends string, FilterProperties>({
|
||||
viewName,
|
||||
viewIcon,
|
||||
onSortsUpdate,
|
||||
onFiltersUpdate,
|
||||
availableSorts,
|
||||
availableFilters,
|
||||
}: OwnProps<SortField>) {
|
||||
filterSearchResults,
|
||||
onSortsUpdate,
|
||||
onFiltersUpdate,
|
||||
onFilterSearch,
|
||||
}: OwnProps<SortField, FilterProperties>) {
|
||||
const [sorts, innerSetSorts] = useState<Array<SelectedSortType<SortField>>>(
|
||||
[],
|
||||
);
|
||||
const [filters, innerSetFilters] = useState<
|
||||
Array<SelectedFilterType<FilterProperties>>
|
||||
>([]);
|
||||
|
||||
const setSorts = useCallback(
|
||||
(sorts: SelectedSortType<SortField>[]) => {
|
||||
innerSetSorts(sorts);
|
||||
onSortsUpdate && onSortsUpdate(sorts);
|
||||
const sortSelect = useCallback(
|
||||
(sort: SelectedSortType<SortField>) => {
|
||||
innerSetSorts([sort]);
|
||||
onSortsUpdate && onSortsUpdate([sort]);
|
||||
},
|
||||
[onSortsUpdate],
|
||||
);
|
||||
|
||||
const onSortItemUnSelect = useCallback(
|
||||
const sortUnselect = useCallback(
|
||||
(sortId: string) => {
|
||||
const newSorts = [] as SelectedSortType<SortField>[];
|
||||
innerSetSorts(newSorts);
|
||||
@ -85,25 +100,30 @@ function TableHeader<SortField extends string>({
|
||||
[onSortsUpdate],
|
||||
);
|
||||
|
||||
const [filters, innerSetFilters] = useState<Array<SelectedFilterType>>([]);
|
||||
|
||||
const setFilters = useCallback(
|
||||
(filters: SelectedFilterType[]) => {
|
||||
innerSetFilters(filters);
|
||||
onFiltersUpdate && onFiltersUpdate(filters);
|
||||
const filterSelect = useCallback(
|
||||
(filter: SelectedFilterType<FilterProperties>) => {
|
||||
innerSetFilters([filter]);
|
||||
onFiltersUpdate && onFiltersUpdate([filter]);
|
||||
},
|
||||
[onFiltersUpdate],
|
||||
);
|
||||
|
||||
const onFilterItemUnSelect = useCallback(
|
||||
(filterId: SelectedFilterType['id']) => {
|
||||
const newFilters = [] as SelectedFilterType[];
|
||||
const filterUnselect = useCallback(
|
||||
(filterId: SelectedFilterType<FilterProperties>['key']) => {
|
||||
const newFilters = [] as SelectedFilterType<FilterProperties>[];
|
||||
innerSetFilters(newFilters);
|
||||
onFiltersUpdate && onFiltersUpdate(newFilters);
|
||||
},
|
||||
[onFiltersUpdate],
|
||||
);
|
||||
|
||||
const filterSearch = useCallback(
|
||||
(filter: FilterType<FilterProperties> | null, searchValue: string) => {
|
||||
onFilterSearch && onFilterSearch(filter, searchValue);
|
||||
},
|
||||
[onFilterSearch],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledTableHeader>
|
||||
@ -113,14 +133,16 @@ function TableHeader<SortField extends string>({
|
||||
</StyledViewSection>
|
||||
<StyledFilters>
|
||||
<FilterDropdownButton
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
isFilterSelected={filters.length > 0}
|
||||
availableFilters={availableFilters || []}
|
||||
filterSearchResults={filterSearchResults}
|
||||
onFilterSelect={filterSelect}
|
||||
onFilterSearch={filterSearch}
|
||||
/>
|
||||
<SortDropdownButton
|
||||
setSorts={setSorts}
|
||||
sorts={sorts}
|
||||
<SortDropdownButton<SortField>
|
||||
isSortSelected={sorts.length > 0}
|
||||
availableSorts={availableSorts || []}
|
||||
onSortSelect={sortSelect}
|
||||
/>
|
||||
|
||||
<DropdownButton label="Settings" isActive={false}></DropdownButton>
|
||||
@ -129,9 +151,9 @@ function TableHeader<SortField extends string>({
|
||||
{sorts.length + filters.length > 0 && (
|
||||
<SortAndFilterBar
|
||||
sorts={sorts}
|
||||
onRemoveSort={onSortItemUnSelect}
|
||||
filters={filters}
|
||||
onRemoveFilter={onFilterItemUnSelect}
|
||||
onRemoveSort={sortUnselect}
|
||||
onRemoveFilter={filterUnselect}
|
||||
/>
|
||||
)}
|
||||
</StyledContainer>
|
||||
|
||||
@ -3,15 +3,15 @@ import { lightTheme } from '../../../../layout/styles/themes';
|
||||
import { FilterDropdownButton } from '../FilterDropdownButton';
|
||||
import styled from '@emotion/styled';
|
||||
import { FilterType, SelectedFilterType } from '../interface';
|
||||
import {
|
||||
FaRegUser,
|
||||
FaRegBuilding,
|
||||
FaEnvelope,
|
||||
FaPhone,
|
||||
FaCalendar,
|
||||
FaMapPin,
|
||||
} from 'react-icons/fa';
|
||||
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 { defaultData } from '../../../../pages/people/default-data';
|
||||
|
||||
const component = {
|
||||
title: 'FilterDropdownButton',
|
||||
@ -20,58 +20,129 @@ const component = {
|
||||
|
||||
export default component;
|
||||
|
||||
type OwnProps = {
|
||||
setFilters: (filters: SelectedFilterType[]) => void;
|
||||
type OwnProps<FilterProperties> = {
|
||||
setFilter: (filters: SelectedFilterType<FilterProperties>) => void;
|
||||
};
|
||||
|
||||
const mocks = [
|
||||
{
|
||||
request: {
|
||||
query: SEARCH_PEOPLE_QUERY, // TODO this should not be called for empty filters
|
||||
variables: {
|
||||
where: undefined,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
searchResults: defaultData,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: SEARCH_PEOPLE_QUERY, // TODO this should not be called for empty filters
|
||||
variables: {
|
||||
where: {
|
||||
_or: [
|
||||
{ firstname: { _ilike: '%%' } },
|
||||
{ lastname: { _ilike: '%%' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
searchResults: defaultData,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: SEARCH_PEOPLE_QUERY, // TODO this should not be called for empty filters
|
||||
variables: {
|
||||
where: {
|
||||
_or: [
|
||||
{ firstname: { _ilike: '%Jane%' } },
|
||||
{ lastname: { _ilike: '%Jane%' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
searchResults: [defaultData.find((p) => p.firstname === 'Jane')],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const availableFilters = [
|
||||
{
|
||||
key: 'fullname',
|
||||
label: 'People',
|
||||
icon: <FaRegUser />,
|
||||
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,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'company_name',
|
||||
label: 'Company',
|
||||
icon: <FaRegBuilding />,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
icon: <FaEnvelope />,
|
||||
},
|
||||
{ key: 'phone', label: 'Phone', icon: <FaPhone /> },
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Created at',
|
||||
icon: <FaCalendar />,
|
||||
},
|
||||
{ key: 'city', label: 'City', icon: <FaMapPin /> },
|
||||
] satisfies FilterType[];
|
||||
] satisfies FilterType<People_Bool_Exp>[];
|
||||
|
||||
const StyleDiv = styled.div`
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
`;
|
||||
|
||||
export const RegularFilterDropdownButton = ({ setFilters }: OwnProps) => {
|
||||
const [filters, innerSetFilters] = useState<SelectedFilterType[]>([]);
|
||||
const InnerRegularFilterDropdownButton = ({
|
||||
setFilter: setFilters,
|
||||
}: OwnProps<People_Bool_Exp>) => {
|
||||
const [, innerSetFilters] = useState<SelectedFilterType<People_Bool_Exp>>();
|
||||
const [filterSearchResults, setSearhInput, setFilterSearch] = useSearch();
|
||||
|
||||
const outerSetFilters = useCallback(
|
||||
(filters: SelectedFilterType[]) => {
|
||||
innerSetFilters(filters);
|
||||
setFilters(filters);
|
||||
(filter: SelectedFilterType<People_Bool_Exp>) => {
|
||||
innerSetFilters(filter);
|
||||
setFilters(filter);
|
||||
},
|
||||
[setFilters],
|
||||
);
|
||||
return (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<StyleDiv>
|
||||
<FilterDropdownButton
|
||||
availableFilters={availableFilters}
|
||||
filters={filters}
|
||||
setFilters={outerSetFilters}
|
||||
/>
|
||||
</StyleDiv>
|
||||
</ThemeProvider>
|
||||
<StyleDiv>
|
||||
<FilterDropdownButton
|
||||
availableFilters={availableFilters}
|
||||
isFilterSelected={true}
|
||||
onFilterSelect={outerSetFilters}
|
||||
filterSearchResults={filterSearchResults}
|
||||
onFilterSearch={(filter, searchValue) => {
|
||||
setSearhInput(searchValue);
|
||||
setFilterSearch(filter);
|
||||
}}
|
||||
/>
|
||||
</StyleDiv>
|
||||
);
|
||||
};
|
||||
|
||||
export const RegularFilterDropdownButton = ({
|
||||
setFilter: setFilters,
|
||||
}: OwnProps<People_Bool_Exp>) => {
|
||||
return (
|
||||
<MockedProvider mocks={mocks}>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<InnerRegularFilterDropdownButton setFilter={setFilters} />
|
||||
</ThemeProvider>
|
||||
</MockedProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
const component = {
|
||||
@ -37,10 +38,24 @@ export const RegularSortAndFilterBar = ({ removeFunction }: OwnProps) => {
|
||||
filters={[
|
||||
{
|
||||
label: 'People',
|
||||
operand: { id: 'include', label: 'Include' },
|
||||
id: 'test_filter',
|
||||
operand: { label: 'Include', id: 'include', keyWord: 'ilike' },
|
||||
key: 'test_filter',
|
||||
icon: <FaArrowDown />,
|
||||
value: 'John Doe',
|
||||
where: {
|
||||
firstname: { _ilike: 'John Doe' },
|
||||
},
|
||||
searchQuery: GET_PEOPLE,
|
||||
searchTemplate: () => ({
|
||||
firstname: { _ilike: 'John Doe' },
|
||||
}),
|
||||
whereTemplate: () => {
|
||||
return { firstname: { _ilike: 'John Doe' } };
|
||||
},
|
||||
searchResultMapper: (data) => ({
|
||||
displayValue: 'John Doe',
|
||||
value: data.firstname,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { SelectedSortType, SortType } from '../interface';
|
||||
import { SortType } from '../interface';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { lightTheme } from '../../../../layout/styles/themes';
|
||||
import {
|
||||
@ -23,8 +23,6 @@ type OwnProps = {
|
||||
setSorts: () => void;
|
||||
};
|
||||
|
||||
const sorts = [] satisfies SelectedSortType[];
|
||||
|
||||
const availableSorts = [
|
||||
{
|
||||
key: 'fullname',
|
||||
@ -60,9 +58,9 @@ export const RegularSortDropdownButton = ({ setSorts }: OwnProps) => {
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<StyleDiv>
|
||||
<SortDropdownButton
|
||||
sorts={sorts}
|
||||
isSortSelected={true}
|
||||
availableSorts={availableSorts}
|
||||
setSorts={setSorts}
|
||||
onSortSelect={setSorts}
|
||||
/>
|
||||
</StyleDiv>
|
||||
</ThemeProvider>
|
||||
|
||||
@ -1,44 +1,53 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { RegularFilterDropdownButton } from '../__stories__/FilterDropdownButton.stories';
|
||||
import { FaEnvelope } from 'react-icons/fa';
|
||||
import { FaUsers } from 'react-icons/fa';
|
||||
|
||||
it('Checks the default top option is Include', async () => {
|
||||
const setSorts = jest.fn();
|
||||
const setFilters = jest.fn();
|
||||
const { getByText } = render(
|
||||
<RegularFilterDropdownButton setFilters={setSorts} />,
|
||||
<RegularFilterDropdownButton setFilter={setFilters} />,
|
||||
);
|
||||
|
||||
const sortDropdownButton = getByText('Filter');
|
||||
fireEvent.click(sortDropdownButton);
|
||||
|
||||
const sortByEmail = getByText('Email');
|
||||
fireEvent.click(sortByEmail);
|
||||
const filterByPeople = getByText('People');
|
||||
fireEvent.click(filterByPeople);
|
||||
|
||||
const filterByJohn = getByText('John Doe');
|
||||
await waitFor(() => {
|
||||
const firstSearchResult = getByText('Alexandre Prot');
|
||||
expect(firstSearchResult).toBeDefined();
|
||||
});
|
||||
|
||||
const filterByJohn = getByText('Alexandre Prot');
|
||||
fireEvent.click(filterByJohn);
|
||||
|
||||
expect(setSorts).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'John Doe',
|
||||
value: 'John Doe',
|
||||
label: 'Email',
|
||||
operand: { id: 'include', label: 'Include' },
|
||||
icon: <FaEnvelope />,
|
||||
},
|
||||
]);
|
||||
expect(setFilters).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'Alexandre Prot',
|
||||
value: 'Alexandre Prot',
|
||||
label: 'People',
|
||||
operand: {
|
||||
id: 'include',
|
||||
keyWord: 'ilike',
|
||||
label: 'Include',
|
||||
},
|
||||
icon: <FaUsers />,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('Checks the selection of top option for Doesnot include', async () => {
|
||||
const setSorts = jest.fn();
|
||||
const setFilters = jest.fn();
|
||||
const { getByText } = render(
|
||||
<RegularFilterDropdownButton setFilters={setSorts} />,
|
||||
<RegularFilterDropdownButton setFilter={setFilters} />,
|
||||
);
|
||||
|
||||
const sortDropdownButton = getByText('Filter');
|
||||
fireEvent.click(sortDropdownButton);
|
||||
|
||||
const sortByEmail = getByText('Email');
|
||||
fireEvent.click(sortByEmail);
|
||||
const filterByPeople = getByText('People');
|
||||
fireEvent.click(filterByPeople);
|
||||
|
||||
const openOperandOptions = getByText('Include');
|
||||
fireEvent.click(openOperandOptions);
|
||||
@ -46,19 +55,80 @@ it('Checks the selection of top option for Doesnot include', async () => {
|
||||
const selectOperand = getByText("Doesn't include");
|
||||
fireEvent.click(selectOperand);
|
||||
|
||||
const filterByJohn = getByText('John Doe');
|
||||
await waitFor(() => {
|
||||
const firstSearchResult = getByText('Alexandre Prot');
|
||||
expect(firstSearchResult).toBeDefined();
|
||||
});
|
||||
|
||||
const filterByJohn = getByText('Alexandre Prot');
|
||||
fireEvent.click(filterByJohn);
|
||||
|
||||
expect(setSorts).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'John Doe',
|
||||
value: 'John Doe',
|
||||
label: 'Email',
|
||||
operand: { id: 'not-include', label: "Doesn't include" },
|
||||
icon: <FaEnvelope />,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(setFilters).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'Alexandre Prot',
|
||||
value: 'Alexandre Prot',
|
||||
label: 'People',
|
||||
operand: {
|
||||
id: 'not-include',
|
||||
keyWord: 'not_ilike',
|
||||
label: "Doesn't include",
|
||||
},
|
||||
icon: <FaUsers />,
|
||||
}),
|
||||
);
|
||||
const blueSortDropdownButton = getByText('Filter');
|
||||
await waitFor(() => {
|
||||
expect(blueSortDropdownButton).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
it('Calls the filters when typing a new name', async () => {
|
||||
const setFilters = jest.fn();
|
||||
const { getByText, getByPlaceholderText, queryByText } = render(
|
||||
<RegularFilterDropdownButton setFilter={setFilters} />,
|
||||
);
|
||||
|
||||
const sortDropdownButton = getByText('Filter');
|
||||
fireEvent.click(sortDropdownButton);
|
||||
|
||||
const filterByPeople = getByText('People');
|
||||
fireEvent.click(filterByPeople);
|
||||
|
||||
const filterSearch = getByPlaceholderText('People');
|
||||
fireEvent.click(filterSearch);
|
||||
|
||||
fireEvent.change(filterSearch, { target: { value: 'Jane' } });
|
||||
|
||||
await waitFor(() => {
|
||||
const loadingDiv = getByText('LOADING');
|
||||
expect(loadingDiv).toBeDefined();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const firstSearchResult = getByText('Jane Doe');
|
||||
expect(firstSearchResult).toBeDefined();
|
||||
|
||||
const alexandreSearchResult = queryByText('Alexandre Prot');
|
||||
expect(alexandreSearchResult).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const filterByJane = getByText('Jane Doe');
|
||||
|
||||
fireEvent.click(filterByJane);
|
||||
|
||||
expect(setFilters).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'Jane Doe',
|
||||
value: 'Jane Doe',
|
||||
label: 'People',
|
||||
operand: {
|
||||
id: 'include',
|
||||
keyWord: 'ilike',
|
||||
label: 'Include',
|
||||
},
|
||||
icon: <FaUsers />,
|
||||
}),
|
||||
);
|
||||
const blueSortDropdownButton = getByText('Filter');
|
||||
await waitFor(() => {
|
||||
expect(blueSortDropdownButton).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
@ -14,14 +14,12 @@ it('Checks the default top option is Ascending', async () => {
|
||||
const sortByEmail = getByText('Email');
|
||||
fireEvent.click(sortByEmail);
|
||||
|
||||
expect(setSorts).toHaveBeenCalledWith([
|
||||
{
|
||||
label: 'Email',
|
||||
key: 'email',
|
||||
icon: <FaEnvelope />,
|
||||
order: 'asc',
|
||||
},
|
||||
]);
|
||||
expect(setSorts).toHaveBeenCalledWith({
|
||||
label: 'Email',
|
||||
key: 'email',
|
||||
icon: <FaEnvelope />,
|
||||
order: 'asc',
|
||||
});
|
||||
});
|
||||
|
||||
it('Checks the selection of Descending', async () => {
|
||||
@ -42,12 +40,10 @@ it('Checks the selection of Descending', async () => {
|
||||
const sortByEmail = getByText('Email');
|
||||
fireEvent.click(sortByEmail);
|
||||
|
||||
expect(setSorts).toHaveBeenCalledWith([
|
||||
{
|
||||
label: 'Email',
|
||||
key: 'email',
|
||||
icon: <FaEnvelope />,
|
||||
order: 'desc',
|
||||
},
|
||||
]);
|
||||
expect(setSorts).toHaveBeenCalledWith({
|
||||
label: 'Email',
|
||||
key: 'email',
|
||||
icon: <FaEnvelope />,
|
||||
order: 'desc',
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { DocumentNode } from 'graphql';
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
Companies_Bool_Exp,
|
||||
People_Bool_Exp,
|
||||
Users_Bool_Exp,
|
||||
} from '../../../generated/graphql';
|
||||
|
||||
export type SortType<SortKey = string> = {
|
||||
label: string;
|
||||
@ -6,20 +12,30 @@ export type SortType<SortKey = string> = {
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export type FilterType<FilterKey = string> = {
|
||||
label: string;
|
||||
key: FilterKey;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export type SelectedFilterType = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
operand: { id: string; label: string };
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export type SelectedSortType<SortField = string> = SortType<SortField> & {
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export type FilterType<WhereTemplate, T = Record<string, string>> = {
|
||||
label: string;
|
||||
key: string;
|
||||
icon: ReactNode;
|
||||
whereTemplate: (operand: FilterOperandType, value: T) => WhereTemplate;
|
||||
searchQuery: DocumentNode;
|
||||
searchTemplate: (
|
||||
searchInput: string,
|
||||
) => People_Bool_Exp | Companies_Bool_Exp | Users_Bool_Exp;
|
||||
searchResultMapper: (data: any) => { displayValue: string; value: T };
|
||||
};
|
||||
|
||||
export type FilterOperandType = {
|
||||
label: string;
|
||||
id: string;
|
||||
keyWord: 'ilike' | 'not_ilike';
|
||||
};
|
||||
|
||||
export type SelectedFilterType<WhereTemplate> = FilterType<WhereTemplate> & {
|
||||
value: string;
|
||||
operand: FilterOperandType;
|
||||
where: WhereTemplate;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user